Context management
Long agent sessions hit two failure modes the model can’t recover from on its own:
- The context window fills up. Every turn appends to the prompt; eventually the next turn errors out with “context window exceeded.”
- Raw tool output bloats the parent. A 5,000-line file read, a 200KB URL fetch, a grep with hundreds of matches — each dumps that volume into the parent’s window even while it’s still working, slowing every subsequent turn and crowding out the actual task.
core-agent ships three mechanisms — designed together, deployed independently — to keep long sessions alive. All three are on by default. See docs/context-management-design.md for the full design rationale.
| Mechanism | Default | CLI flag to disable | Slash command |
|---|---|---|---|
| Compaction | on | --no-compact | /compact [focus] (alias /summarize) |
| Task-boundary checkpoints | on | --no-checkpoint | /done [note] (alias /checkpoint) |
| Agentic tool wrappers (subtasks) | on | --agentic-tools=false | (model-driven via agentic_* tools) |
A fourth — /context (alias /boundaries) — is an observation surface, not a mechanism: it reports the shape of what the others have done this session.
Compaction (Mechanism A)
Section titled “Compaction (Mechanism A)”The reactive backstop. When the context window fills past a per-model-tier threshold (default 0.85 for frontier, 0.65 for mid, 0.35 for small-tier models since v2.5), the agent automatically compacts the conversation into a “teammate handover” summary and slices the pre-summary history out of future requests. The full audit log is preserved on disk — only the live LLM request is sliced.
How it fires
Section titled “How it fires”- Automatic: post-turn hook checks utilization against the configured per-tier threshold; when over, the next
Rundrains acompactionPendingflag by writing the summary before its actual work. The operator-visible turn boundary stays clean — no surprise latency cliff after the assistant finishes. - Manual:
/compact [focus]runs the same summarizer immediately. The optionalfocusargument biases the summary toward a particular thread when you want to preserve specific context.
Per-tier thresholds (since v2.5)
Section titled “Per-tier thresholds (since v2.5)”A single 0.85 threshold worked for frontier-tier models (Opus, Pro) but fired far too late for small-tier models (Flash, Haiku) — reasoning quality on those tiers degrades well before they reach 85% context utilization. The per-tier defaults trigger earlier on smaller models so the session stays inside its effective working range:
| Tier | Default trigger | Examples |
|---|---|---|
frontier | 0.85 (unchanged) | claude-opus-4-*, gemini-3.x-pro, gemini-3.6-flash |
mid | 0.65 | claude-sonnet-4-*, gemini-3.5-flash, gemini-2.5-pro |
small | 0.35 | claude-haiku-4-*, gemini-3.5-flash-lite, gemini-3.1-flash, gemini-2.5-flash |
Tier classification is by substring match against the model ID — see pkg/modeltier. Unknown models fall back to the single compaction.threshold setting (default 0.85).
Override per-tier defaults in .agents/config.json:
{ "compaction": { "threshold": 0.85, "threshold_by_tier": { "small": 0.30 } }}Only set tiers you want to override; the rest take their substrate defaults.
What the summary contains
Section titled “What the summary contains”A five-section “teammate handover”:
# Current stateThe exact user request. What's been completed. What's actively in progress. What's specifically remaining.
# Files & changesFiles modified, read, or analyzed. Critical code locations with line numbers when known.
# Technical contextArchitectural decisions made and why. Patterns adopted. Commands that worked or failed.
# Strategy & approachThe strategy chosen. Alternatives considered and rejected. Gotchas. Blockers.
# Exact next stepsA concrete numbered list of the next developer-style actions.When to disable
Section titled “When to disable”Pass --no-compact for short headless one-shots where compaction would never fire anyway, or when debugging issues where you don’t want history rewrites in play. /compact remains available as a manual command regardless of the flag.
Task class (since v2.5)
Section titled “Task class (since v2.5)”--task=<class> is a single flag that picks a coherent bundle of defaults tuned for the kind of work the operator is sitting down to do. Operator-declared (not LLM-inferred) — the operator knows whether they’re debugging or chatting; asking them to type one flag is cheaper and more predictable than any classifier we could build.
Five classes ship today:
| Class | Default model tier | Compaction threshold | Ask mode | Tools | Plan-first | When to use |
|---|---|---|---|---|---|---|
debug | frontier (e.g. claude-opus-4-7, gemini-3.6-flash) | 0.65 | auto | built-ins − bash | on | Bug hunts, root-cause investigations, multi-file traces |
implement | frontier | 0.70 | auto | built-ins | off | Feature work, multi-file refactors |
chat | mid (e.g. claude-sonnet-4-6, gemini-3.5-flash) | 0.85 | auto | built-ins | off | Q&A, pairing, lightweight design discussion |
research | mid | 0.65 | allow | built-ins − bash | on | Read-heavy codebase exploration; allow keeps the ask-mode noise out of the way |
review | frontier | 0.75 | auto | built-ins − bash | on | PR / diff review |
Resolution per-provider:
| Tier | Gemini / Vertex | Anthropic |
|---|---|---|
| frontier | gemini-3.6-flash | claude-opus-4-7 |
| mid | gemini-3.5-flash | claude-sonnet-4-6 |
| small | gemini-3.5-flash-lite | claude-haiku-4-5 |
Explicit per-knob flags always win over the class defaults:
--model(long-form alias of-m) pins the model — e.g.--task=debug --model=gemini-3.6-flashuses debug-mode defaults but a specific model. A model set in the config file (model.name) is likewise respected;--taskonly fills in the tier model when neither--modelnor a config-file model is set.--compaction-threshold=<0..1>pins the post-turn compaction trigger, overriding both the config-filecompaction.thresholdand the class default.--ask=off|stdin|autopins the ask-user mode; left unset, the class default applies.--enable-tools=<names>adds back a built-in the class dropped —--task=debug --enable-tools=bashgives you the shell under debug defaults.--plan-mode=off|advisory|requiredpins plan mode;--task=debug --plan-mode=offkeeps the reduced tool set without the plan requirement, and--plan-mode=advisorykeeps the plan artifact without the gate. (--plan-first[=false]is the deprecated two-state spelling.)
Tools and plan-first (since v2.9)
Section titled “Tools and plan-first (since v2.9)”The three investigation-shaped classes — debug, research, review — drop bash from the built-in set and turn on plan-first gating (plan_mode: "required"). Both defaults come from the same measured session (#160): the model reached for bash $ grep -rn on its first tool call with the native grep tool sitting in the schema, and emitted zero plan sentences before acting. implement keeps the shell because edit-then-test cycles need it and plan-first would gate the very edits the class exists to make; chat isn’t investigation-shaped at all.
Dropping bash is the blunt version of the bash search gate, which refuses only the search-shaped subset. They compose: --task=debug --enable-tools=bash puts the shell back and the search gate still refuses bash grep.
Three things to know:
--enable-toolscancels the profile, not your config. It cannot re-enable a tool you turned off intools.disableor--disable-tools— asking for both is a startup error rather than a silent win for either side. Naming a tool the class never dropped is a harmless no-op.- Subagents inherit the reduction. A declarative subagent draws from the parent’s already-gated catalog, so
--task=debughardens the parent and its subagents together. - Plan-first gates
fetch_urlandspawn_agenttoo, not just writes. Under--task=researchthe model records a plan before its first fetch. That is the intended discipline, but it is a real behavior change for scripted research runs —--plan-mode=offopts out, and--plan-mode=advisorykeeps the recorded plan while unblocking the run.
Plan-first needs somewhere to write plans. If no .agents/ directory was resolved, --no-builtin-tools is set, or record_plan is disabled, the class default is suppressed with a startup line saying which of those it was — a plan-first gate with no record_plan denies every mutating call for the life of the session and nothing can clear it (/replan only revokes a plan; it can’t grant one). An explicit --plan-mode=required (or the deprecated --plan-first / require_plan_artifact: true) is still honored in that situation, but startup warns. Advisory mode can’t deadlock — it arms no gate — but it goes inert under the same conditions, since there is no record_plan to write the artifact.
Config-file equivalent:
{ "session": { "task_class": "debug" } }Useful for project-local defaults (an infra repo where debugging is the typical workload sets task_class: debug once and operators don’t have to remember).
What --task does NOT change
Section titled “What --task does NOT change”- Agentic-tools — already on by default since v2.1; every task class wants it on.
--agentic-small-model— per-provider default already picked by #122.- Per-tier compaction thresholds in
compaction.threshold_by_tierconfig — those still win for their specific tier even when a task class sets the fallbackThreshold. Operators who’ve carefully tuned per-tier thresholds keep them.
Small-tier-parent guard (since v2.5)
Section titled “Small-tier-parent guard (since v2.5)”The --task flag picks a sensible model tier for each class, but explicit --model always wins. When the operator’s explicit choice (or their config-file default) lands on a small-tier model (Flash, Haiku, etc.) for the parent, a startup-time guard fires by default (#121):
core-agent: small-tier parent: gemini-2.5-flash is a small-tier model. Small-tier models work well as subtask workers (--agentic-small-model) but loop and stall as the parent for long interactive sessions. Consider a frontier or mid-tier model for the parent — e.g. --model gemini-3.6-flash --agentic-small-model gemini-2.5-flash. Pass --small-tier-parent=allow to suppress this notice.Modes:
--small-tier-parent | Behavior |
|---|---|
warn (default) | Logs the notice and proceeds. |
refuse | Exits with config-error code. Useful for supervised deploys. |
allow | Suppresses the check entirely. |
Skipped regardless when -p (one-shot — operator may be scripting Flash on purpose), --yolo (trust-the-operator), or the resolved model’s tier doesn’t classify (unknown / future model).
Config-file equivalent: safety.small_tier_parent. CLI overrides config; default is warn.
The 2026-06-08 smoke that motivated this guard burned ~$80 across three sessions on gemini-3.5-flash as the parent — the same bug an Opus-tier session found in a handful of turns.
Cost ceiling (kill switch — since v2.5)
Section titled “Cost ceiling (kill switch — since v2.5)”Compaction and watchdog signals catch behavioral runaway (context fill, repeated tool calls). They don’t bound the outcome — a model can produce many tool calls in a single turn. The cost ceiling is the dollar-denominated guard for that case, and since #720 it is checked during the turn rather than only after it.
Two bounds, both optional, both off by default:
| Bound | CLI flag | Config field | What it caps |
|---|---|---|---|
| Per-turn | --max-turn-cost-usd=<N> | agent.max_turn_cost_usd | Cumulative spend of a single conversation turn (every model call + subtask between one operator inject and agent-done state) |
| Per-session | --max-session-cost-usd=<N> | agent.max_session_cost_usd | Cumulative spend across all turns since the agent started |
What happens when a ceiling trips
Section titled “What happens when a ceiling trips”- Session cost (from the usage tracker) and per-turn delta (against a snapshot taken at turn start) are computed as the turn runs, on each event, and again at the turn boundary.
- If either configured bound is met or exceeded, the agent emits a structured
turn-errorevent withkind=cost_ceiling, message describing the spend + bound, andretryable=false. - The turn in flight is cancelled, and a flag is set; the next
Runcall returns the same error immediately without invoking the model. - The operator clears the flag to resume —
/guardrail resetin the TUI,POST /sessions/{id}/guardrails/resetover attach (#666), orAgent.ResetCostCeiling()when embedding the library.
Checking in-turn is what makes this a backstop rather than a receipt. A runaway is a loop inside one turn — model, tool, model, tool — and the tracker grows on every model call within it. Through v2.9.0-dev.0 enforcement ran only at turn boundaries (the post-turn hook, plus #362’s settle-time re-check at the top of the next turn), so a single runaway turn was capped only after it had finished spending, and a turn that never terminated was never capped at all. Note the consequence: crossing a ceiling now kills the turn in progress and discards its partial answer, the same as an operator /interrupt. That is the intended trade for a bound whose whole job is to stop spending.
A per-session trip needs more than a bare reset. The accumulator is already at or past the ceiling, so clearing the flag alone re-trips on the very next turn — the reset surface refuses that case outright (HTTP 409) and asks for additional_budget_usd, which RAISES the ceiling. It never zeroes the accumulator or restarts a spend window: /usage, the eventlog-derived cost, and the ceiling check all keep counting the same dollars, so a session that spent $12 still reports $12 after the operator hands it another $5 of runway. A per-turn trip needs no budget — the next turn starts from a fresh baseline.
Resetting a tripped guardrail
Section titled “Resetting a tripped guardrail”Both backstops share one recovery surface (#666):
| Surface | Read state | Reset |
|---|---|---|
| In-process TUI | /guardrail | /guardrail reset [watchdog|cost_ceiling|all] [+<usd>] |
| Attach HTTP | GET /sessions/{id}/guardrails | POST /sessions/{id}/guardrails/reset (body optional) |
| Library | Agent.WatchdogTripped() / Agent.CostCeilingTripped() | Agent.ResetWatchdog() / Agent.ResetCostCeiling() + Agent.AddSessionCostBudget(usd) |
/guardrail with no arguments prints what is armed, what tripped, why, and — when a bare reset would re-trip — how much budget to add. The reset is SessionWrite, not admin: the next thing an operator does after clearing a halt is POST /inject, which is itself SessionWrite, so gating the reset harder would buy no safety.
Halts survive a restart
Section titled “Halts survive a restart”A halt that a restart clears is not a halt. Since v2.9.0-dev (#643) both trips — and the operator resets that clear them — are written to the eventlog and folded forward by the next process over the same session. A crash, an OOM kill, or a pod roll no longer hands a runaway loop a fresh budget, which matters most for exactly the unattended deployments #642 turned these backstops on for. Budget an operator granted before the restart is preserved too, so a resumed session doesn’t re-halt at the old bar.
Restored state never overrules live configuration: a daemon restarted with --watchdog=warn does not resurrect an enforce-mode halt, and granted budget is not applied to a per-session ceiling that is no longer configured. Restore also fails open — if the guardrail history can’t be read, the session runs rather than being bricked by a transient database error. Durability requires a session store (--session-db / WithEventLog); with no eventlog, behavior is unchanged.
Why “stop, get attention” instead of throttle
Section titled “Why “stop, get attention” instead of throttle”A cost-ceiling trip almost always means something is wrong — a tool-call loop (#144), a model going off the rails, an unexpectedly expensive prompt. Auto-resume would just continue burning budget. The explicit operator reset forces a human look-in.
Defaults and posture
Section titled “Defaults and posture”The per-turn bound is off by default. The per-session bound is off for interactive runs and $10.00 for unattended runs — -p one-shot, a --no-repl daemon, or any run whose stdin isn’t a TTY (#642). An unattended agent has nobody watching the spend, so “off until configured” meant every deploy that forgot the flag ran unbounded.
To opt an unattended run back out, say so explicitly — an explicit 0 from either source beats the default:
core-agent -p "..." --max-session-cost-usd=0 # flag# or "agent": { "max_session_cost_usd": 0 } # configTwo recommended starting postures:
# Interactive desktop / dev — bound a single turn so a runaway can't# burn more than a coffee's worth before refusingcore-agent --max-turn-cost-usd=0.50
# Long-running autonomous deploy — bound the whole session so a slow# burn over hours doesn't quietly exceed the deploy's budgetcore-agent --no-repl --attach-listen=127.0.0.1:7777 \ --max-turn-cost-usd=1.00 --max-session-cost-usd=20.00Tune from your own usage — /stats shows current session cost; pick bounds at ~5x your normal turn / session spend so genuine work doesn’t trip.
Composition with the other mechanisms
Section titled “Composition with the other mechanisms”- Compaction (above) caps context not money.
- Cost ceiling caps money regardless of why.
- Watchdog (below) catches behavioral patterns (repeated identical tool calls) without waiting for the dollar count to add up.
All three are complementary. Both the session cost ceiling and the watchdog resolve their default by mode: active backstops when unattended, advisory when an operator is at the keyboard.
Watchdog (behavioral observer — since v2.5)
Section titled “Watchdog (behavioral observer — since v2.5)”Compaction caps the context dimension. The cost ceiling caps the dollar dimension. The watchdog catches the behavioral dimension — a session going off-rails (an agent stuck calling read_file on the same path five times in a row, the #144 pattern, or cycling between two calls forever) before the dollar count gets large enough to trip the cost ceiling.
The modes are a ladder — each one includes everything the mode above it in this table does.
| Mode | What it does |
|---|---|
off | No observation. |
warn | Observes the tool-call stream. When a signal trips, logs a structured alert to the operator via the normal status channel (send() callback for CLI; future SSE event for attach-mode). Does NOT pause the turn, and does not tell the model anything. |
feedback | Warn, plus the observation is injected into the model’s next-turn context as a [watchdog] block (#159). A correction, not a backstop — nothing halts a model that reads the block and loops anyway. |
enforce | Feedback, plus a Critical runaway signal (today: repeated-tool-call or alternating-tool-cycle — not the Warn-level tool-failure-streak) halts the agent: it cancels the turn in flight, emits a turn-error (kind=watchdog, non-retryable), and refuses new turns until the operator clears it (/guardrail reset, POST /sessions/{id}/guardrails/reset, or Agent.ResetWatchdog when embedding). This is the hard behavioral backstop — an auto-continue re-drive of the interrupted turn is refused at pre-flight instead of re-issuing the looping call. |
Feedback: telling the model what it is doing
Section titled “Feedback: telling the model what it is doing”warn and enforce both route the observation to an operator — a log line, or a halt that waits for one. Neither tells the party actually choosing the next tool call. Under feedback and above, the next turn’s prompt is prefixed with a block like:
[watchdog] Automated observation about your own previous turn — this is not a message from the user, and the user cannot see it.- repeated-tool-call: You called read_file 5 times in a row with byte-identical arguments ({"path":"a.txt"}). The same call with the same arguments returns the same result, so repeating it cannot make progress. Change the arguments, use a different tool, or — if you have no next step that differs — stop calling tools and say what you are stuck on. Do not repeat this call unchanged.Adjust your approach on this turn accordingly.Notes on the contract:
enforceimpliesfeedback. An enforce halt is cleared by an operator reset, and the reset resumes a model whose context still ends in the loop it was halted for. Without the injection, the reset is a treadmill: the same five calls, the same halt, one operator round-trip later.ResetWatchdogtherefore clears the halt but keeps the queued observation, and a halt restored from the eventlog after a restart re-synthesizes it from the persisted reason.warnis unchanged and injects nothing. Feedback is its own rung precisely so turning it on is a decision, not a silent rewrite of the context every existingwarnoperator is already running.- Two readers, two texts.
watchdog.Alert.Reasonis operator-facing and may name operator controls (/interrupt,--max-turn-cost-usd);Alert.Guidanceis model-facing and names none of them. A customSignalthat sets noGuidancefalls back toReason, so a third-party detector is never silently inert under feedback. - Not a trust boundary. The block is framed as an automated observation, but a user prompt can contain the literal string
[watchdog], exactly as it can contain[Inbox]. Treat it as steering, not authentication. - The queue is bounded (4 alerts, oldest dropped), and nothing is queued while the mode is below
feedback— flipping the mode later can’t deliver a stale backlog.
Choosing the mode
Section titled “Choosing the mode”Precedence is --watchdog > safety.watchdog > a mode-dependent default, mirroring --small-tier-parent / safety.small_tier_parent:
{ "safety": { "watchdog": "enforce" } }The config field (#660) exists so a recipe is a self-contained unit — before it, --watchdog was CLI-only, so a hardened recipe still depended on every deploy manifest and every core-agent -c ... invocation remembering the flag by hand.
With neither source set, the default is enforce for unattended runs (-p, --no-repl, or a non-TTY stdin) and warn for interactive REPL/TUI runs (#642). The split is about who reads the alert: an interactive operator sees the warning and can hit Ctrl-C, so halting on their behalf is presumptuous. Nobody reads a daemon’s warn-mode log in time, which made warn indistinguishable from off exactly where the backstop mattered. Pass --watchdog=warn (or set the config field) to restore observe-only on an unattended run.
The resolved mode applies to every agent the process hosts, including the sessions a multi-session daemon creates through POST /sessions. The startup line names the source it came from and what the mode actually does, e.g. watchdog: enforce mode [unattended default] (…; injects the observation into the model's next turn; halts the turn in flight and refuses new ones until cleared with /guardrail reset, …).
feedback is the mode for a run where you want the agent to self-correct without a halt — an interactive session, or an autonomous job where stopping is more expensive than a few wasted turns. It is weaker than the unattended default, so an unattended run only gets it by asking for it explicitly.
Enforce mode mirrors the cost ceiling’s halt contract (above): a trip sets a flag, the next Run refuses at pre-flight, and recovery is operator-driven (/guardrail reset, which also resets the signal’s run-length state). There is no automatic reset — a tripped watchdog is a “stop, get human attention” signal, not a throttle. Only Critical signals halt; a hypothetical future low-severity signal would stay advisory even under enforce.
Detection is in-turn, not just at the turn boundary (#705). A tool loop is a loop inside one turn — model, tool, model, tool — and through v2.9.0-dev.0 the signals were only drained after the turn returned, so the halt arrived for a turn that had already finished burning budget. A turn that loops forever never reaches that boundary at all, which made enforce a no-op against precisely the shape it exists to catch. Under enforce the alerts are now drained as each tool call is observed and a Critical trip cancels the in-flight turn immediately (the same cancellation path as an operator /interrupt, but recorded as a watchdog halt, not an interrupt). warn and feedback keep post-turn timing — neither halts anything, and moving their log line or their injection earlier would only change when the operator reads it.
Future modes — prompt (pause turn + ask operator via the existing permissions prompter) and auto (call Agent.SwapModel to escalate to a frontier model without operator interaction) — are designed but deferred. Same for the remaining designed signals (tools-without-text, files-not-touched, context-growth-rate, cost-burn-rate), semantic loop detection (“these two calls ask the same question differently”), and an operator /escalate slash for manual model swaps.
Signals
Section titled “Signals”Three signals ship. The two loop detectors are Critical — they halt under enforce and reach the model under feedback. The failure-streak signal is Warn: it never halts, in any mode.
| Signal | Severity | Trips when | Catches |
|---|---|---|---|
repeated-tool-call | Critical | The same tool is called 5 times in a row with equivalent args | The read_file loop from #144 |
alternating-tool-cycle | Critical | The same sequence of 2–4 calls repeats 3 times with identical args each lap | The list_agents → check_agent loop that survived stop and /interrupt in the PR #622 GKE UAT |
tool-failure-streak | Warn | 3 tool calls in a row all return errors, with none succeeding in between | An agent with no tool-verified evidence about anything — the state it was in when it reported an incident “fully resolved” in the same UAT (#639) |
Both were added because the v1 detector documented its own evasions (#649): “consecutive” means a run of one call, so wedging a second call into the loop hid it, and literal-string arg comparison meant main.go and /workspace/main.go read as two different calls.
- Args are path-canonicalized, narrowly. Values under path-shaped keys (
path,file_path,dir,target, …) are cleaned, somain.go,./main.goanddir/../main.goare one call; the consecutive detector additionally treats/workspace/main.goandmain.goas one, since a genuine path suffix on a component boundary is the same file.a/doc.goandb/doc.gostay distinct — a basename match would false-positive on every repo with repeated filenames. Non-path values (agreppattern, abashcommand) are never normalized even when they look like paths. - One alert per stuck pattern, not one per call past the threshold — including across rotations, so
a → b → a → bdoesn’t alert twice for presenting asb → aon the next call. - A pure repeat only raises one alert. The cycle detector skips blocks made of a single repeated call, so
a → a → a → a → a → aisrepeated-tool-callalone. - Two laps is not a cycle. Read-grep-read-grep is ordinary exploration. Three laps with byte-identical arguments each time is not: nothing in the inputs changed, so nothing in the results can have.
- The known false positive is a hand-rolled polling loop written as alternating tool calls. That is what
wait_and_verifyexists for; an embedder who wants the pattern anyway can constructwatchdog.DefaultWatchdogwith their own signal list.
tool-failure-streak (v2.9+)
Section titled “tool-failure-streak (v2.9+)”Every other signal reads tool calls. This one reads outcomes, because the failure it exists for is invisible from calls alone: in the PR #622 GKE UAT an agent that could not reach its cluster at all reported the incident resolved — “everything is in tip-top shape” — with nothing having verified anything. The calls looked normal; the results were the story.
What it detects is deliberately narrow and objective — a run of calls that all came back as errors, with none succeeding in between. No prose is inspected. A detector that tried to recognize an over-confident claim would be a heuristic about English wearing the costume of a runtime guarantee, which is the defect class this release exists to remove. So this closes the evidence half of #639, not the honesty half: it tells a model that has been failing every call that it has verified nothing, at the point where it is most likely to start narrating instead of reporting. It cannot detect a confident conclusion drawn from tools that all succeeded and said nothing useful.
- Warn, never Critical. Under
enforce— the unattended default since #642 — a Critical alert halts the agent. Halting three denials into a legitimate RBAC probe would make the backstop the outage. A failure streak is an evidence problem, so it goes to the operator log and, underfeedback, to the model’s own next turn. Runaway behavior is already Critical via the loop detectors. - One success resets the run, and re-arms the alert. One success is evidence, and evidence is the thing being counted.
- One alert per streak, like the loop detectors — under
feedbacka re-emitting signal is a prompt leak. - Success and failure follow ADK’s convention: a reserved
errorkey inside the function response. The agent flattens it at the bridge, so the watchdog never has to know a provider’s response shape. - Tool outcomes are an optional observation. A custom
Watchdogsees them only if it implementswatchdog.ToolResultObserver; theWatchdoginterface itself is unchanged, so a third-party implementation doesn’t break to gain a signal it may not want.
Composition
Section titled “Composition”The watchdog is the behavioral signal layer. Paired with:
- Per-tier compaction thresholds (#119) — the context signal.
- Cost ceiling (#145) — the dollar signal. The hard backstop when behavioral signals miss.
- Task class (#123 PR 1) — the operator-declared posture layer (different signal, set up-front rather than detected at runtime).
Library usage
Section titled “Library usage”import ( "github.com/go-steer/core-agent/v2/pkg/agent" "github.com/go-steer/core-agent/v2/pkg/watchdog")
w := watchdog.NewDefaultWatchdog()a, err := agent.New(model, agent.WithWatchdog(w, func(alert watchdog.Alert) { log.Printf("watchdog: %s", alert) }), agent.WithWatchdogEnforce(), // optional: halt on Critical runaway // ... other options)The Watchdog interface lets you plug in a custom implementation (same composability pattern as Compactor / Checkpointer). For most operators the default — NewDefaultWatchdog() with RepeatedToolCallSignal(threshold=5) wired in — is sufficient.
Add agent.WithWatchdogEnforce() to promote it from observe-only to a kill switch: a Critical alert then trips Agent, and subsequent Run calls return an error satisfying agent.IsWatchdogTripped(err) until it is reset (a.ResetWatchdog() in-process; /guardrail reset or POST /sessions/{id}/guardrails/reset for operators). Query a.WatchdogTripped() for the (bool, reason) to surface in a /stats-style view.
Agentic tool wrappers (Mechanism B)
Section titled “Agentic tool wrappers (Mechanism B)”The proactive bloat prevention. Compaction and checkpoints are reactive — they clean up after raw tool output has already landed in the parent’s context. Agentic wrappers are proactive — they route the underlying tool call through a single-purpose subtask on a (typically cheaper) model so only the digest reaches the parent. The raw 5,000-line read never enters the parent’s context.
On by default since v2.1. Pass --agentic-tools=false to register only the bare tools.
Configuring
Section titled “Configuring”# Default — wrappers register; subtasks auto-route to the provider's# cheap-tier model (gemini-3.5-flash-lite on Gemini/Vertex, claude-haiku-4-5# on Anthropic). The cost-efficiency win activates without extra config.core-agent
# Pin a specific small model (cross-provider, custom tier, etc.)core-agent --agentic-small-model gemini-2.5-flash
# Pin subtasks to the parent's model (disable the cheap-tier default)core-agent --model claude-opus-4-7 --agentic-small-model claude-opus-4-7
# Opt out — register only the bare toolscore-agent --agentic-tools=falseThe four wrappers
Section titled “The four wrappers”| Wrapper | Inner tools | Replaces |
|---|---|---|
agentic_read_file | read_file | bare read_file for large files |
agentic_fetch_url | fetch_url | bare fetch_url for long pages |
agentic_grep | grep + read_file | bare grep when matches will be many |
agentic_research | read_file + grep + list_dir + glob | open-ended investigation |
Tool descriptions tell the model when to prefer the wrapper (“Use INSTEAD OF read_file (NOT IN ADDITION TO) when the file might be large…”). They also explicitly forbid the verify-with-bare-tool fallback (“Treat the digest as authoritative; DO NOT re-read with bare read_file to spot-check”). The framing pushes a model that wants to double-check toward refining the agentic call with a narrower question rather than re-fetching the raw content — defeats of the wrapper otherwise (see #59 for the smoke that motivated the wording). The wrappers share the parent’s permission gate and per-tool output caps — the subtask isn’t a security boundary, it’s a context isolation boundary.
Cost efficiency
Section titled “Cost efficiency”The wrappers’ point is the model-selection asymmetry: parent on a frontier model (Pro, Opus) does the reasoning; subtasks on a cheap tier (Flash, Haiku) do the content digestion. A subtask reading a 5,000-line file is ~95% prompt-context cost; offloading that to a model ~10x cheaper per-token routinely cuts session cost by 30-50% on long sessions.
Fresh-context invariant
Section titled “Fresh-context invariant”Each subtask sees ONLY its SystemPrompt + UserMessage. The parent’s history never reaches it. This is load-bearing: the subtask gets the full attention budget for one narrow question, and the parent’s prior turns can’t leak into a subtask’s work. The subtask’s events land in a parent-prefixed session row (<parent>:sub:<branch>) so the audit log stays correlated without polluting the parent’s session.
Task-boundary checkpoints (Mechanism C)
Section titled “Task-boundary checkpoints (Mechanism C)”The proactive task-slicing. Where compaction triggers on context pressure, checkpoints trigger on task completion — the model self-signals “this task is done” and a richer six-section completion record gets written, slicing the prior task’s exploration out of future requests so the next task starts with a clean working set.
How it fires
Section titled “How it fires”- Model-driven: at natural task boundaries the model calls the built-in
mark_task_done(detail)tool. The handler stashes the detail and flips a pending flag; the nextRundrains it by writing the checkpoint. - Operator-driven:
/done [note]slash (alias/checkpoint) does the same thing manually — useful when the model didn’t notice the boundary or when you want to force one before switching topics.
What the checkpoint contains
Section titled “What the checkpoint contains”A six-section completion record:
# TaskWhat was the task? What's the headline outcome?
# Files & changesFiles modified, read, or analyzed. Files considered and NOT changed (with why).
# Technical contextArchitectural decisions, patterns, commands that worked or failed.
# Strategy & approachStrategy chosen, alternatives rejected, gotchas, lessons.
# Verification & next stepsWhat's been verified, what's known-good but unverified, follow-up work queued.
# Where we areStatus framed as "what the operator and I both know right now."Why checkpoints help (vs. compaction alone)
Section titled “Why checkpoints help (vs. compaction alone)”Compaction triggers on token pressure — it might fire mid-task and the summary will reflect mid-task state. Checkpoints fire on natural boundaries the model recognizes, so the summary is task-complete-state rather than whatever-state-we-happened-to-be-in. Both write the same kind of slicing boundary event under the hood (session.Event.CustomMetadata["compaction"] = "checkpoint" vs "summary"); the differences are the trigger condition and the prompt that shapes the summary.
When to disable
Section titled “When to disable”Pass --no-checkpoint for runs where the model shouldn’t self-signal task completion, or when debugging where auto-slicing complicates reproduction. Both /done and the mark_task_done model-facing tool are removed when this flag is set; /help and /tools reflect that.
Observing the shape — /context
Section titled “Observing the shape — /context”/context (alias /boundaries) reports what the three mechanisms have done this session. Companion to /stats: where /stats shows token totals + cost, /context shows the shape of the conversation.
Context-management activity: Compactions: 1 (last 4m12s ago, focus: auth module) Checkpoints: 3 (last 51s ago, note: finished surveying messageKinds for the v3 design) Summarized: 8420 chars across all boundaries Subtasks: 2 (32919 in / 338 out tokens, $0.0107 rolled up to /stats total) Models: gemini-3.1-pro-preview-customtools (5 turns, 30822 in / 558 out, $0.0683) + gemini-2.5-flash (2 turns, 16520 in / 206 out, $0.0055)The Models row only appears when more than one model has been used this session (typical for --agentic-tools --agentic-small-model). Sorted by descending cost so the priciest model leads. The same breakdown also surfaces in /stats directly when multiple models are in play.
How they layer together
Section titled “How they layer together”The three mechanisms are designed to compose:
- Agentic wrappers prevent bloat from entering the parent in the first place (proactive).
- Checkpoints carve the session into focused task chunks at natural boundaries (semi-proactive).
- Compaction cleans up whatever still accumulates between boundaries (reactive backstop).
For a long autonomous run that needs to survive across many tasks, default-on compaction + default-on checkpoints + --agentic-tools --agentic-small-model is the recommended setup. Each layer makes the others more effective:
- The cheaper subtask cost makes compaction summaries less expensive (less raw output to summarize).
- Checkpoints between tasks mean compaction has less work to do (history is already mostly sliced).
- Compaction catches the case where you forget to
/doneor the model misses a natural boundary.
Library usage
Section titled “Library usage”From your own Go code:
import ( "github.com/go-steer/core-agent/v2/pkg/agent" "github.com/go-steer/core-agent/v2/pkg/tools/agentic")
a, err := agent.New(model, agent.WithCompactor(agent.NewDefaultCompactor()), agent.WithCheckpointer(agent.NewDefaultCheckpointer()), agent.WithUsageTracker(tracker),)For the agentic wrappers, use tools/agentic.AgenticReadFile, AgenticFetchURL, AgenticGrep, AgenticResearch. They take an AgenticToolOpts with AgentGetter (a late-binding closure — see agent.WithPostConstruct), Provider, SmallModelID, and InnerTools (the bare tools the subtask is allowed to call). See Library API → Context management for full signatures.
Direct programmatic access:
Agent.Compact(ctx, focus) (CompactionResult, error)— runs the summarizer synchronously.Agent.CompactIfNeeded(ctx, focus) (CompactionResult, error)— threshold-gated variant.Agent.Checkpoint(ctx, taskNote) (CheckpointResult, error)— writes a task-boundary checkpoint.Agent.RunSubtask(ctx, SubtaskSpec) (SubtaskResult, error)— the primitive the agentic wrappers are built on.Agent.ContextStats() ContextStats— snapshot the same data/contextshows.Agent.HasCompactor() bool/Agent.HasCheckpointer() bool— predicates for host adapters gating slash commands.
Where to go next
Section titled “Where to go next”- Interactive workflows — operator-side workflow context
- Library API — full signatures + extension points
- Autonomous runs — compaction makes long unattended runs viable
- Sessions and event log — how boundary events show up in the audit log
docs/context-management-design.md— full design rationale, alternatives considered, future roadmap (memory tools)