Binding spec for upgrading atn/providers/base.py:send_orchestrate (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 orchestration (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 orchestration 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 (chars/4 estimate,
calibrated by the last turn's actual input_tokens when available) >
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 — drop oldest turns (keeping (a)–(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 orchestration is active to consume it.send_orchestratedrains 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 orchestration 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 — 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.
_resolve_provider_for_model: unknown prefix → probe the ollama tags cache; if the model is installed locally → OllamaProvider. Otherwise raiseProviderError("unknown model '<m>': set provider explicitly"). No silent BridgeProvider fallback. (Explicitprovider: claude_maxconfig keeps working; gemini/openai keyless fallbacks at provider_manager.py:459/471 also become errors.)create_agenttool schema gains optionalprovider(enum of known providers); plumbed intoAgentDefinition.provider.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 non-orchestrator model). Whatever the mechanism: errors fromquery()must surface as an@@EVENT@@ error+ request failure, never a bare loop continue. - First-event watchdog: if a sent orchestrate 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 orchestrate requests on models whose spec says non-orchestrator-capable (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
orchestrator/root cognitive agent. Gate, don't hardcode: a model spec
flag
orchestrator_capablethat local models can earn; the provider-levelorchestrator_capable: Falseon ollama (provider_manager.py:216) becomes per-model. - 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 / ORCHESTRATOR_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": ...}.
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). 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.