Binding spec for upgrading atn/providers/base.py:run_agent_loop (the
loop behind anthropic / openai_compat / ollama agents) and its adapters
to parity with current harnesses (Claude Code / Agent SDK, opencode,
Codex CLI). Grounded in: the 2026-07-04 live daemon session (findings
1-7 below), the loop audit, and the SOTA survey. The bridge (Claude
Agent SDK) keeps its own loop; only §10-11 touch it.
Live findings this spec answers:
- Unknown model strings silently route to BridgeProvider
(
provider_manager.py:483): aqwen3.5:4bagent burned 45k subscription tokens on the bridge. create_agenthas noproviderfield;update_agentprovider changes are inert (cached instance never evicted,execution_engine.py:847).- InputArbiter mic can be held by a dead WS surface forever; ghosts
stay in
_connectedand recapture the mic on every hand-off. All input surfaces wedge withinput_not_active. - Ollama requests never set
num_ctx; ollama silently truncated a 10,768-token request to 4096 (server.log) and the agent answered from hallucination. Non-bridge agents also get the full_SHELL_TOOLSschema block unconditionally (execution_engine.py:909). - Ollama multi-turn tool history is flattened to text
(
_normalize_content): the structuredtool_calls/role:"tool"linkage is lost after turn 1. - A transient stream drop mid-loop aborts the whole agent loop (no loop-level retry); compaction failure silently no-ops into a guaranteed overflow.
- Bridge +
claude-haiku-4-5main-loop model = silent hot-spin (100% core, zero events, 20+ min);kill_agentreported success but left the spinning bun subprocess alive. delegate_messagemid-turn injection races turn completion: a message written to bridge stdin as the agent loop ends is accepted ("User message injected") and silently dropped: the daemon reportsinjectedwith no consumption ack and no inbox fallback. (Observed live: injection at 01:32:45.685, execution completed 01:32:45.)
- Error classes are routes, not retries. Transient → bounded
resample. Overflow → context reduction, never retry. Fatal (auth,
invalid request) → structured abort. (opencode
retry.tssplit.) - Two-tier context reduction: prune before compact. Dropping old tool bodies is free; an LLM summary costs tokens and can fail.
- History must always be replayable. No orphaned
tool_usewithout atool_resulton any exit path. - Steering parity.
delegate_messagemust land mid-turn on the generic loop, not just the bridge. - Fail loud on routing. A model the daemon can't place is an error, not a silent fallback onto the user's subscription.
Wrap the per-turn send_stream call:
- Classify:
overflow(provider 400s whose message matches context / token-limit patterns; adapters map their provider's overflow shape to a sharedContextOverflowError),transient(timeouts, dropped streams, 5xx/429 that escaped adapter retries),fatal(everything else). - Transient: up to 3 loop-level retries, exp backoff 2s/8s/30s +
jitter; honor
Retry-Afterwhen surfaced. Retry re-sends the same request (history unchanged). - Overflow: jump to §2 reduction, then re-send once. Overflow after
reduction → abort
context_overflow. - Fatal: abort with
stop_reason="provider_error", error text in the result, orphan repair per §3.
Trigger pre-send: estimated next-request tokens (last response's
reported input + chars/4 of what was appended since, see §19) >
ctx_window − max_tokens − 8k buffer. ctx_window from
model_specs.get_context_window (see §7).
Tier 1, prune (no LLM call): replace tool_result bodies older than
the last 2 assistant turns with [pruned: <n> chars, tool=<name>],
protecting the most recent 40k chars of tool output. Only prune when
it saves ≥ 20k estimated chars.
Tier 2, compact (only if still over): summarize everything except (a) the original task message, (b) the last 2 turns, (c) the last ~20k chars of user messages, using the structured template: Goal / Progress / Key decisions / Critical context / Next steps.
- On summarizer failure: fallback hard truncation, dropping oldest turns (keeping (a) through (c)), never no-op.
- Spiral guard: max 2 compactions per run; if a compaction reduces
the estimate by <20%, abort
context_overflowinstead of looping. (Codex v0.112 / opencode #27924 class of bug.)
Any return/abort that leaves a trailing assistant message containing
tool_use blocks without matching results (budget_exceeded,
per_turn_input_exceeded, repeat abort, interrupt, provider_error)
appends synthetic tool_result blocks
({"cancelled": true, "reason": <abort reason>}, is_error=True)
before returning, so persisted history replays cleanly against
Anthropic-shape APIs.
- Exact-repeat threshold drops 5 → 3 (opencode
DOOM_LOOP_THRESHOLD). - Oscillation guard: over the last 6 calls, if ≤ 2 distinct
(tool, args-hash) fingerprints and every fingerprint appeared ≥ 2
times → abort
loop_detected. - Before aborting, inject one warning turn: a user message telling the model it appears to be looping and must change approach or conclude; abort only if the next call repeats again. (The model gets one chance to self-correct; my own harness does the same.)
Providerbase gainssend_user_message(content) -> boolbacked by anasyncio.Queue(_steering_queue). Returns True if an an agent loop is active to consume it.run_agent_loopdrains the queue at each iteration boundary (after tool results are appended, before the next send) and appends each item as ausermessage.execution_control.send_delegate_messagealready prefersprovider.send_user_message; with this, API-provider agents get live injection instead of the inbox fallback.- Delivery ack (finding 8, applies to bridge too): injection is
only "delivered" when consumed. Bridge:
claude-bridge.tsemits@@EVENT@@ user_message_consumedwhen the SDK loop actually reads the injected message;bridge.pytracks pending injections and, on agent-loop end with unconsumed injections, re-posts them to the agent's inbox (HIGH WORK) so the next run picks them up. Generic loop: same contract, where steering-queue items not drained by run end are re-posted to the inbox.delegate_messageresult distinguishesinjected(consumed or pending with fallback guarantee) so callers are never lied to.
Replace the tail-chop at _TOOL_RESULT_CHAR_MAX (40k) with head+tail:
first 24k + \n…[elided <n> chars of <total>]…\n + last 12k. Keeps
the command echo and the error tail (Codex shape). Cap unchanged.
get_context_window/ output caps come frommodel_specs.pyeverywhere;base.py:CONTEXT_WINDOWSbecomes a thin re-export or is deleted. Per-requestmax_tokens = min(16384, spec.max_output_tokens)instead of the hardcoded 16384.- Register local models in
model_specs.py:qwen3.5:4b,vibethinker-3b(context 16384 default,relative_cost=0, tier: tool-use). Unknown ollama models default to 16384, not 128k.
When an adapter degrades unparseable tool-call args to {"raw": ...},
the loop must NOT execute the tool. Return a synthetic error
tool_result ("arguments were not valid JSON; re-emit the call")
and continue the loop. Applies to all three adapters' parse paths.
- Translate canonical history natively: assistant
tool_useblocks → ollamamessage.tool_calls; canonicaltool_resultblocks →role:"tool"messages (withtool_name)._normalize_contentremains only for plain-text blocks. Round-trip test required: 2-turn tool conversation re-sent to ollama preserves the linkage. - Every request sets
options.num_ctx=get_context_window(model)(per §7; 16384 default for local models). Never rely on ollama's 4096 default. - Lean tool surface:
execution_engine.py:909stops appending_SHELL_TOOLSunconditionally for non-bridge providers; shell tools ride the existingtoolscategories (add a"shell"category) so a lean agent's prompt stays small. - Web access is the same shape (2026-09-18): bridges have
WebSearch/WebFetch natively under
"sdk_builtin"; API and local providers get the daemon'sweb_search/web_fetch(atn/web_tools.py, keyless, DuckDuckGo HTML + httpx) only when the agent is granted"web". Both are LOCAL tools like shell: the worker runs them in-process. No other bundle carries file or web access, and the common base prompt says so, so an agent without the grant asks instead of guessing thatatn_fullmight.
- Routing is explicit (decision 2026-10-06): every cognitive agent names
its
providerANDmodel. There is no daemon-wide default, no inheritance from the parent and no routing by model prefix; an agent missing either raisesProviderErrorat resolve time. A per-agentservice_providerbinding (or providerservice) names the model itself. Validation:provider_identity.routing_error. create_agentrequiresproviderandmodel(cognitive mode);update_agent/set_agent_modelcan change them but never clear them.- Agents written before the decision are pinned once at boot to what they
already resolved to (
atn/legacy_routing_migration.py), which also consumes and deletes the retired config.yamldefaults:section. update_agent: whenproviderormodelchanges, evict + close_active_providers[agent_id](same cleanup as unregister_agent, minus file deletion) so the change is live.
- Root-cause the haiku hot-spin in
bridge/claude-bridge.ts(SDKquery()with a model that is not loop-capable). Whatever the mechanism: errors fromquery()must surface as an@@EVENT@@ error+ request failure, never a bare loop continue. - First-event watchdog: if a sent agent-loop request produces zero
bridge events within 120s (configurable
ATN_BRIDGE_FIRST_EVENT_TIMEOUT), kill the subprocess and fail the request; don't wait for the 1800s idle ceiling. - Kill ladder:
kill_agent→ provider interrupt must escalate to subprocess termination (bun PID) within a 5s grace window, verified by the daemon (poll PID,taskkill /Ffallback). A "killed" execution must never leave the provider subprocess running. - Model-tier guard: reject agent-loop requests on models whose spec
says
loop_capable=False(haiku) with a clear error before spawning the SDK loop, since the SDK loop is known to misbehave.
- Ghost holder fix:
WebSocketBridgevalidates the holder on every gate denial: if the holder token is aws:surface with no live session, callrelease_for(holder)and re-run the gate. Cheap, contained, no arbiter API change. - Hand-off successor selection skips
ws:tokens with no live session (ws_server passes a liveness predicate to the arbiter at construction). is_activeauto-acquire keepssetdefaultbut the predicate prevents dead tokens from re-entering via hand-off.
- New tool
get_usage(categorybudget): returns the caller's provider usage: for bridge agents, the cachedrefresh_usagepayload (5h/7d utilization + reset times) plus the agent's own budget consumption (registrybudget counters); for API providers, cumulative session tokens vs configured budget. Cached ≤60s so agents polling it don't burn a probe call per turn. - Purpose: agents can self-pace long-horizon work against the shared subscription the way the operator does, instead of running blind until a budget abort.
- The "no cloud available" contingency: ollama models may serve as the
root cognitive agent. Gate, don't hardcode: a model spec flag
loop_capable(atn/model_specs.py) that local models can earn, rather than a blanket provider-level refusal on ollama. - Prerequisites before flipping any local model on: §9 structured tool history (multi-turn tool use must round-trip), §7 num_ctx, and a smoke test (spawn child → collect → summarize) passing on that model.
- Fallback chains (
provider: [claude_max, ..., ollama]) already exist; with this flag the chain can legally end in ollama so the fleet degrades to local instead of halting when cloud auth/quota is gone.
New tool + WS message compact_agent(agent_id).
Permissions: the owner (WS / OWNER_ID caller) may compact any
agent; an agent may compact only its direct children
(target.parent_id == caller_id); never itself. Violations return an
error, no side effects.
Semantics by target state:
- Running, generic loop: set a compact-requested flag on the
provider; the loop honors it at the next iteration boundary by
running
_reduce_context(force=True). Result{"status": "queued"}. - Running, bridge: if the Claude Agent SDK input protocol supports
a compact control message, inject it; otherwise return
{"status": "unsupported_while_running"}. Do NOT pretend. - Idle (any provider): compact the persisted conversation store:
summarize all but the last 2 turns via the agent's own provider into
one summary turn (same Goal/Progress/Decisions/Critical
context/Next steps template), archive the original history
(existing store archive mechanics), and evict the cached provider
instance (bridge session_id dropped) so the next run rebuilds from
the compacted store. Result
{"status": "compacted", "turns_before", "turns_after"}.
Events: every manual compaction emits the existing CONTEXT_COMPACTION
event with "manual": true and "requested_by": <caller_id> in data,
statuses in_progress / completed / hard_truncated as today.
Frontend contract (atn_web): subscribe to context.compaction events:
live "Compacting…" indicator while in_progress, updated compaction chip
on completed, visually distinct warning on hard_truncated; a "Compact"
action (owner UI) sends {"type": "compact_agent", "agent_id": ...}.
Ideas below §17 are ported from OpenAI Codex CLI (reimplemented, not
copied). Shell executors (atn/shell_tools.py) are coroutines awaited
on the daemon/worker event loop, so none may block it:
bashandsearch_filesrun throughasyncio.create_subprocess_*with stdout+stderr merged (interleaved in write order) and stdin closed (a prompting command fails fast instead of hanging).- On timeout the whole process tree is killed (
taskkill /F /Ton Windows,killpgon POSIX, where the child gets its own session) and the partial output is returned with the error. Cancellation of the run also kills the tree. - Output keeps head (30k) + tail (20k) chars, middle elided, instead of a head-only chop, so the final status and last traceback survive. Capture is bounded in memory (128 KiB head + 96 KiB rolling tail).
read_file/write_file/edit_file/list_directorydo their disk I/O in a worker thread (asyncio.to_thread).
format_tool_result (base.py) renders what the model reads; it used to
json.dumps every result, wrapping shell output in quotes and escaped
newlines.
- string result: sent as-is.
- shell-shaped dict (
outputand/orexit_code, optionalerror):Exit code: N/Error: ...lines, thenOutput:and the raw text. - dict with one text field (
content,matches,output,text,result): that text. Dict with onlyerror:Error: .... - anything else (genuinely structured): JSON, as before. Always a string, so anthropic / openai_compat / ollama adapters pass it through unchanged as tool content. §6 head+tail truncation applies after.
The per-turn ceiling and the §2 pre-send trigger no longer use chars/4
over the whole stack. _next_input_estimate: the provider-reported
input of the last response (input + cache_read + cache_creation,
which already counts system prompt and tool schemas) plus chars/4 of
the messages appended since that request. The anchor is per run and is
dropped whenever a reduction rewrites the stack (pre-send, reactive
overflow, manual compaction); without an anchor (first turn, usage of
0, or right after a reduction) the old chars/4 estimate is used.
_reduce_context(est_tokens=...) carries the gap between the real size
and chars/4 as a fixed offset on its char budget, so reduction aims at
the real size.
The in-loop compactor (_compact_messages via _summarize_for_compaction)
no longer sends a separate summarizer system prompt (a guaranteed
prompt-cache miss on the run's largest request). The summary request is
the loop's own request: same system prompt, same tool schemas
(_run_tool_defs), the full current history as prefix, plus one final
user message (COMPACTION_INLINE_PROMPT: no tool calls, handoff note
under the Goal / Progress / Key decisions / Critical context / Next
steps headings). The result still becomes summary + retained tail (§2).
- If the summary request itself overflows, the oldest ~20% of history
is dropped in valid units (an orphaned leading tool_result goes too; a
user marker is prefixed if the rest starts on an assistant turn) and
the request retried, up to 4 times, before falling to
_hard_truncate. - If the model answers with a tool call instead of text, retry once without tools.
- Spiral guards (§2) are unchanged. The idle-path
compact_agent(§15) still usesCOMPACTION_SUMMARIZER_SYSTEM; it has no live cache prefix.
When one turn carries several tool calls, the loop
(_execute_tool_batches) walks them in order: a maximal run of
consecutive parallel-safe calls executes concurrently (asyncio.gather,
at most 8 at once); any other call waits for the batch before it and
runs alone. That is read/write-lock semantics with fair, in-order
acquisition. Results are appended in the original call order.
- Parallel-safe =
PARALLEL_SAFE_TOOLS(read_file, list_directory, search_files, web_fetch, web_search, probe_tools and the list/get framework tools that only read registries) plus any tool dict flaggedparallel_safe: True; a tool dict may opt out withparallel_safe: False. bash, write/edit, use_tool, delegation and every mutating framework tool stay exclusive. - Loop guard (§4) and malformed-args guard (§8) run as before; a call
with malformed args is never part of a parallel batch and is not
executed. An executor exception now also marks its result
is_error.
Every generic-loop run that has a tool surface and an executor gets
update_plan (UPDATE_PLAN_TOOL): {plan: [{step, status}], explanation?}, status one of pending / in_progress / completed, at most
one in_progress, max 50 steps. Each call replaces the whole plan.
- Handled by the loop, never by
tool_executor: validated, stored in run state (_run_plan,_run_plan_explanation, reset per run), answeredPlan updated.or anis_errorresult. ASTEP_OUTPUTevent withchannel: "plan"carries the plan to the UI. - Survives context reduction:
format_plantext is inserted verbatim into the compaction summary message, the_hard_truncatehead and the tier-3 hard-reset notice (not left to the summarizer). - Bridge providers keep their own SDK todo tooling; unaffected.
After a turn's tool results are built, the loop computes the next
request size (§19 estimate) against the usable input budget
(compaction_trigger_tokens). On first crossing 70%, 80% and 90%, a
short note (percent in use, tokens left before older history is
summarized, advice to keep output small) is appended to the LAST tool
result of that turn. Never in the system prompt, which must stay
byte-stable for the prompt cache. Each level is announced once per
run; falling back under 70% (after a reduction) re-arms them.
- Whole-stack pairing before every send (
_repair_history, run at the top of each_send_stream_with_retryattempt, so also after a reactive reduction). A tool_use with no result in the next message gets a syntheticis_error"no result was recorded" result (merged into the next tool-result message, results first, or a new user message inserted). A tool_result whose id is not a tool_use of the immediately preceding assistant turn (e.g. a compaction tail that starts on results whose call was summarized away) becomes a short text block. Valid stacks are never touched (cache prefix intact). The trailing assistant turn is left to the loop / §3 exit repair. - Output cap mid tool call.
stop_reason == "max_tokens"with tool calls means the last call is cut off. The turn is discarded (any text kept as an assistant message, the partial calls never enter history) and one user message (TRUNCATED_TOOL_CALL_PROMPT) asks for a smaller re-issue. Once per incident: a second consecutive truncation finalizes as before; a normal tool turn re-arms it. - Backoff. Connection-level failures (refused / reset / dropped
stream, also when wrapped as
__cause__of a ProviderError without a status) route as transient on their own schedule, 1/2/4/8/16s + jitter, separate from the 2/8/30s 429/5xx schedule.Retry-Afteris honored up to 300s.
Every policy in this document that is a CHOICE rather than a safety
property is now a field of HarnessPolicy (atn/harness_policy.py),
carried in the harness distro's config blob (docs/tool_substrate.md,
Decision 2026-10-12). The loop reads it per run:
- Prompts: common base, per-category tool notes, type guidance,
small-context note (
delegate_prompts.build_system_prompt(..., policy=)/build_identity_header(..., policy=)). - Closing turns: verify on/off, verify prompt, code-file suffixes; review on/off, review prompt; bridge follow-up turn caps (8 / 4).
- Compaction (§2, §20): inline instruction, prune protect/min savings, retained turns + user chars, max compactions, min reduction, summary max tokens, overflow retries + drop fraction, hard-reset cap, reset digest cap.
- Guards (§4, §24): repeat limit, oscillation window, per-turn input ceiling, loop warning text, truncated-tool-call prompt.
- Headroom (§23): levels and note template. Parallel (§21): safe tool list and concurrency cap. Tool bundles: an optional list that NARROWS the agent's granted categories (never widens).
Resolution: agent definition knobs (per_turn_input_max,
repeat_call_limit) > the agent's bound distro (AgentDefinition. loadout, owner-set) > the reference policy. The reference policy is
built from the module constants, so an agent with no loadout runs a
byte-identical loop (tests/atn/test_swappable_harness.py). A distro
policy is an overlay: missing keys inherit, unknown keys are ignored,
bad values (wrong type, template missing its placeholders) keep the
reference value, numbers are clamped. In-process runs pass the policy
object; worker runs carry it as a dict in the run manifest (only when
it differs from the reference).
NOT policy (kernel, never distro-settable): the budget gate and usage
recorder, tool dispatch + containment, the constitutional preamble, the
governance heartbeat, history storage, _repair_history, update_plan
validation. Policy-as-code hooks (capabilities.provides: ["policy:compaction"], etc.) are declarable but not dispatched yet;
see POLICY_HOOKS in atn/harness_policy.py for the intended contract.
Codex loads AGENTS.md from repo root down to the working directory.
Not ported: cognitive agents on the generic loop have no working
directory of their own (shell tools run in the daemon's process cwd;
agents_dir/<id> work dirs exist only for pipeline agents). Revisit if
a per-agent workdir is introduced.
New/updated files: tests/atn/test_loop_hardening.py (§1-§6, §8:
mock provider raising transient/overflow/fatal; compaction fallback;
orphan repair; oscillation; steering queue), extend
tests/atn/test_provider_base.py (head+tail truncation, max_tokens
from spec), tests/atn/test_ollama_translation.py (§9 round-trip,
num_ctx presence), tests/atn/test_provider_routing.py (§10 fail-loud
- eviction),
tests/atn/test_input_arbiter.py(§12 ghost recovery). Codex-port items §17-§24:tests/atn/test_loop_codex_port.py. §25 (distro policy, loadouts, core swaps):tests/atn/test_swappable_harness.py. Run alongside existingtest_provider_base.py,test_long_horizon_safeguards.py,test_budget_inner_loop.py.
Out of scope: worker-isolation default, voice/chat surfaces, sponsor paths, any consensus/substrate code.