ADR-096
The agent-loop intelligence layer — routing, stall guard, compose, telemetry
accepted · 2026-06-12 · L2 L0 · cites 3
0ADR-096 — The agent-loop intelligence layer #
- Status: Accepted (2026-06-12)
- Layer: L2 (
nika-verb-agent) + L0 vocabulary (nika-event) - Relates: ADR-092 (static-analysis ladder · the compose gate's checker),
spec
02-verbs.md§agent (the YAML contract — UNTOUCHED by this ADR)
Context #
The s12 agent verb shipped the canonical ReAct loop: infer → whitelisted
dispatch → feed back, bounded by turns/tokens. Production agent traces across
the field show four recurring gaps, each with published evidence:
- No-progress loops — repetitive-action cycles are a dominant agentic failure class (TRAIL, arXiv:2505.08638); a turn budget alone burns the whole budget before stopping.
- Tool-context crowding — injecting every tool schema every turn degrades context budget and selection quality at scale (MCP-Zero, arXiv:2506.01056; Gorilla, arXiv:2305.15334).
- Self-composition without verification — agents that generate artifacts (here: workflows) need «generation is not permission» discipline (PCE, arXiv:2605.24462; CodeAct, arXiv:2402.01030).
- Opaque internals — observability must expose the agent's DECISIONS, not just its I/O (AgentOps, arXiv:2411.05285).
Decision #
Four orthogonal, deterministic mechanisms inside the loop — zero new YAML
(the spec's agent: field table is untouched; tuning is engine-internal
AgentConfig, embedder-set with production defaults):
1 · Stall guard (`guard.rs` · NIKA-467) #
Turn signatures hash the turn's tool calls (key-order-canonical args) AND
their observations. Windowed max-repeats cycle scan (W=16 · ≤W²/4 u64
compares/turn). Ladder: nudge_after (3) occurrences → ONE Reflexion-style
corrective message in the transcript (arXiv:2303.11366 · bounded by
max_reflections, default 1; an all-error streak shares the budget);
stall_after (5) → stop as NIKA-467 Stalled {period, repeats}.
Because observations are part of the signature, legitimate polling (same
call, changing result) never trips it — only byte-identical
action+outcome cycles do. Stalls are terminal verdicts (is_transient() = false): a blind rerun replays the proof.
2 · Per-turn tool routing (`router.rs`) #
Below 24 whitelisted definitions: passthrough (stable prompts win —
provider prompt-caches stay warm). At ≥24: rank definitions against the
live context (prompt + last assistant text + last observations) with
`nika-bm25` (L2→L1 downward dep — the engine's own BM25S/eager
satellite; sovereign, zero LLM calls, bit-reproducible) and offer top-12
∪ pinned (intrinsics + nika:done) ∪ recently-used (≤2 turns).
Fail-open: zero lexical overlap ships the full universe — a blind
model is worse than a crowded one.
3 · Intrinsics (`intrinsic.rs` · `nika:compose`) #
Amendment 2026-06-13 · `nika:compose`, not `agent:compose`. The first cut put this in anagent:tool namespace — but the spec's tool namespace set is CLOSED at{nika:, mcp:}(02-verbs§218, enforced in the prose + the JSON schema +validate_tool_ref), so a third namespace was a grammar violation that only "worked" through a gap in the agent whitelist's namespace check. The precedentnika:donealready shows the right shape: a loop-served tool is anika:builtin, marked loop-only. Socomposeis nownika:compose— the 23rd canonical builtin (Introspection, the static sibling ofinspect), catalogued innika-catalog, rejected standalone innika-builtin(NIKA-BUILTIN- COMPOSE-001), documented in the public spec. The whitelist gap is closed (the parser now enforces the closed set).ToolSourcecollapses to{Builtin, Mcp, Other}(the spec's set + a catch-all); theSkill/Intrinsicvariants — which anticipated namespaces the spec doesn't have — are removed, re-added when those namespaces actually land.
nika:compose is a loop-served nika: builtin (like nika:done):
synthesized by the loop (any upstream def of the same name is dropped —
poison-shadow proof), whitelist-gated, NEVER dispatched to the executor
seam. It takes a full workflow YAML draft and returns the complete
nika check verdict as JSON — conformance violations with prescriptive
codes, secret flows, permits escapes, and the AARA cost/termination
certificate (ADR-092 · via nika-schema, L2→L0). The draft never
executes here: composition yields an artifact + its certificate; running
it stays a separate, gated decision. Drafts are size-capped (256 KiB)
ahead of the parser.
4 · Telemetry (`observe.rs` + 5 `nika-event` kinds) #
An injected AgentObserver seam (4th seam · optional · Arc<dyn> — a
generic would infect every embedder signature for a non-data-path
concern). Every decision is a typed AgentEvent: RunStarted · TurnStarted · ToolsSelected{offered, universe, by_source} · ToolCompleted · ComposeChecked · Nudged · Stalled{period, repeats} · BudgetCheckpoint · Finished. The L3 runtime maps these 1:1 onto the new
nika-event kinds (agent_tools_selected · agent_nudge · agent_stalled · agent_compose_checked · agent_budget_checkpoint, EventClass::Agent) —
INV-024 holds: the runtime adapter is the ONE emission site.
The `skill:` forward direction (NOT a v0.1 namespace) #
Amended 2026-06-13.ToolSourceclassifies ONLY the spec's closed v1 set (nika:·mcp:) + a catch-all. Stored-workflow skills (Voyager-style · arXiv:2305.16291; AWM · arXiv:2409.07429) are a real FUTURE direction, butskill:is not a v0.1 tool namespace (the spec's set is closed; the parser rejects it). Whenskill:lands — a future additive MINOR per02-verbs§218 — itsToolSourcevariant + the parser acceptance + the served definitions land together. The earlier "routed and counted today" framing was the same ahead-of-contract mistakeagent:composewas, now corrected.
Alternatives rejected #
- Tree search (LATS, arXiv:2310.04406) — multiplies provider spend against the budget-first posture; revisit post-v0.81 behind the same observer seam.
- Result caching for repeated identical calls — breaks legitimate polling semantics (a cached poll lies); the observation-aware stall guard covers the pathological case without faking results.
- LLM-based reflection/self-evaluation — costs a provider call to decide whether to spend provider calls; the deterministic detector is free and evidence-carrying.
- New YAML fields — the spec is a public contract with its own 4-surface cascade; everything here has sane engine defaults and zero author-facing surface.
Verification #
tests/research_conformance.rs — one executable property per claim,
through the REAL loop with mock seams: the nudge lands in the transcript
exactly once and the loop recovers · identical cycles stall as NIKA-467
with evidence and the queued 6th turn is never requested · polling with
changing observations never trips the guard · routing measurably narrows
the request's tool list (telemetry mirrors the request) · zero-overlap
fails open · the compose check-repair loop converges in 2 rounds with the
certificate riding the verdict and ZERO executor dispatches · a poisoned
upstream agent:compose def never shadows the loop-owned one · the
observer sequence is bracketed (RunStarted … Finished) with per-turn
routing + budget events. Unit property tests pin the detector (injected
p-cycles detected; unique streams never fire) and the canonical hash
(key-order invariance · observation sensitivity).
read at v0.107.0 · the decision record ships with the engine