Skip to content

Context management

Long agent sessions hit two failure modes the model can’t recover from on its own:

  1. The context window fills up. Every turn appends to the prompt; eventually the next turn errors out with “context window exceeded.”
  2. 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.

MechanismDefaultCLI flag to disableSlash command
Compactionon--no-compact/compact [focus] (alias /summarize)
Task-boundary checkpointson--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.


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.

  • Automatic: post-turn hook checks utilization against the configured per-tier threshold; when over, the next Run drains a compactionPending flag 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 optional focus argument biases the summary toward a particular thread when you want to preserve specific context.

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:

TierDefault triggerExamples
frontier0.85 (unchanged)claude-opus-4-*, gemini-3.x-pro, gemini-3.6-flash
mid0.65claude-sonnet-4-*, gemini-3.5-flash, gemini-2.5-pro
small0.35claude-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.

A five-section “teammate handover”:

# Current state
The exact user request. What's been completed. What's actively in progress. What's specifically remaining.
# Files & changes
Files modified, read, or analyzed. Critical code locations with line numbers when known.
# Technical context
Architectural decisions made and why. Patterns adopted. Commands that worked or failed.
# Strategy & approach
The strategy chosen. Alternatives considered and rejected. Gotchas. Blockers.
# Exact next steps
A concrete numbered list of the next developer-style actions.

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> 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:

ClassDefault model tierCompaction thresholdAsk modeToolsPlan-firstWhen to use
debugfrontier (e.g. claude-opus-4-7, gemini-3.6-flash)0.65autobuilt-ins − bashonBug hunts, root-cause investigations, multi-file traces
implementfrontier0.70autobuilt-insoffFeature work, multi-file refactors
chatmid (e.g. claude-sonnet-4-6, gemini-3.5-flash)0.85autobuilt-insoffQ&A, pairing, lightweight design discussion
researchmid0.65allowbuilt-ins − bashonRead-heavy codebase exploration; allow keeps the ask-mode noise out of the way
reviewfrontier0.75autobuilt-ins − bashonPR / diff review

Resolution per-provider:

TierGemini / VertexAnthropic
frontiergemini-3.6-flashclaude-opus-4-7
midgemini-3.5-flashclaude-sonnet-4-6
smallgemini-3.5-flash-liteclaude-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-flash uses debug-mode defaults but a specific model. A model set in the config file (model.name) is likewise respected; --task only fills in the tier model when neither --model nor a config-file model is set.
  • --compaction-threshold=<0..1> pins the post-turn compaction trigger, overriding both the config-file compaction.threshold and the class default.
  • --ask=off|stdin|auto pins 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=bash gives you the shell under debug defaults.
  • --plan-mode=off|advisory|required pins plan mode; --task=debug --plan-mode=off keeps the reduced tool set without the plan requirement, and --plan-mode=advisory keeps the plan artifact without the gate. (--plan-first[=false] is the deprecated two-state spelling.)

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-tools cancels the profile, not your config. It cannot re-enable a tool you turned off in tools.disable or --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=debug hardens the parent and its subagents together.
  • Plan-first gates fetch_url and spawn_agent too, not just writes. Under --task=research the 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=off opts out, and --plan-mode=advisory keeps 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).

  • 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_tier config — those still win for their specific tier even when a task class sets the fallback Threshold. Operators who’ve carefully tuned per-tier thresholds keep them.

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-parentBehavior
warn (default)Logs the notice and proceeds.
refuseExits with config-error code. Useful for supervised deploys.
allowSuppresses 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.


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:

BoundCLI flagConfig fieldWhat it caps
Per-turn--max-turn-cost-usd=<N>agent.max_turn_cost_usdCumulative 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_usdCumulative spend across all turns since the agent started
  1. 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.
  2. If either configured bound is met or exceeded, the agent emits a structured turn-error event with kind=cost_ceiling, message describing the spend + bound, and retryable=false.
  3. The turn in flight is cancelled, and a flag is set; the next Run call returns the same error immediately without invoking the model.
  4. The operator clears the flag to resume — /guardrail reset in the TUI, POST /sessions/{id}/guardrails/reset over attach (#666), or Agent.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.

Both backstops share one recovery surface (#666):

SurfaceRead stateReset
In-process TUI/guardrail/guardrail reset [watchdog|cost_ceiling|all] [+<usd>]
Attach HTTPGET /sessions/{id}/guardrailsPOST /sessions/{id}/guardrails/reset (body optional)
LibraryAgent.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.

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.

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:

Terminal window
core-agent -p "..." --max-session-cost-usd=0 # flag
# or "agent": { "max_session_cost_usd": 0 } # config

Two recommended starting postures:

Terminal window
# Interactive desktop / dev — bound a single turn so a runaway can't
# burn more than a coffee's worth before refusing
core-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 budget
core-agent --no-repl --attach-listen=127.0.0.1:7777 \
--max-turn-cost-usd=1.00 --max-session-cost-usd=20.00

Tune from your own usage — /stats shows current session cost; pick bounds at ~5x your normal turn / session spend so genuine work doesn’t trip.

  • 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.

ModeWhat it does
offNo observation.
warnObserves 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.
feedbackWarn, 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.
enforceFeedback, plus a Critical runaway signal (today: repeated-tool-call or alternating-tool-cyclenot 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:

  • enforce implies feedback. 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. ResetWatchdog therefore 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.
  • warn is 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 existing warn operator is already running.
  • Two readers, two texts. watchdog.Alert.Reason is operator-facing and may name operator controls (/interrupt, --max-turn-cost-usd); Alert.Guidance is model-facing and names none of them. A custom Signal that sets no Guidance falls back to Reason, 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.

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.

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.

SignalSeverityTrips whenCatches
repeated-tool-callCriticalThe same tool is called 5 times in a row with equivalent argsThe read_file loop from #144
alternating-tool-cycleCriticalThe same sequence of 2–4 calls repeats 3 times with identical args each lapThe list_agents → check_agent loop that survived stop and /interrupt in the PR #622 GKE UAT
tool-failure-streakWarn3 tool calls in a row all return errors, with none succeeding in betweenAn 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, so main.go, ./main.go and dir/../main.go are one call; the consecutive detector additionally treats /workspace/main.go and main.go as one, since a genuine path suffix on a component boundary is the same file. a/doc.go and b/doc.go stay distinct — a basename match would false-positive on every repo with repeated filenames. Non-path values (a grep pattern, a bash command) 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 → b doesn’t alert twice for presenting as b → a on 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 → a is repeated-tool-call alone.
  • 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_verify exists for; an embedder who wants the pattern anyway can construct watchdog.DefaultWatchdog with their own signal list.

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, under feedback, 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 feedback a re-emitting signal is a prompt leak.
  • Success and failure follow ADK’s convention: a reserved error key 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 Watchdog sees them only if it implements watchdog.ToolResultObserver; the Watchdog interface itself is unchanged, so a third-party implementation doesn’t break to gain a signal it may not want.

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).
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.


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.

Terminal window
# 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 tools
core-agent --agentic-tools=false
WrapperInner toolsReplaces
agentic_read_fileread_filebare read_file for large files
agentic_fetch_urlfetch_urlbare fetch_url for long pages
agentic_grepgrep + read_filebare grep when matches will be many
agentic_researchread_file + grep + list_dir + globopen-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.

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.

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.


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.

  • 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 next Run drains 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.

A six-section completion record:

# Task
What was the task? What's the headline outcome?
# Files & changes
Files modified, read, or analyzed. Files considered and NOT changed (with why).
# Technical context
Architectural decisions, patterns, commands that worked or failed.
# Strategy & approach
Strategy chosen, alternatives rejected, gotchas, lessons.
# Verification & next steps
What's been verified, what's known-good but unverified, follow-up work queued.
# Where we are
Status 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.

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.


/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.


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 /done or the model misses a natural boundary.

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 /context shows.
  • Agent.HasCompactor() bool / Agent.HasCheckpointer() bool — predicates for host adapters gating slash commands.