--- name: harness description: >- The durable agent turn loop — kick off a turn with `harness::send`, render it from session-manager transcript events, react to `harness::turn-completed`, with deny-by-default tool dispatch and synchronous hook extension points for policy siblings. --- # harness The harness is the durable turn loop that wires `session-manager`, `llm-router`, and `context-manager` into an agent. A consumer stays thin: it kicks off a turn, renders the conversation from the session transcript, and reacts to turn boundaries and human-gated calls. The harness streams the assistant message into the session as it generates, so you watch the session, not the harness — there is no `agent::events` stream, and `harness::status` is a point-in-time recovery read, not a render feed. Every invocation is a trigger (`iii.trigger({ function_id, payload })`); there is no separate "call" verb. Tool dispatch is deny-by-default: a send with no `options.functions.allow` is a plain chat loop and every model-requested call is refused until you allow globs per send. Sessions are minted by the harness (`s_`) or supplied by you; a send into a running turn folds in as steering (`merged: true`) instead of erroring, and a repeated `idempotency_key` returns the original turn without appending. A session-creating send names a `model`, or only a `provider`: the harness then starts on that provider's declared default model (`default_model` in `router::provider::list`, checked against its live catalog; a provider that reports none fails the send as before). A new session that names neither `thinking_level` nor `provider_options` also starts on the provider's `default_thinking_level`; an explicit level wins, and later sends into the session inherit the prior turn's as usual. `thinking_level` is `off`, `minimal`, `low`, `medium`, `high` or `xhigh`; `off` is honoured only on models whose catalog entry says `supports_thinking_off: true`; elsewhere the fallback is the provider's own (most send their lowest effort and warn, Kimi ignores the level). Prerequisites: `session-manager` (required — transcript store and change feed) and `llm-router` (required — generation and the model catalog) must be present. `context-manager` (token budgeting and compaction) is a soft dependency — absent it, the harness sends raw history. `approval-gate` (the human-in-the-loop gate) is optional; without it no call is held and every allowed call runs un-gated. ## When to Use - Start or steer an agent turn and return immediately (`harness::send`). - Cancel an in-flight turn (`harness::stop`) or read coarse turn state for recovery and guards (`harness::status`). - Chain turns or react to outcomes by binding `harness::turn-completed`. - Drive a turn from an arbitrary inbound event (cron tick, webhook, sensor) by translating it into a `harness::send`. ## Boundaries - Not a transcript feed. Render from `session-manager`'s `session::message-added` / `message-updated` / `status-changed` (reconcile by `revision`); do not poll `harness::status` for content. - Not the approvals engine. The harness ships only the gate mechanics; the policy, decision RPCs (`approval::resolve`), inbox (`approval::list-pending`), and prompt triggers live in `approval-gate`. - Not a chain guard. `options.max_turns` bounds a single turn, not a send-completed-send loop; carry your own stop condition. - Do not trigger the internal functions (below) — they forge call ids and turn progress, so calling them out of band corrupts the turn record. - An in-run agent cannot start turns: `send` / `run` / `turn` / `stop` are denied to the model by policy. `harness::spawn` is the only turn-starter an agent calls directly, and it self-enforces depth, fan-out, and policy subsetting. The other path is event-driven: an agent binds `engine::register_trigger` straight to `harness::spawn` (see Reactive triggers), with the spawn spec in the registration `metadata`, and the engine spawns the sub-agent when the event fires. ## Functions Consumer-facing: - `harness::send` — ensure the session, persist the incoming message, and kick off a turn; returns fast or merges into a running turn (steering). - `harness::stop` — request cancellation of an in-flight turn; cascades to spawned children. - `harness::status` — read the current turn state for a session; `null` when no turn ever ran. For recovery and guards, not rendering. - `harness::spawn` — spawn a sub-agent in a child session. Model-facing (invoked through `agent_trigger`), not a consumer entry point. - `harness::ask` — put a decision with discrete options to the user as a clickable card (1–4 questions, 2–4 options each). Model-facing (invoked through `agent_trigger`) and answered by the turn loop itself: the turn ends on the question and the answer arrives as the user's next message. Refused in sub-agents, structured-output turns, and for a second ask in one step. Internal — the harness drives these; never trigger them directly: `harness::turn` (the durable loop step), `harness::function::trigger` / `harness::function::resolve` (dispatch and parked-call settle), `harness::sweep-pending` (cron expiry), and `harness::on-config-change` (hot-reload). ## Filesystem scope `options.metadata.fs_scope.root` on a send is the session's working directory; a later send that omits `fs_scope` keeps it. The harness stamps a trusted `fs_scope { root, grants, boundary }` onto every `shell::*` / `coder::*` call and strips any the model supplies. `boundary` decides what `root` means to the `ide` worker: - `workspace` — `coder::*`, `shell::fs::*` and an exec `cwd` stay inside `root` plus the session's grants (`harness::filesystem::grant`). - `configured_roots` — `root` only anchors relative paths; the worker's own roots apply. The model's prompt says so ("default directory, not an access boundary"). The `filesystem_boundary` config picks it: `auto` (the default) is `workspace` only while approval-gate's access watch is bound; `workspace` or `configured_roots` pins it. `harness::filesystem::info` reports the boundary in effect. A sub-agent spawned in a turn into a new session starts with a copy of its parent's grants; later grants to the parent do not reach it. Under `workspace`, an in-turn spawn's `options.filesystem_root` must lie inside the parent's root or grants. What an exec'd process itself writes is the `ide` worker's `fs.exec_confinement` switch. ## Reactive triggers The harness emits two async turn-boundary trigger types so consumers and siblings react without polling `harness::status`: - `harness::turn-started` — a turn began executing (first loop step). - `harness::turn-completed` — a turn reached a terminal status (`completed` / `cancelled` / `failed`), carrying the result or error for chaining, failure toasts, auto-titling, and result delivery. Bind `turn-completed` for outcomes and to chain the next hop; bind `turn-started` only for observability. Delivery is fire-and-forget, at-least-once, and unordered — treat each event as an edge. Nothing replays on reconnect: re-seed with `harness::status` and `approval::list-pending`, then rebind. Do not bind these for live transcript rendering — that is `session-manager`'s job. Binding `config` filters delivery by `session_id`, or by `parent_session_id` to watch the children a turn `spawn`s (in-turn spawns only — a direct `harness::spawn` call creates no parent link, so filter those by `session_id`). Bind an event to a sub-agent with one call — the spawn spec goes in the registration `metadata`; the fired event is appended to the task: ```json engine::register_trigger { "trigger_type": "harness::turn-completed", "config": { "session_id": "" }, "function_id": "harness::spawn", "metadata": { "task": "Summarize the completed run.", "once": true } } ``` `model` defaults to the registering turn's model. A failed upstream turn does not fire the reaction unless `continue_on_error: true`. Reactive chains stop at depth 8. There is no fan-in join primitive — use the workflow worker to join parallel work. An in-session registration that omits `model` also inherits its provider, when the spec pins none; raw engine-side registrations have no turn to inherit from and must pass one, and any supplied `model` must be a live id from `router::models::list` (validated at registration and again at fire time). Omit `parent_session_id` and the child nests under the registering session's root automatically; pin it only to choose a different REAL session (an invented id shows the children as disconnected roots). A trigger-fired spawn has no parent policy to inherit — it gets the harness's read-only `default_functions` baseline unless `options.functions` grants more (narrowing only, same as a direct `harness::spawn` call). Filter `turn-completed` only by session ids you know exist; registration returns a warning `note` when the filtered session doesn't. Self-edge drop and the depth cap are the ONLY loop breakers — still design filters so a reaction is not matched by its own subscription. A cycle routed through a state write (or any other hop that starts a fresh turn) re-enters at depth 0 and is NOT throttled: every lap around the cycle spawns another paid turn indefinitely. Design reaction graphs acyclically. This is the in-run agent's chaining path; the `registerFunction` recipe below is for workers. ### How to bind 1. Register a handler: `registerFunction('myapp::on-turn-done', handler)`. 2. Register the trigger: ```typescript iii.registerTrigger({ type: 'harness::turn-completed', function_id: 'myapp::on-turn-done', config: { session_id: sessionId }, }) ``` For the event payload shape, call `get function info` on the trigger type. ### Hooks (policy siblings only) The harness also registers five synchronous, in-path hook trigger types: `harness::hook::pre-turn`, `harness::hook::pre-generate`, `harness::hook::post-generate`, `harness::hook::pre-trigger`, and `harness::hook::post-trigger`. A bound hook runs in the turn's critical path and the harness acts on its return value (veto / hold / mutate) under a per-binding `timeout_ms` and `on_error` policy; `pre-trigger` / `post-trigger` bindings take a `functions` glob list to scope which calls they gate. These are for operator-trusted policy siblings (`approval-gate` binds `pre-trigger`); ordinary consumers do not bind hooks.