# Chat agent decisions (`AGT`) Decisions about what the chat agent may do, what it is shown, and how a turn is driven. Cost and context shaping live in [chat context](chat-context.md); sandbox delegation in [sandbox](sandbox.md). Primary code: `reporting/services/chat_graph.py`, `reporting/services/chat_orchestrator.py`, `reporting/services/mcp_runtime.py`, `reporting/services/headless_chat.py`, `reporting/services/agent_run.py`, `reporting/services/chat_turns.py`, `reporting/temporal_workflows/chat_turn.py`. ## AGT-001 — Chat tools fail closed **Applies to:** `mcp_runtime.list_tools_for_user(chat_safe_only=True)` Chat sees only built-ins whose `required_permissions` are all in `_CHAT_SAFE_PERMISSIONS` (read/inspection only), plus mutating built-ins that carry an action-confirmation resolver. **Why:** a newly added write or delete tool is hidden from chat by default rather than exposed by default. The failure mode of the alternative is silent. **The exceptions are documented at the tool registration**, and there are only two: - `reports__create` — creates a new *private* report and modifies nothing existing. It still carries a **conditional** resolver for the one case where it publishes (filing into a space). - `sandbox__delegate` — isolation is the control; see [SBX-009](sandbox.md). `reports__clone` is gated **unconditionally**, because whether it publishes depends on the source's placement, and reading that is the resolver's job rather than the handler's. **Don't:** add a third exception without recording it here and at the registration. ## AGT-002 — Progressive disclosure is a context economy, not an authorization boundary **Applies to:** `CHAT_LLM_PROGRESSIVE_DISCLOSURE`, `chat_orchestrator._required_action_spec` RBAC is the boundary: `chat_safe_only` + `chat:tools:call` bound `_list_chat_tools` and everything a sandbox sub-agent could ever reach. Nothing below widens that set. What a model is *shown* is narrower, and under progressive disclosure so is what the sub-agent may actually call without loading a skill — see [SBX-004](sandbox.md). That narrowing is context economy and skill curation, not authorization: it decides what is convenient to reach, never what is permitted. Because disclosure only decides what a model is *shown*: - `ChatState.disclosed_tools` carries across turns. - `planner_node`'s capability context includes what earlier turns unlocked. - A tool a plan names in `required_action` is **disclosed rather than refused**. `_required_action_spec` resolves against the full permitted universe; only a tool that does not exist *for that user* is a contract error. **Why:** refusing was a real cost for no security gain. The planner reads session memory, where a tool an earlier turn used is recorded by name — including tools a sandbox sub-agent called. Observed as steps blocked on `Required tool action cve_analysis__get_recent_cves is not available` for a tool the previous turn had just used successfully. Only the explicitly required tool is disclosed this way; `suggested_tools` still come from the disclosed pool. ## AGT-003 — Message tags exclude messages from context without deleting them **Applies to:** `reporting/services/chat_messages.py` `MessageTag` values live in `additional_kwargs["seizu_tags"]` (they round-trip through the checkpoint serializer). `load_thread_messages` drops `EPHEMERAL` at the read boundary; broken assistant outputs are tagged `BROKEN` and filtered from future model context. **Why:** a failed turn should not poison retries, but the message still has to exist for history and debugging. This is the reusable mechanism — reach for a tag rather than a bespoke filter. ## AGT-004 — Thread ids are namespaced server-side **Applies to:** `chat_graph.namespaced_thread_id` `user:{user_id}:thread:{client_thread_id}`. **Why:** clients supply the thread id, so without server-side namespacing one user can reach another's thread. Everything downstream — checkpoints, the sandbox resume id, session memory — inherits this isolation, which is what makes those safe to persist. ## AGT-005 — Report configs are validated at save time **Applies to:** `CreateVersionRequest.validate_report_config` Configs are validated against the `Report` schema when saved, and markdown panels must carry content in `markdown`. **Why:** without it an agent stores a config that renders empty and gets no signal. An actionable error at save time is the difference between a retry and a silently broken report. ## AGT-006 — Headless runs execute as the schedule's creator, with permission-based bypass **Applies to:** `reporting/authnz/headless.py`, `reporting/services/agent_run.py` Identity: `resolve_stored_user()` rebuilds the creator's permissions from the last role claim seen on an authenticated request (`User.role`, synced by `get_or_create_user`). Archived users hard-stop. Confirmation bypass is gated by `chat:bypass_permissions` (Editor+). `call_tool_for_chat(bypass_confirmations=True)` re-checks the permission and AUDIT-logs every bypassed execution. Without it, headless runs keep the normal confirmation flow, so mutating tools fail closed for the run. Creator RBAC and `chat_safe_only` always apply. The same permission gates the chat UI's "Bypass confirmations" toggle (`ChatStreamRequest.bypass_confirmations`, 403 without it, default off). Headless turns get a system-prompt addendum (`_HEADLESS_PROMPT_ADDENDUM`) telling the model nobody can answer: don't ask for confirmation, follow the skills, summarize blocks instead of retrying. **Don't:** weaken any of this without explicit sign-off. A headless run is an unattended agent holding a real user's permissions. ## AGT-007 — Scheduled chats run on Temporal, and their sessions are not chat sessions **Applies to:** `reporting/services/chat_schedules.py`, `reporting/temporal_workflows/scheduled_chat.py` Execution is Temporal-only; the polling worker is gone. Run sessions are created with `origin="scheduled"` + `scheduled_chat_id`, excluded from `list_chat_sessions`, listed via `list_scheduled_chat_sessions`, and admitting a turn on one **is rejected** (403). **Why the rejection:** a scheduled run's transcript is a record of what an unattended agent did. Allowing a user to continue that thread interactively would blur which turns were unattended. Schedules are per-user (`created_by` owner; list/get/update/delete are owner-scoped, 404 otherwise). `chat:schedule:read_all` (Admin) unlocks *viewing* every user's schedules; mutations stay owner-only. Overlap is `ScheduleOverlapPolicy.SKIP` with no mutex, unlike `ConfiguredWorkflow`, and runs use `maximum_attempts=1`. Disabling **pauses** the Schedule rather than deleting it. Monthly specs over-fire on days 28–31, and `load_scheduled_chat` drops the non-matching firings via `schedule_due`. `POST /chat/schedules//run` is keyed on `run_requested_at` so a recovering reconcile pass is idempotent. ## AGT-008 — An interactive turn is detached from the connection watching it **Applies to:** `reporting/services/chat_turns.py`, `reporting/temporal_workflows/chat_turn.py`, `reporting/routes/chat.py`, `reporting/services/report_store` (chat turn log), `src/api/chatTransport.ts` A turn used to *be* the HTTP request: `graph.astream` was iterated inside the `StreamingResponse` generator. Now a turn is a **Temporal workflow** writing an append-only log of stream parts, and a request is a **reader** tailing it. The two share only a turn id, so a client can disconnect and reattach. **Why:** two failures come from the old shape, and both are structural. `gunicorn.conf` set no `timeout`, so the 30s default applied — and under `UvicornWorker` that is a heartbeat watchdog, so a loop blocked past it gets the worker `SIGABRT`ed mid-response and the client receives a **truncated body rather than an error** (observed three times under the harness, as `IncompleteRead` after ~470 bytes). Separately, a dropped connection **destroyed the turn**: Starlette cancels a `StreamingResponse` generator on `http.disconnect`, so closing the tab killed minutes of work outright. An explicit `timeout = 300` originally masked the first; only detaching the turn fixes the second and makes a web-worker restart recoverable. With production now owned by Temporal, keeping a wedged web worker alive for five minutes has no chat benefit. The bundled Gunicorn watchdog therefore follows `API_REQUEST_TIMEOUT` (60 seconds by default). A healthy `UvicornWorker` keeps notifying Gunicorn during a long-lived stream, so chat duration does not set this value. ### Interactive chat now requires Temporal This **reverses the scoping in issue #254**, which asked for the turn to be detached without adding a dependency, and the first implementation duly ran the producer as a detached `asyncio.Task` in the web process. What that cost is the point: a detached task has no identity, so everything a workflow gives for free had to be rebuilt by hand — a renewable **lease** to prove it was alive, a process-local **registry** to find it, **crash detection** to notice it was gone, a **finalizer** for a task cancelled before its coroutine ever ran, and a first-writer-wins guard so a second cancel could not land inside the first one's cleanup. Each of those appeared as a review finding, and several of the fixes produced findings of their own. Meanwhile a restart of `seizu` still ended every turn it was running. `ChatTurnWorkflow` replaces all of it. It exists, it is addressable by a name derived from the turn id (`workflow_id_for`), and Temporal is what guarantees it reaches an end — so stopping a turn needs no stored handle, liveness needs no renewable lease, and a turn survives a web-process restart. The price is that `CHAT_ENABLED` now implies a reachable Temporal server, the same as scheduled chats ([AGT-007](#agt-007)). In Docker Compose the web service therefore waits for Temporal's health check before starting; it need not wait for the worker, because Temporal durably queues an admitted workflow until a worker polls it. **Don't:** add a retry policy. `maximum_attempts=1`, for the same reason as AGT-007 — a turn is expensive and not idempotent, and a retry both re-bills it and appends a second answer to the same log. What running here buys is that the turn survives its request and always reaches an end, not that it is repeated. **Two paths reach a terminal state, deliberately.** The activity finalizes its own turn, including under cancellation — it is ordinary Python and owns the log it has been writing. The workflow finalizes only when the activity never got to: it died with its worker, or timed out. Between them a turn cannot sit at "running" forever, which is what a reader waits on and what keeps the thread from admitting another. **Identity is intersected, never unioned.** An interactive turn's `CurrentUser` comes from a live JWT and is not serializable, so the activity rebuilds it with `resolve_stored_user` ([AGT-006](#agt-006)) and **intersects** the result with the permission cap stored in the admitted command. `resolve_stored_user` reads the last seen role claim, which can be staler *or broader* than the live token. ### Admission is its own request `POST /chat/threads/{thread_id}/turns` answers with a `turn_id` before anything streams; `GET /chat/turns/{turn_id}/stream` reads it. The client therefore holds the id from the moment it sends. **Why this is a separate request and not the head of the stream.** Folding the two together meant every command about a turn had to be expressed against a resource that might not exist yet. That produced, in order: a second identity (`client_token`) for the window before the first frame; a cancel route that accepted *either* name and 422'd on neither; a stop that could beat its own turn into the store, and so had to **create** a turn already canceled to claim the token; and a uniqueness constraint per `(thread, client_token)` to settle the race between that create and the real one. Every one of those is deleted by answering admission first. **Seven rounds of review findings on this feature trace to that single coupling** — if a command here is hard to express, check whether the resource it names exists yet before adding a mechanism. `idempotency_key` makes admission repeatable: asking again resolves to the turn already made rather than starting a second one, so a lost response is fixed by retrying the same request. **Admission returns an outcome, not an exception.** `ChatTurnAdmission.outcome` is one of `created` / `existing` / `busy` / `retired`. It used to raise one of four errors inferred from *whichever constraint rejected the write*, so a single collision could mean any of them and the store had to guess. The store now re-reads and says which it was. **A store failure during admission is a 503, never an assumption.** A failed write means we do not know whether the conversation is being torn down; refusing costs a retry, guessing costs the conversation. **Admission and the session touch are one write.** The turn's half of the retirement handshake ([SBX-011](sandbox.md#sbx-011)) happens *inside* `admit_chat_turn`. Touching the session first left a window: a delete could read the fresh timestamp, claim the session, see no running turn and cascade, all between the two — and the turn was then created against a conversation that no longer existed. **Don't:** enforce one-running-turn with a read above the insert. The *store* says a thread has at most one running turn: a partial unique index (`status = 'running'`) in PostgreSQL. Under read-committed two requests can both observe no running turn and both commit, leaving two producers interleaving two answers into one conversation. **The handoff is repairable, because the store commits first.** Admission writes the turn and *then* starts the workflow, so a failure in between leaves a turn recorded as running with nothing producing it: unreadable, and holding the thread against a successor until its claim lapses. So `start_turn` ensures the workflow for **`existing` as well as `created`**, and treats `WorkflowAlreadyStartedError` as the outcome it wanted. The workflow id is derived from the turn id, which is what makes ensuring it twice a no-op rather than a second producer. **Don't:** ensure it unconditionally. A repeat can arrive after the turn it names has finished, and a closed workflow's id is reusable — ensuring it then would run the whole turn again and bill a second answer into the same log. The guard is `status == "running"`. **Temporal deduplicates, not the status check above it.** That check is a read followed by an act, so the turn can finish in between — and under the default `ALLOW_DUPLICATE` a *closed* workflow's id is free again, so the repair would start the finished turn over. `REJECT_DUPLICATE` makes the id itself the guard: a turn's workflow can exist exactly once, ever. **A cancellation buffer comes off the top of that remainder.** Timing a workflow out does not stop its activity there and then — the cancellation reaches it on its next heartbeat — so handing it the claim's full remainder still lets a producer run past the instant a successor can be admitted. Too little left for both the work and the stopping means the turn is not started at all, and is **closed** rather than left at `running`: a turn with no producer is one a client attaches to and waits on until the tail deadline, for an answer that is never coming. That case is stored with terminal status `expired` and has its own admission outcome (`expired` → 503), so every repeat of the same idempotency key remains retryable rather than becoming an `existing` empty log. That 503 carries `X-Seizu-Chat-Admission: expired`: unlike an ambiguous store 503, the old key is definitively spent, so the transport retries the same logical message once under a fresh key instead of attempting to repair it. **The workflow is bounded by what is left of the claim, not by a fresh duration.** A duration cannot express the invariant: the claim is an *instant* fixed at admission, while a timeout starts whenever the workflow is created — so a handoff repaired minutes later restarts the clock and the workflow can run past a claim a successor has already taken. `chat_turn_execution_bound_seconds` takes the turn's `expires_at` and returns the remainder; a turn whose claim has already lapsed is not started at all. **Both deadlines come from one helper.** The bound adds *half* the lease margin where the lease adds all of it, so it is smaller by construction rather than by coincidence. Hard-coding it (`timeout + 60`) while the lease used the configurable margin left the invariant true only for the settings the test happened to run under — a test that looked like it protected the property and did not. **The execution bound covers queue time, not just the activity.** A `start_to_close_timeout` starts when a worker accepts the task, so a workflow queued through a worker outage can begin *after* the turn's claim on the thread has lapsed and a successor has been admitted. `execution_timeout` bounds the whole thing and is kept strictly under the lease, with a test asserting that relationship so a settings change cannot quietly reopen it. Belt and braces on the worker side: the activity re-checks that its turn is still `running` **and** is still the active unexpired turn for the thread before producing, because a lapsed claim is exactly the case the bound is protecting against. **The admitted command is immutable.** The turn record stores the message, continuation/confirmation fields, bypass flag, permission cap, and timeout. Every first handoff or later repair dispatches that stored command; the retrying request is only a lookup by its required idempotency key. This keeps one durable source of truth, makes repair safe across request or permission changes, and removes a parallel request-fingerprint protocol. A client must mint a key per logical send and reuse it for ambiguous retries. **Cancellation does reach the fallback finalizer.** Worth recording because it reads like a bug: the workflow catches `Exception`, and Temporal cancellation is often described as arriving as `asyncio.CancelledError`, which is not one. In this path it does not — `execute_activity` raises `ActivityError` (wrapping temporalio's own `CancelledError`), which *is* an `Exception`, whether the cancel lands while the activity is running or while it is still scheduled. `chat_turn_test.py` pins both cases, so a future change that lets a cancelled turn stay `running` fails there rather than in production. ### The event log **The producer renders the parts; the reader only replays them.** The log holds the exact JSON the live stream sent, so the first delivery and every replay are byte-identical and there is no second rendering path that can drift. `message_id` and `text_id` live on the turn record for the same reason — a replay that minted fresh ids would read to the client as a *second* assistant message, not the same one being rebuilt. **A reader stops on two conditions, not one:** a terminal status *and* a cursor that has reached `last_seq`. Status alone races the visibility of the final batches and cuts the answer off mid-sentence, which is why `finish_chat_turn` takes `last_seq` rather than deriving it (that would mean rewriting the metadata item on every flush). **Don't:** advance a reader's cursor past a gap in `seq`. Store propagation is per item, so a poll can return 5 and 7 without 6; taking the gap loses 6 permanently rather than late. The store truncates a page at the first gap. **Only the stream route is exempt from the request timeout.** The exemption is matched on the path's shape (`/api/v1/chat/turns/{id}/stream`) because the id is in the path — exempting the whole `/turns/` subtree would silently drop the deadline from admission and cancellation too. ### Stopping Disconnecting no longer stops anything, so `POST /chat/turns/{turn_id}/cancel` puts that back. It **only marks**: it sets a flag on the record and cancels the workflow. The flag is what reaches a turn running on a worker that never saw the request, and cancelling the workflow is what interrupts a turn blocked mid-call. **Don't:** make the turn notice a stop only between chunks. It reads the flag on its heartbeat and cancels its own work, because a turn is most likely to be stopped precisely while it is blocked on a slow model call or tool — where no chunk arrives for as long as the call takes, and where letting the call finish first means its side effects happen anyway. **Stop names the turn, not the thread.** The request can be delayed or retried, and by the time it lands the turn it was aimed at may have finished and the user started another — a thread-addressed stop would then kill the successor. The client learns the id from admission, or, after a reload, from `GET /chat/threads/{thread_id}/turns/active` (204 when the thread is idle, which the AI SDK maps to "nothing to resume"). **Deleting a conversation closes it first, then stops the turn, then cascades.** Cancelling alone is not enough: the cancelled turn releases its mutex when it stops, and another tab can start a successor before the cascade runs. The gate is the reaper's own claim (`claim_chat_session_for_retirement`, [SBX-011](sandbox.md#sbx-011)), which makes admission fail atomically — one mechanism for "this conversation is going away", not a second one. The record is deleted **last**, after the checkpoint and sandbox, by the same `delete_session_state` the reaper uses. The route used to do the opposite and swallow a cleanup failure behind a 204: the record is what makes a thread findable, so that left the transcript stored forever with nothing to retry from and nothing saying so. Every uncertainty on that path is a **503**, never a delete: a failed cancel, a lost claim, or a turn that does not stop within the internal stop deadline. The claim is re-claimable by design, so the session stays closed and the retry is a plain repeat; a conversation half-removed from under a live producer cannot be put back. ### Expiry and sweeping `expires_at` on a **running** turn is a claim on the thread, and admission retires a lapsed one. It is therefore derived from the turn's own timeout (`CHAT_TURN_TIMEOUT_SECONDS` plus an internal safety margin, in the shared `chat_turn_lease_expiry`), **not** from the replay retention window. On finish the record is re-stamped with `CHAT_TURN_RETENTION_SECONDS`, which is a replay deadline rather than a claim. This replaced a **renewable** lease heartbeated by the producer. Renewal existed only because a detached task had no other way to prove it was alive, and it brought three separate correctness rules with it (moving the record and the active pointer in one transaction, re-checking expiry in the takeover update as well as the read above it, and a sweep that could not assume creation order was expiry order). With the workflow bounding the turn, a fixed lease derived from that bound is sound and all three disappear. **Deletion is transactional.** Event batches and their turn row are removed in one PostgreSQL transaction. The idempotency key is constrained on that same turn row, so deleting the row cannot leave a separate key that resolves to nothing or permanently blocks a retry. **Note:** expired logs are swept at the end of turns, not by a scheduler. A log belongs to a *turn*, so `delete_chat_session`'s cascade is not enough. The PostgreSQL `expires_at` index lets a bounded ordered query read only expired turns, with no scan cursor or renewal path. **Heartbeat on a timer, never on output.** A turn is quietest exactly when it is slowest — a model call or a tool can run for minutes producing no chunk — so heartbeating from the stream loop times out *healthy* turns. The damage is not just a failed turn: the fallback finalizer marks it failed and frees the thread while the original activity is still running and still writing the same checkpoint, which is two producers on one conversation. The activity ticks independently (`_CHAT_TURN_HEARTBEAT_INTERVAL_SECONDS`); `start_to_close_timeout` is what bounds a turn that genuinely runs too long. **A terminal write is conditional on the turn still being `running`.** Two writers reach `finish_chat_turn` for one turn: the turn closing itself, and the workflow's fallback closing it after the activity timed out. A turn that timed out *spuriously* is still alive, so an unconditional write lets the late one replace a recorded outcome — including its `last_seq`, which is exactly what a reader uses to know it has seen the whole answer. First writer wins, enforced by `WHERE status = 'running'` in the update itself. The loser is handed **what is recorded**, not what it asked for, so it can tell it lost; `None` keeps meaning "no such turn". `produce_turn` reports the store's status rather than its own, so the workflow result and the log a reader sees cannot disagree. ### The client holds a pending send Stop is live from `submitted`, which is *before* admission answers, so there is a window where the user can ask to stop a turn the client cannot yet name. Aborting the admission request does not close that window — the server may already have admitted and started the turn, which then runs with nobody watching it. The transport therefore keeps one `PendingSend` per logical message (idempotency key, turn id once known, and a `stopRequested` flag), and admission is deliberately **not** given the abort signal. A stop with no id yet is recorded and applied the instant there is one. Three rules fall out of that object, and each was a live defect without it: - **The key is minted per logical message, not per attempt.** A fresh key per attempt puts the server's idempotency promise out of reach: a retry admits a *second* turn instead of resolving to the one a lost response already made. - **Ambiguous admissions are retried by the transport, with that key.** A 503 or a dropped connection means the turn may well have been admitted, and the server's repair path is reachable *only* by asking again with the same key — waiting for the user to resend does not work, because their next message gets a new id and therefore a new key, which admits nothing and is told the thread is busy. This retries an idempotent *request*; the turn still runs at most once ([AGT-007](#agt-007)). A 409 or 404 is a decision, not ambiguity, and is never retried. - **Pending state is a map keyed by thread, not one slot.** The transport outlives any one conversation and the sidebar can switch mid-turn, so a single slot means whichever thread acted last owns it and the others silently lose Stop. Reattaching in an idle conversation must clear only its own entry. - **Completion is identified by the turn that ended**, not by the thread on screen and not by "the stream currently being read". Several conversations can stream at once, so the latter is not a single thing either — an earlier thread finishing after a later one starts would be attributed to the later one. `clearFinishedTurn(turnId)` takes the id off the finished message; a turn that ended without announcing one is left alone, which keeps it stoppable. The producer therefore carries `turn_id` in the opening frame's message metadata; defining the TypeScript field without emitting it leaves every completion unidentified. - **Unresolved threads are a set.** One value means a second ambiguous send in another conversation hides the recovery the first still needs. - **Reconnect prefers a turn this client already holds** over asking `/active`, which answers 204 once the turn has finished — losing a response nobody has rendered yet. That is exactly the case after a retry resolves to a turn that completed while the connection was down. - **An unresolved send is recoverable from the UI**, or the preserved key is unreachable and the repair path is theatre — typing the message again mints a new key and admits a second turn. The banner offers Retry, which replays the stored body under the stored key. First send and retry share one admission path so they cannot drift. - **Don't** hold that state only in the transport. It lives on plain objects React cannot observe, so the button never renders and the feature is invisible in the product while testing green against the transport directly (`onUnresolvedChange` mirrors it into state). - **Recovery is rendered from unresolved state, not SDK error state.** Changing threads recreates the SDK chat and its transient error, while the transport's unresolved key deliberately survives. Nesting Retry under `error` therefore hides the only route back to the turn after navigating away and back. - **The pending send is scoped to its thread.** The transport outlives any one conversation and the sidebar can switch sessions mid-turn, so a turn finishing in a thread the user has navigated away from must not clear the pending state of the one they are watching — that silently disarms Stop for the turn that is actually running. - **Admission carries its own deadline.** It deliberately ignores the SDK's abort signal, so without one a response that never arrives pins the send forever: the retry loop cannot advance and a stop asked for meanwhile is never delivered, because it is waiting on a turn id. A timeout is ambiguous in exactly the way a 503 is, and retries with the same key. - **"Unknown" is not "failed".** A send whose attempts all ended ambiguously keeps its key (`unresolved`), because the turn may exist server-side and the key is the only route back to it; a 409 or 404 is a decision and spends it. `onFinish` fires for errored sends too, so clearing pending state there unconditionally strands exactly the turn the repair path exists to recover. - **A deferred stop reports through a callback, not a return value.** A stop asked for before admission answers is carried out later, by which point `requestStop` has returned and throwing from the send would be swallowed by the SDK as an expected abort — so the exact race the deferral exists for would be the one whose failure is invisible. (A promise settled later was tried first and is worse: it deadlocks any caller that awaits it before releasing the step that settles it, and strands unsettled on an admission failure.) - **A finished turn stops being the pending one.** Otherwise a Stop pressed during the *next* send — before that one is admitted — cancels the turn that already ended and silently does nothing to the live one. - **A refused cancel raises.** The reader stops either way, so an unchecked 401/403/5xx looks exactly like success while the turn keeps generating and running the actions it had queued. ### Testing notes - A concurrency test here needs **real connections**. The SQL store's fixture uses `StaticPool`, which hands every session the *same* connection, so two "concurrent" sessions interleave inside one transaction and the race cannot occur — a broken read-then-write passes, with both callers reporting success even though only one row lands. `test_two_concurrent_admissions_cannot_both_win` builds its own file-backed engine for that reason. - These races are easy to write tests *around* rather than *for*. Several tests here passed against the broken code before being fixed, so **check a new one fails against the version without the fix**. - The route tests stand in for Temporal by running the activity inline (`_fake_temporal`), so admission, the producer and the reader are exercised together without a worker. The fake records workflow starts in a **list**, not a dict keyed by workflow id — a second start against the same id is precisely the bug worth catching, and a dict silently overwrites it. (That mistake made the first version of the "don't re-run a finished turn" test pass with its own fix removed.) - CI's `unit` job blocks network, which catches unmocked store and Temporal calls that pass locally because a real Temporal and Postgres are there to fall into. A locally green backend suite does not prove the mocks are complete. - **When you scope one piece of state to a thread, check its siblings.** Mapping `pending` by thread while leaving `streamingThread` global reproduced the same bug one field over, and the test missed it by having the *later* thread finish — the order a single slot handles correctly. Order the actors so the earlier one finishes last. - **Drive client behaviour through the path a user takes.** Two defects here were masked by tests that called the transport directly: a `clearPending` scoped by an argument the test supplied (so it never exercised the callback that reads the *wrong* thread), and a Retry that worked in the transport while never rendering a button. If a test supplies the value the bug is about, it cannot see the bug. - **A resumed SSE stream cannot be fed to the AI SDK mid-message.** `createStreamingUIMessageState` initialises `activeTextParts: {}` even when it reuses the existing assistant message, and `text-delta` throws when that map has no entry — the `text-start` was before the cursor and is not re-sent. Resuming was implemented and reverted for this reason; replaying from the first frame is what works. Note that no test here can catch it: the environment's `fetch` has no streaming body, so `processResponseStream` is stubbed and the real parser never sees a resumed stream. - In the frontend suite, `jest.mock` is applied at call time by Bun rather than hoisted, so it **cannot** replace the superclass of a class that has already been evaluated — mocking `ai` does not change what `SeizuChatTransport` extends. Stub the instance method instead. ## AGT-037 — The answer is written once, from output that keeps its conclusion **Applies to:** `chat_orchestrator._keep_ends`, `CHAT_ORCHESTRATOR_SYNTHESIS_STEP_MAX_CHARS`, `_closing_summary_ids`, `_init_plan`, `_PLANNER_PROMPT` A reachability turn's final answer did not carry the source-level findings its own steps had produced. Two separate causes. **The bound took the wrong end.** Each step's output reached the synthesizer through an unnamed 4,000-character cut taken from the front. A step's answer is at its *end* — the reachability skill instructs its sub-agent to state the verdict last — so the cut removed the verdict and kept the working. Measured on one turn: three reachability steps produced 3,414, 4,383 and 2,772 characters and only the longest lost its conclusion, which is the shape of a bug rather than of a budget. `_keep_ends` keeps both ends and drops the middle, weighted to the tail. The bound is now named and larger, and it is **not** the real limit: the request is fitted to the model's window afterwards ([CTX-001](chat-context.md#ctx-001)), which is what has to hold. This is only a guard against one enormous step crowding the others — the same relationship `CHAT_ORCHESTRATOR_SYNTHESIS_EVIDENCE_MAX_CHARS` has, and that one applies to a different thing entirely: steps that produced *no* output and are shown their tool trace instead. **The plan summarized, then the synthesizer summarized the summary.** Plans ended with an `answer` step gathering the earlier findings, and the turn's answer is already written from every step's output — so the same summary was produced twice, losing detail at the extra hop and paying a worker and a verifier call for it. **Told not to, the planner kept doing it: three of four samples.** So it is removed rather than asked again. `_closing_summary_ids` drops an `answer` step that waits on other steps and that **nothing waits on**. Narrow deliberately: a mid-plan decision later steps consume is kept (selecting three CVEs is an answer step), and a plan that is a single answer step — the request needing no live action, or reporting that nothing can obtain the evidence — is untouched. The prompt rule stays, because it tells the planner *why*; the code is what makes it hold. ## AGT-036 — Package metadata comes from an external MCP, because nothing else can supply it **Applies to:** the `external-mcp-deps` Compose service (`ghcr.io/mappedsky/depsdevmcp`), `MCP_EXTERNAL_PROXIES` Seizu's graph records which package versions are **installed** — `version` and a resolved `requirements` pin per manifest — and which fall in a CVE's vulnerable range. It does not record what a package *declares it needs*. There is no property or edge saying `botocore 1.42.91 requires urllib3 <2.0`, because that is registry metadata rather than scan output. **A sub-agent asked to judge reachability filled the gap from memory.** Read off its own thinking: *"botocore pins urllib3 < 1.27 historically, but modern botocore (>=1.29) requires urllib3 >= 1.25.4, < 2.0? … Actually botocore 1.29.0 was released around Dec 2022 and it still required urllib3<1.27… **But this is a fictional future scenario (2026 versions)**"*. It discarded the speculation that time. A security verdict resting on remembered version history is the failure this exists to prevent — [AGT-016](#agt-016)'s rule about invented identifiers, one layer down. **The sandbox cannot fetch it.** Measured: `curl` to pypi.org and raw.githubusercontent.com both time out, and `getent hosts pypi.org` fails — no egress, not even DNS. So an instruction to "look it up" would have sent it somewhere it cannot reach, and its discovery clause would then have had it conclude the data does not exist. **Not a call from the Seizu backend either.** Third-party egress belongs in a component the operator runs, which is what the external MCP proxy mechanism already is. The tools arrive as `ext__deps__depsdev_*`, all carry `readOnlyHint` so [AGT-010](#agt-010)'s gating skips confirmation (verified), and they inherit the rate-limit retry from [AGT-029](#agt-029). Only a package name and version leave the network. **Transport, not tools, was the integration question.** `depsdevmcp` began stdio-only, and Seizu's external MCP speaks `sse` or `streamable_http`. Teaching Seizu stdio was rejected: the no-pooling rule (a fresh transport per call per user, for identity isolation) would make it a **process spawn per tool call**, and it would move the egress back into the Seizu backend. A bridge sidecar was the fallback; upstream adding a streamable-HTTP mode removed the need for either. Its default listen address is loopback, so the container is given `DEPSDEVMCP_HTTP_ADDRESS=0.0.0.0:8080` or nothing can reach it. **What it gives.** Nine read-only tools: declared requirements, resolved dependency graphs, package and version metadata (advisory keys, licences, attestations), OSV advisories, project metadata and a Graphviz rendering. It holds a process-local LRU cache, which matters because a reachability sub-agent asks repeatedly — measured `cached:false` then `cached:true`, 0.36s then 0.16s. **Known gap.** There is no chain-shaped answer: nothing returns *"does X pull in Y, and by what path"* directly, so a sub-agent asking that fetches the resolved graph and walks it in `run_python`. That works and costs a round trip and some tokens. Worth adding upstream if reachability becomes a common workflow. **Declaring it in a skill is what made it reachable.** Under progressive disclosure a sub-agent only sees what a skill declares, so until one named these tools nothing directed it to them. `cve_response/dependency_provenance` covers the standalone question, and `repo_cve_reachability` — where the guessing was measured — carries `find_dependency_path` and `get_requirements` plus a step telling it never to state a pin from memory. **Verified on a full turn:** the sub-agent called `ext__deps__depsdev_get_requirements` six times, all succeeding in under a second, in place of recalling a pin. `find_dependency_path` was not reached in that run, so the transitive-path tool is exercised only by its own tests so far. ## AGT-035 — The planner believed two false things about its own system **Applies to:** `mcp_builtins/sandbox.py::_delegate_description`, `_PLANNER_PROMPT` Read off the planner's own reasoning ([AGT-033](#agt-033)'s instrumentation), against the listing [AGT-034](#agt-034) gave it. It uses the tool names as intended — *"cve_severity_analysis? That gives CVSS distribution, not individual CVE vectors, and not remote. Not enough."* is the plan-time capability check the names were added for. But it held two beliefs that are simply wrong. **"No graph tool listed… all graph tools are only accessible via skills."** Every delegation is bound `SANDBOX_CORE_TOOLS` — `graph__query`, `graph__schema`, `graph__validate_query`, `graph__explain` ([SBX-003](sandbox.md#sbx-003)) — and the tool's description never said so. It said what a delegation is *not* for ("cannot be expressed as a Cypher query… prefer those first"), which points the same way. The description now names the core tools, **built from the setting** rather than written out, because the set is configurable and a description that drifts from it is worse than none. **"It can likely run one skill only?"** Every skill is a tool spec on every worker (`_worker_tool_specs`), so a step can render as many as it needs. Believing otherwise, the planner adds a step to reach a second skill — and a dependent step waits for its parent and runs in a *later wave*, so the belief costs wall-clock parallelism rather than tidiness. The prompt now states it, with that consequence attached. **Measured: no detectable change in plan shape.** Before, one of three samples chained findings into reachability; after, one of four. Dispatch waves 3–5 either way. Both corrections stand on being *true* — a planner reasoning carefully from false premises reaches wrong conclusions, and this is the third change in a row whose value is correctness rather than a number ([AGT-034](#agt-034)). Recorded so the shape hypothesis is not re-tested from scratch. ## AGT-034 — What the planner is told about capabilities, and what it is not **Applies to:** `chat_graph.build_capability_context(for_planner=)`, `_PLANNER_CAPABILITY_HEADER`, `_format_skills(with_tools=)`, `_skill_required_tool_names`; `chat_orchestrator.planner_node` The planner sees each skill's name, description, trigger phrases and argument names, plus the always-disclosed tools. It has **no tool-calling at all** — `_structured_invoke` passes an empty tool list — so it names capabilities and the worker renders them at execution time. Two things followed from that being implicit rather than stated. **It was told to do something it cannot do.** The capability header is shared with the executing agent and is written in the imperative: *"call the relevant skill tool"*, *"if the current user request matches a trigger phrase, call that skill now instead of describing how to trigger it."* The planner has neither the mechanism nor the tools bound. Its own thinking, read off the span: *"This is strange: as planner, if task matches trigger, call skill now instead of describing how to trigger it. But the output expected is a plan."* It now gets a header describing the same catalogue as **names its steps may use**. **Measured, and it did not help:** median reasoning 22,810 chars before against 24,262 after, four samples each, ranges 705–34,107 and 17,564–29,692. The contradiction is real and worth removing on correctness grounds — an instruction that cannot be followed is a defect — but **it is not what the planner's thinking is spent on**, and this entry exists so nobody re-derives that hypothesis from the same quotes. Plan shape was unaffected (3/3 `plan_probe` samples valid, fan-out preserved). **It could not see what a skill can reach.** `seizu_tools_required` rides on every skill listing and was dropped by `_format_skills`. The cost was concrete: one plan set a step's success criteria to an installed package version, gave it a dependency whose skill has no tool returning one, and the step failed three times for the gap ([AGT-032](#agt-032)). The planner's listing now renders those names. **Names, not descriptions.** Measured on this deployment: names inline cost 1,847 characters, names plus a de-duplicated description glossary 6,288, a description at every mention 6,719 — 75 references over 49 distinct tools, so de-duplication saves almost nothing. Tool names here are descriptive enough to carry the mapping (`github_security__top_vulnerabilities` against `github_security__repo_dependencies`), which is what the planner lacked. The **planner's** listing only: the executing agent is given a skill's tools when it renders one, and its context is the one [SBX-003](sandbox.md#sbx-003) measured. **Not done: an interactive planner.** Letting it call `load_seizu_skill` was probed. The mechanism works — it breaks off after the first round and reliably loads the three relevant skills — and over four samples it is *suggestive but not established*: median 87s and 16,150 reasoning characters against a non-interactive median of ~23,000, with ranges overlapping almost entirely, and one early sample at 52s that did not survive repetition. Judging it properly needs the real structured-output path (schema enforcement, budget accounting, `_plan_problems` validation) rather than a probe, because the metric that matters is plan validity and a probe without the schema cannot produce a valid plan. The objection that stopped it earlier — "two or three calls at planner latency" — is **wrong** and recorded here so it is not reused: that latency largely *is* the speculation the loading removes. ## AGT-033 — What a stage spends thinking is recorded, and graded for the two that answer once **Applies to:** `chat_budget.LlmUsage.reasoning_tokens` / `usage_from_message`, `chat_models.applied_reasoning_effort`, the `llm` span in `chat_graph._run_llm_tool_turn` and the sub-agent span in `sandbox.py`; `CHAT_LLM_PLANNER_REASONING_EFFORT`, `CHAT_LLM_SYNTHESIZER_REASONING_EFFORT` **A slow stage looked like a slow model.** Spans carried duration, tokens, cost and finish reason, and on a reasoning model the thinking and the answer come out of one `output_tokens` figure — so a planner emitting 6,621 tokens for a plan whose JSON is under a thousand is indistinguishable from a planner writing a very long plan. LangChain reports the split as `output_token_details.reasoning` and `usage_from_message` read only the input side. It reads both now. **Measured on the turn that prompted this** (1,194s total): planner 110s, 3,385 in and 6,621 out; synthesizer 229s, 9,401 in and 10,084 out for an answer of about 2,400 tokens. **27% of wall clock for 3 of ~70 calls**, most of it thinking. **The sub-agent's thinking is recorded the other way round.** It does not stream -- `create_react_agent` calls the model directly -- so its span reads `reasoning_content` off the finished message where the outer path accumulates deltas. Both end up on the span under `telemetry.content`, which is what makes a delegation's deliberation readable at all: it is the largest single consumer of a turn's model time (141-144s inside each parallel step of one measured batch) and was previously visible only as a token count. **The effort is read off the model, not re-resolved.** `reasoning_effort` reaches the provider through `model_kwargs` because ChatLiteLLM swallows it as a constructor argument ([AGT-019](#agt-019)), so "what this deployment configured" and "what the provider was told" are different questions, and only the second one is worth putting in a trace. ### The levels are the provider's, not ours, and they are not all distinct Six samples per level on `deepseek-v4-pro`, median reasoning tokens: | effort | median | range | |--------|--------|-------| | (unset) | 179 | 83–223 | | **low** | **104** | 63–164 | | medium | 229 | 89–359 | | high | 212 | 87–388 | Only `low` separates. That is not noise and not litellm flattening the level: **DeepSeek collapses `medium`, `high` and `xhigh` into one setting and defaults to it**, so three of those four rows are the same configuration sampled three times. Its distinct levels are `low`, the collapsed middle, and `max`. So the portable four-level vocabulary is a *request*, not a guarantee, and a level that reads like a reduction can be identical to the default. Measure the dimension that moves — reasoning tokens — rather than trusting the name; that is what this entry's instrumentation is for, and it is why `reasoning_kwargs` sends DeepSeek's level through `extra_body` rather than letting litellm map it. Both stages are set to **`low`**, the one level that measurably reduces thinking on this provider. The risk to watch is plan *shape*, not truncation: [AGT-019](#agt-019)'s one-step-plan failure was an output *ceiling* of 4,096 leaving no room to think and then emit, which is a different mechanism from thinking less within an ample ceiling. `scripts/plan_probe.py` measures shape in seconds and is the check before assuming `low` is free. **Note the prompt.** Those samples used a trivial one, not a planning call with a full schema and 3,385 tokens of context. They establish what the levels *are* on this provider; they do not predict the real workload. ## AGT-032 — Every attempt records its trace, not only the ones cut short **Applies to:** `chat_orchestrator._run_worker_step` (the `_persist_step_record` branch), `_prepare_retries`, `_RETRY_EVIDENCE_MAX_CHARS` A retry is handed what the previous attempt established: a digest in the prompt, and — where one exists — the path to the whole trace on the sandbox's disk ([AGT-013](#agt-013)). The file was written only when the attempt ended early, on budget or on an execution error. **The commonest retry is neither.** A step that *finished* and was then failed by the verifier is the ordinary case, and it is the one whose previous attempt produced the most: a complete result, judged incomplete. It was the single case that wrote no file. **Measured.** A reachability step wrote a seven-CVE assessment in 396s and was rejected — *"claims to review 8 findings but only covers 7 CVEs"*. Its retry got `_step_evidence(..., 4000)`: a 4,000-character excerpt cut off exactly where the per-CVE detail lives. It re-derived the rest in **307s**, only 23% cheaper than the cold attempt, and passed with *"all 7 selected CVEs"* — the same seven. That is 26% of the turn spent re-deriving what was already on disk, for a rejection whose own count moved between attempts. The branch now runs whenever the attempt made calls. The cost is one small sandbox write per step, against a re-derivation measured in minutes. ## AGT-031 — A sub-agent's tool signature is the tool's schema, composite types included **Applies to:** `mcp_builtins/sandbox.py::_py_type_for`, `_JSON_SCALAR_TO_PY`, `_build_seizu_tools` A sub-agent's tools are built as pydantic models from each tool's JSON Schema. The type map held the three scalars and fell back to `str` for anything else, so every **array** parameter was declared to the sub-agent as a string. **A model told a parameter is a string passes a string.** It then failed at the far end — `parameter fields could not be coerced to []string, is string` — which reads as the model getting it wrong and is the schema telling it wrong. Measured on one reachability turn: **12 of the turn's 12 tool errors were this**, across `search_code`, `list_commits` and `get_file_contents`, and it was the only thing failing in that run. `array` maps to `list[]` (a list of strings when the schema does not say what it holds, never a string), and `object` to `dict[str, Any]`. The fallback for a genuinely unknown type is still `str`, but composite types no longer reach it. **Found by the tool spans** ([AGT-029](#agt-029)), not by reading the code. The failures were ordinary tool results — the sub-agent absorbed each one and carried on — so before those spans existed they left no trace to notice, and the same bug had been running through every delegating turn. ## AGT-030 — The call ceiling only ever fires; it never throttles **Applies to:** `chat_budget._refresh_mode_locked`, `derived_call_ceiling`; `chat_orchestrator._grant_for` / `_calls_are_primary`; `CHAT_RUN_LLM_CALLS_PER_STEP`, `CHAT_RUN_UNPRICED_LLM_CALLS_PER_STEP` [AGT-024](#agt-024) established what the call ceiling is: an emergency loop guard, where cost bounds spend ([AGT-022](#agt-022)) and loop detection catches loops ([AGT-017](#agt-017)). The code did not treat it that way, and a measured run showed all three ways it did not. **It throttled a healthy run.** `_refresh_mode_locked` took the maximum ratio across tokens, cost *and* calls against the soft limit. A reachability turn crossed 0.75 on calls alone — **135 of 176, with 17.6% of its cost budget spent** — and went `degraded`, which is not cosmetic: optional steps are skipped outright, and the worker and synthesizer drop to the economy model. A run doing a lot of productive work was quietly given a worse model and fewer steps. Calls are now out of the soft-limit calculation entirely; the hard stop is unchanged, and is the only thing the dimension does. **It was sliced per step.** The call grant went through the schedule divisor, which is [AGT-025](#agt-025)'s exact finding one dimension over: a backstop divided by the schedule stops being a backstop. On the measured plan a 176-call ceiling became 6 calls for a step that needed about 20, and the step stopped on `budget_step_share` before its first delegation finished. Calls now take the batch-width bound that keeps concurrent grants disjoint — unless neither cost nor tokens is budgeted, in which case the loop guard genuinely *is* the budget (`_calls_are_primary`) and is fair-shared like one. **It was sized at the median, not above the maximum.** 24 calls per step was chosen before a step could delegate. Measured on a delegating turn: **13.4 sub-agent calls per completed step**, plus the worker's own loop, its summary and the verifier — around 20 for an ordinary step, more with a retry. So the figure sat at roughly what legitimate work costs, which is the one place a safety limit must never sit. It is 96 where cost can bind, and `CHAT_RUN_UNPRICED_LLM_CALLS_PER_STEP` (24) tightens it where LiteLLM cannot price the model and cost can therefore never accrue — the same split `derived_token_ceiling` makes for tokens. Zeroing `CHAT_RUN_LLM_CALLS_PER_STEP` still disables the dimension outright; the unpriced figure only ever lowers a ceiling, never restores one. **Why not simply raise the number.** That would be the third re-fit of the same constant (64 → 120 → `8 + 24n`), each correct for the plan in front of it. The count is not what should be doing this work: cost bounds what progress may cost, the turn and sandbox timeouts bound how long it may take, and `_looks_stuck` bounds going nowhere. The ceiling exists for what those three miss — cheap, fast, novel-every-time and useless — and should be sized so that reaching it is itself the evidence. **Worth knowing:** `_looks_stuck` keys on an exact tool-plus-arguments signature, so 88 distinct-but-fruitless searches pass it untouched. If a per-step "is this going anywhere" signal is wanted, widening that is the mechanism; a call count only proxies it. ## AGT-029 — A tool call is traced by its outcome, and a named rate limit is waited out **Applies to:** `mcp_runtime._guarded`, `mcp_builtins/sandbox.py` sub-agent tool wrapper; `external_mcp.call_tool` / `_call_tool_once` / `_rate_limit_delay`, `MCP_EXTERNAL_RATE_LIMIT_*` **A failing tool was invisible.** Spans covered the turn, its batches, its steps and every model call, and the `mcp-python-sdk` contributes its own for external MCP traffic — but nothing recorded whether a call *worked*. Across one reachability turn, **no span carried an error status or event at all**, while the stream log showed 21 errored tool results. Seizu's own tools (`graph__query`, `github_security__*`, the sandbox's five) had no spans of any kind. They are traced now at the two places every call already passes: `_guarded`, which is where a call's outcome is decided for built-in, user-defined and external tools alike, and the sub-agent's tool wrapper, which is the only path the sandbox's own five take. `outcome` is a string — `ok`, `error`, or the block reason — for the same reason `stopped_by` is on a step span: "it failed" and "it was refused" are different questions. Error text is content and stays behind `TELEMETRY_RECORD_CONTENT`. **What that made visible immediately.** Two runs of the same request, believed comparable, were not: | | run 1 | run 2 | |---|---|---| | external `tools/call` | 92 | 89 | | median call | 1,272 ms | 544 ms | | calls over 5s | 5 (max 15.5s, all `search_code`) | 0 | | hard rate-limit refusals | not recorded | 5 named, 19 errored calls | One run absorbed GitHub's code-search limit as throttling, the other as refusals. **Do not compare two runs' cost without checking this**; the difference between those two was substantially upstream conditions, not anything in Seizu. **A refusal is an ordinary result, so nobody was waiting.** The upstream says `"GitHub API rate limit exceeded. Retry after 44s."`, that string reaches the sub-agent as tool output, and the sub-agent does the only thing it can — call again, into the same closed window. 13 of 34 code searches were refused this way in one turn. `call_tool` now reads the delay **out of the refusal** rather than backing off blindly: the upstream knows when its window resets and says so, and a guess either wastes the wait or spends the retry on the same refusal. Bounded three ways, because a sub-agent's whole delegation is bounded by `SANDBOX_TIMEOUT_SECONDS` and one tool call must not spend it asleep — a retry count, a cap past which the refusal is handed back unretried, and a default only for a limit that names no delay. A non-rate-limit error is never retried: a 404 retried is a 404. **A non-zero shell exit is output, not a fault.** `grep` exits 1 when it matches nothing; `curl` exits 2 on a bad flag. E2B raises for all of them, and letting that propagate ended the *whole* delegation — the sub-agent lost every result it had gathered because one command reported "no match". Two delegations died this way in one measured turn. `run_bash` now returns the exit code with stdout and stderr, in the same shape a success returns, and the sub-agent decides what a failure means. The exit code is stated only when non-zero: noise on every success, and the entire finding on a failure, where `(no output)` alone reads as a broken tool. **There were two exceptions doing this, not one.** A command outrunning E2B's deadline raises `TimeoutException`, not `CommandExitException`, and a fix written from the one traceback that had been seen covered only the exit code — the next live run lost a delegation to the other. Both are now reported as text. The timeout says so in words because E2B keeps nothing the command had printed, so there is no output to return: what the sub-agent can use is the fact, and the suggestion to narrow the command or background it and read a file. `run_bash_streaming` deliberately still raises. Its caller is the remediation workflow, which runs builds and tests, and there a non-zero exit is the phase failing — reporting it as output would let a failed build read as a result. ## AGT-028 — Trace the sub-agent, and recognise a rejection it has already had **Applies to:** `mcp_builtins/sandbox.py::_ToolMessageNormalizingModel.ainvoke`; `chat_orchestrator._same_rejection`, `_SAME_REJECTION_SIMILARITY`, `_SAME_REJECTION_MIN_TOKENS`, `_prepare_retries` Two defects found by running a delegating turn against a trace ([AGT-026](#agt-026)). **The sub-agent was untraced.** `chat_graph._run_llm_tool_turn` carries the span for a model call, and a sandbox sub-agent never reaches it — it runs on `create_react_agent`, whose calls go straight to the model. Measured on one reachability turn: **51 traced `llm` spans against 107 calls in the ledger, the missing 56 being every sub-agent call** — 52% of the run's calls and about 60% of its cost, invisible in the trace while fully present in the budget. That makes the one ratio worth watching on a delegating turn — cost per step that completed — uncomputable from traces. The span now sits where the budget reservation already sits, which the code says is the one place every inner call passes. **A rejection restated in new words was not recognised as the same one.** The guard added by [AGT-017](#agt-017) compared a verdict to the previous one with `==`, and a verifier writes its verdict fresh every time. A step was observed failing three times for the one thing its dependency could not supply, each rejection worded differently, each one passing the guard as new. **Measured, on the verdicts that run actually produced** (Jaccard over content words): | pair | overlap | |------|---------| | the two rejections of the same step, reworded | **0.500** | | two different "incomplete" complaints | 0.103 | | unrelated rejections | 0.022 – 0.023 | `0.4` sits in a five-fold gap. **The length floor is not decoration:** at three or four content words one shared word carries the ratio, and the first version of this scored two plainly different terse rejections at exactly 0.400 — caught by an existing test, not by inspection. Below `_SAME_REJECTION_MIN_TOKENS` the comparison stays exact. **Direction of error.** A false positive ends a step that a retry might have saved; a false negative spends another attempt. AGT-017's finding is that the second is the one that actually happens, so the threshold is set to catch the observed repeat rather than to be safe against every conceivable one. ## AGT-027 — Fan out over divergent work, and seed what a batch will all want **Applies to:** `_PLANNER_PROMPT`, `_PlannedStep.map_over` / `map_reason`, `chat_orchestrator._seed_shared_schema`, `_dispatch_batch_distributed`; `mcp_builtins.sandbox_result_dir` Expansion ([AGT-023](#agt-023)) fans a step out over the items an earlier step found. What it cannot tell on its own is whether those items need *different work* or are the same call with a different argument, and the planner prompt used to push it one way: "never write a step that loops over a collection internally". That is right for an agentic loop and wrong for a data one. **Measured, on a request to review one repository's CVEs.** The planner mapped "query the graph for the repositories and packages carrying this CVE" over eight CVEs. That is one Cypher query with an `IN` list. The fan-out took **70 of the run's 80 LLM calls and 219,049 of its 297,017 tokens, and returned nothing** — the answer was written from the un-mapped first step. **So the planner is told the test, not a preference.** Same call with a different argument is one step that fetches everything at once; its own tools, or a decision that depends on what the item turns out to be, is a mapped step. `map_over` is paired with `map_reason` — a planner that has to name what differs between items writes fewer fan-outs — and the reason rides the `chat expand step` span, so a wide trace says whether the width was earned. **Measured after, on the same build.** The CVE request planned two steps and no fan-out (0 of 3 samples), finishing in 263s on 18 calls and $0.020 against 433s, 80 calls and $0.044 — with a *better* answer, covering all 19 findings where the old one had reported a gap. A reachability request over three CVEs still fanned out (2 of 3 samples), `map_reason`: *"Each selected CVE can belong to a different repository and needs its own repository CVE finding call with that repo and CVE id."* The discrimination runs both ways, which is the whole point. ### The batch, not the carry, is what made eight sub-agents fetch one schema Each of those eight children opened by fetching the same 52,846-byte schema, writing it to its own file **on the disk they were already sharing**, and reading it back — two of the four calls each could afford, before reaching its question. **The obvious fix was already built, and was not the fix.** Handing a step the files its dependency saved duplicates two existing mechanisms: `EpisodeLog.recall` already advertises a sandbox's receipts to a sub-agent, and `session_digest` already puts the same manifest in the *worker's* prompt for the model deciding whether to delegate at all ([SBX-008](sandbox.md#sbx-008)). A parallel path was built, measured against them, and **reverted** — it emitted a near-identical second copy of the same list, and unlike `render_receipts` it did not filter on `sandbox_id`, so the one case where it said anything new was the case where the file was not readable. **The steps of a batch start together**, and a receipt reaches a sibling only once the batch it was written in has returned. No carry can close that, because there is nothing yet to carry. `_seed_shared_schema` fetches the schema once before a distributed batch of two or more, writes it to a fixed path under `sandbox_result_dir()`, and records the receipt — after `_shared_sandbox_id` has opened the sandbox and **before the ledger is serialized for the workers**, or the batch it was fetched for is the one batch that cannot see it. Bounded deliberately: batches of one are skipped (a lone step's delegations already carry it to each other), it never opens a sandbox that SBX-015 did not already open, it re-uses the file across a turn's later batches, and it is skipped for a caller without `query:execute` so seeding cannot hand anyone data they could not have asked for. Every failure path is a warning and a return. **Not done:** seeding anything else. The schema earns it by being the one thing every sub-agent wants, identical for all of them, and the largest single result the graph returns; nothing else on that list is obvious. ## AGT-026 — Tracing is diagnosis, opt-in, and content-free by default **Applies to:** `reporting/services/telemetry.py`, `chat_graph._run_llm_tool_turn`, `chat_orchestrator._dispatch_batch` / `_run_worker_step_with_session` / `_expand_mapped_steps`, the Temporal client and worker; `TELEMETRY_*` Spans cover the turn, its dispatch batches, each plan step, each expansion and every model call, exported over OTLP to any collector. Off unless `TELEMETRY_OTLP_ENDPOINT` is set. **Why it needs the Temporal interceptor to be worth anything.** A turn spans at least three processes — the web service admits it, one worker activity drives it, and its plan steps run as further activities that may land on other replicas ([AGT-018](#agt-018)). Without propagated context those are unrelated trees and the only question worth asking, *where did this turn spend its time*, has no answer. `temporalio.contrib.opentelemetry.TracingInterceptor` on both the client and the worker is what joins them. **What it answered on the first traced run**, a 208-second turn: | | share | |---|---| | `llm planner` (one call) | **54%** — 112.5s | | both dispatch batches (five steps, parallel) | 30% | | `llm synthesizer` | 12% | | every worker and verifier call | 12% | One planner call cost more than all the parallel step work put together. That had been invisible: the planner is a single structured call with no step of its own, and the whole of [AGT-023](#agt-023)'s tuning went into the steps. It is also the stage still on the reasoning model at adaptive effort. **Content is opt-in.** `telemetry.content()` returns `""` unless `TELEMETRY_RECORD_CONTENT` is set. A trace of this system carries graph rows, tool output and the user's own words, and exporting it sends them wherever the collector is — a different decision from wanting timings. Exceptions are recorded by **type** for the same reason: an exception string here routinely carries tool output. **Never load-bearing.** `configure()` swallows its own failures, a span is a no-op context manager when tracing is off, and budget decisions read the ledger (`chat_budget`) rather than any of this. A run must spend the same whether or not the collector is reachable. **The local collector has its own Compose toggle.** `make otel_enable` selects the local endpoint and enables telemetry; `make otel_disable` clears that endpoint and deselects the collector. `OTEL_COLLECTOR_ENABLED` controls the `tracing` profile independently of `TELEMETRY_ENABLED`, because a remote OTLP endpoint does not need a local collector. Disable preserves remote endpoints, and `make down` includes all Compose profiles even when their toggles are already off; otherwise the documented disable-then-restart sequence leaves optional services running. This also covers external MCP and auth containers. **Not metrics.** Step ids are content-derived (`s2-cve-2026-44432-61daf8`), which is fine as a span attribute and unusable as a metric label; anything wanting aggregate counters should derive them in the backend. ## AGT-025 — Only the dimension that bounds the run is fair-shared **Applies to:** `chat_orchestrator._cost_is_primary`, `_grant_for`, `_step_thresholds`, `_dimension_share`, `_terminal_status`, `_verify_step`, `chat_budget.grant_ledger` Found by running a real interactive turn on an expanded plan (AGT-023). Three defects, all in the same seam between per-step budgeting and a plan that grows. **1. A backstop divided N ways stops being a backstop.** `_step_thresholds` and `_grant_for` sliced *both* dimensions by the schedule divisor ([AGT-020](#agt-020)). Expansion made the plan 17 steps deep, so the divisor reached ~24 and the 2,000,000-token backstop became ~66,000 per step — small enough that every second- and third-stage step was cut before producing even a summary, while **87% of the cost budget was unspent**. Two different bounds live in that arithmetic: the schedule divisor shares what is left between the steps still to run (fairness), and the batch width keeps concurrent grants from overlapping (safety). When cost is the budget, fairness is cost's job, so the token dimension keeps **only the safety bound**. Grants stay disjoint, which is what [AGT-018](#agt-018) depends on — granting each step the whole remainder would break that on a model LiteLLM turns out not to price, since cost would then never bind. **2. A step that used its share is not a run that ran out.** `_terminal_status` returned `budget_exhausted` from any step's flag, so a turn with 13% of its cost spent reported itself as out of budget — and `_verify_step` told the reader "the run budget entered finalization" when it was the step's own grant, which for a distributed step is a separate ledger entirely. A step-level stop now carries `budget_step_share`, reports `partial`, and says so in its own words. The distinction matters because it is the difference between "raise the budget" and "this plan has more steps than it has room for". **3. Grants handed out the finalization reserve.** `_dimension_share` subtracted spend but not the reserve, where the token path used `remaining_normal_tokens` and did. Invisible while tokens were the budget; load-bearing once cost binds a step, because the reserve is what pays for the answer. `grant_ledger` also had `reserve_cost_usd` hard-coded to `0.0`, so a step whose grant bound on cost was cut with nothing to show — the failure [AGT-012](#agt-012) exists to prevent, reintroduced through the dimension that had no reserve. Both now mirror the token side. ## AGT-024 — The call ceiling is derived from the plan, not configured **Applies to:** `chat_budget.derived_call_ceiling`, `BudgetController.set_planned_steps`, `_refresh_remaining_estimate`; `CHAT_RUN_MAX_LLM_CALLS`, `CHAT_RUN_LLM_CALLS_PER_STEP` `CHAT_RUN_MAX_LLM_CALLS` defaults to `0`, meaning *derive*: a ceiling of `8 + CHAT_RUN_LLM_CALLS_PER_STEP x steps`, recomputed wherever the plan changes. A positive value pins it; zeroing both it and the per-step figure disables the dimension. **Why a constant is wrong here specifically.** The call ceiling is an emergency loop guard — what a run may *spend* is bounded by cost and tokens ([AGT-022](#agt-022)), and a loop is caught by loop detection ([AGT-017](#agt-017)). A fixed count does not bound spend or detect loops; what it actually bounds is **plan size**, wearing a safety limit's clothing. 64 was chosen when a plan was at most eight steps, and it silently became "at most about five steps' worth of work" the moment a step could expand into eight ([AGT-023](#agt-023)). **Measured:** the expansion run in AGT-023 ended `exhausted` on the call ceiling at 120 calls, having spent **28% of its cost budget** — the same failure shape as [AGT-019](#agt-019)'s output ceiling and AGT-021's reservation sizing, one level up: an expensive, productive run stopped by a number that had no relationship to what it was doing. **Only ever raises.** `set_planned_steps` never lowers the ceiling. A plan that shrinks — steps skipped by the budget sweep, a retry cycle that drops one — would otherwise pull the ceiling below what the run had already spent and finalize it retroactively. **The per-step figure is deliberately generous** (24). It covers a step's own loop, whatever it delegates to, its summary pass and the verifier's look at it, across retries; and since every one of those calls is already bounded by the step's share of cost and tokens, a high count cannot translate into high spend. The number only has to be above what legitimate work does and below what a run making calls without spending or progressing would reach. ## AGT-023 — A step maps over what an earlier step discovered **Applies to:** `chat_orchestrator._PlannedStep.map_over`, `_MapItems`, `_expand_mapped_steps`, `_child_step_id`, `_dependency_satisfied`, `_runnable_steps`, `_step_contract`; `CHAT_ORCHESTRATOR_MAX_EXPANSION` A planned step may declare `map_over: `. When that dependency passes, the dispatcher extracts the items from its output and replaces the step with one step per item; the parent takes the status `expanded` and never runs. The children are ordinary steps, so the ready set, the fan-out ([AGT-018](#agt-018)) and the retry cycle carry them with no changes. **Why the planner cannot do this.** `planner_node` runs once, before anything executes, so it can only fan out over items the *request* names. Measured on `deepseek-v4-pro`, same graph and session: "investigate these four CVEs: …" planned 5-7 steps, **4 wide**; "find the four highest-severity CVEs, then investigate each" planned **1 step**. The second is the shape the work actually has, and the planner was right — the ids did not exist yet. The cost of being right was that one step rendered a skill five times and made 57 tool calls in sequence, at the one level where nothing could parallelise it. **Ids are derived from the item, never from position.** `_child_step_id` slugs the item and appends a digest of it. The fan-out's idempotency key is built from the step ids in a batch, which is what stops a repeat from paying for the same batch twice ([AGT-018](#agt-018)); ids minted from list order or a counter would move when the model returns the same items in a different order, and the guarantee would quietly stop holding. **The budget re-allocates itself.** `_budget_divisor` and `_remaining_waves` ([AGT-020](#agt-020)) are computed from the live plan every time a batch is granted, so children are budgeted as the steps they are the moment they join it. Nothing subdivides the parent's slice, because the parent never had one — it had not run. An `expanded` step is excluded from the outstanding set, so it is not counted as work the run still has to pay for. **Expansion is bounded where the items arrive**, not by trusting the model: `CHAT_ORCHESTRATOR_MAX_EXPANSION` (default 8) cuts the list, and the run records what it dropped in `run_errors` so a partial sweep is stated rather than implied. Items are deduplicated first — two labels for one thing would run the work twice and charge for both. `0` disables expansion entirely. **A step that cannot be expanded runs as written.** No items, or a failed extraction, clears `map_over` and leaves the step pending. The alternative — a step that disappears because its collection came back empty — loses work the plan said was necessary, and the failure would be invisible. **A dependent of an expanded step waits for every child** (`_dependency_satisfied`). The parent's `passed` never arrives, so without this a synthesis step that depends on it would either block forever or, worse, run with nothing. **Each child carries its item, not the collection.** The child's goal names the item, its contract says siblings cover the rest, and its `depends_on` drops the step it was mapped over — so it is not handed, and does not pay for, the whole list it was sliced from. **Its `success_criteria` is rewritten for the item as well**: the verifier judges a step against that text, and the parent's was written for the collection, so a child covering one CVE was failed for "not covering all four" and the run reported `partial` with every step complete. **Retry works per child.** Children are ordinary steps: `_prepare_retries` resets a failed one and the siblings that passed are not re-run, while the parent stays `expanded` and is never expanded a second time (it is no longer `pending`). **A half-expanded plan needs no rule of its own.** Its children are pending, so `_has_pending_plan` and [AGT-011](#agt-011)'s discard-unless-resumed treat it exactly like any other unfinished plan. **Mapping over an already-expanded step chains 1:1.** The planner reliably produces this shape — find the CVEs, then per CVE confirm the finding, then per CVE judge reachability — so a `map_over` whose source is `expanded` takes that source's *children* as its items: no extraction call (the items are already steps), and each child depends on its own counterpart rather than on the whole previous stage, so `s3-for-X` starts as soon as `s2-for-X` passes. Without this the second mapped step degrades to one step looping internally, which is the thing the construct exists to remove. ### Measured `scripts/plan_probe.py` on the discovery-shaped request ("find the four highest-severity CVEs, then investigate each one separately") now plans a `map_over` step in both samples, where it previously planned one step. A full headless turn on the same request: ``` expansions: s2 -> 4 (CVE-2017-12791:…, CVE-2017-14695:…, CVE-2017-7893:…, CVE-2019-17361:…) s3 -> 4 (chained 1:1 off s2's children) ready sets: [s1] [s1] [4 x s2-child] [1 x s2-child + 3 x s3-child] widest ready set: 4 ``` Four wide — the same width the issue measured for the *enumerated-ids* baseline, now reached without the ids appearing in the request. The last batch mixes a first-stage child with three second-stage ones, which is the per-item edge working: `s3-for-X` started while `s2-for-Y` was still running. **The call ceiling is what binds now** — which is why it became derived ([AGT-024](#agt-024)): that run ended `exhausted` on `CHAT_RUN_MAX_LLM_CALLS` having spent 28% of its cost budget, and expansion multiplies calls by design. ### What a wide plan actually costs, once it is allowed to finish A complete three-stage expansion (15 steps, four CVEs, every step producing output) on `deepseek-v4-pro` for planning and synthesis and `deepseek-v4-flash` at `reasoning_effort=low` for the per-step stages: **22 minutes, 274 calls, $0.18, batches of four starting together, step durations 39-320s.** The same request with the reasoning model on every stage and `CHAT_ORCHESTRATOR_MAX_PARALLEL=3` did not finish in 42 minutes: steps ran 317-600s, three hit the 600s step timeout, and a stage of four children was split into two sequential batches. Two things were wrong and both mattered — adaptive reasoning on work that is mostly tool-calling, and a concurrency width narrower than the expansion it had to carry. The per-step stages are the ones to put on a fast model: they are the ones expansion multiplies. **Not done:** a general map/reduce planner, and reduce steps other than an ordinary step that depends on the expanded one. This is one construct. ## AGT-022 — A run is budgeted in cost; tokens are the backstop **Applies to:** `chat_budget.BudgetController.open_scope` / `scope_exhausted` / `scope_cost_spend` / `remaining_normal_cost_usd` / `_authorize_locked`, `chat_orchestrator._StepThresholds` / `_step_thresholds` / `_grant_for` / `_dimension_share`, `chat_step_worker`; `CHAT_RUN_COST_BUDGET_USD`, `CHAT_RUN_TOKEN_BUDGET` `CHAT_RUN_COST_BUDGET_USD` is the limit an operator tunes. It bounds the run, and a share of it bounds each plan step. `CHAT_RUN_TOKEN_BUDGET` is the backstop for a model LiteLLM cannot price, defaulted high (2,000,000) so that cost normally binds first on a model it can. **Why cost is the right denomination.** Tokens vary about a hundredfold in price across the models LiteLLM reaches, and a run that crosses its soft limit switches to `CHAT_LLM_ECONOMY_MODEL` and so changes its own token price part-way through — a token ceiling therefore bounds spend only for one model at a time, and has to be retuned for every model change. Dollars are what a "runaway" actually is. **Measured, on the run that prompted this** ([AGT-021](#agt-021), run B): a four-step turn exhausted its 400,000-token budget at 88% while spending **5.6% of the cost limit** — $0.056 of $1.00. The token ceiling was not protecting anything; it was ending useful work at an arbitrary point that happened to be denominated in the wrong unit. **Enabling cost was not enough on its own.** Per-step fair-share (`_step_thresholds`, `open_scope`) was token-only, so raising the token ceiling to let cost bind would have removed step-level bounding entirely: one step could spend the whole run's money while every token check passed. A scope therefore now carries a cost ceiling and cost spend alongside the token pair, and `scope_exhausted` / `scope_soft_limit_reached` are true when **either** binds. The worker loop asks the controller rather than comparing tokens itself. **A dimension left at zero does not bound.** `_step_thresholds` used to return the complexity floor when `remaining_normal_tokens` was `None`, which meant a run deliberately budgeted on cost had every step capped at a guess made before any work happened (the thing [AGT-017](#agt-017) removed). It now returns *no* token bound in that case and lets cost do the work; the floor applies only when neither dimension is budgeted. **The same schedule divisor everywhere.** Cost and call grants for a distributed step were an equal split by batch size while tokens used the wave-aware divisor ([AGT-020](#agt-020)); `_dimension_share` now takes the divisor, so all three dimensions slice a bottleneck the same way. **What this does not change.** The distributed grant is still a hard cut (AGT-018) — it now carries a cost ceiling as well as a token one. Contention on the cost dimension waits exactly as it does on tokens (AGT-021). ### The backstop is derived, because a fixed one can never let cost bind `CHAT_RUN_TOKEN_BUDGET` defaults to `0` = derive: **no token ceiling at all** when a cost budget is set and litellm can price the model, and `CHAT_RUN_UNPRICED_TOKEN_BUDGET` when it cannot. A positive value pins it; zeroing both it and the cost budget is an explicit "no limit" and is respected. **Why a fixed backstop cannot work.** Measured on two real turns: a run cost **$0.126 per million tokens**, so the $2.00 cost budget is worth ~16M tokens and a 2,000,000-token backstop is *eight times tighter than the budget it backs up*. It ended the run at 10% of the cost limit. On a frontier model the same 2M figure would be ~$30 — five times too loose. There is no single number that is a backstop for both, which is the same argument as [AGT-019](#agt-019) for output ceilings and [AGT-024](#agt-024) for the call ceiling: a constant that has to track a model's properties belongs to the model, not to configuration. **Pricing is asked, not assumed.** `ModelCapability.priced` comes from litellm's `input_cost_per_token`/`output_cost_per_token`. If it says priced and the price turns out to be zero at runtime, the run keeps the derived call ceiling (AGT-024) and each step keeps its cost share, but nothing bounds total tokens — the case for setting `CHAT_RUN_TOKEN_BUDGET` explicitly on a gateway that proxies a priced model under a private name. ### Verified on a real turn The same four-step request, run with a deliberately tight cost budget ($0.03) and the 2,000,000-token backstop: ``` status : budget_exhausted reason : Step worker:s4 reached its share of the run cost budget ($0.0056). cost : $0.0290 / $0.03 tokens : 89,084 / 2,000,000 per-step ceilings: tokens=529,584 cost=$0.0056 (soft $0.0019) ``` Every worker scope was handed both ceilings, **cost** was what stopped a step, and the run ended on cost at 4.5% of its token backstop — the inverse of [AGT-021](#agt-021)'s run B, which died on tokens at 5.6% of its cost limit. The run still delivered a 3,397-character partial answer, so the finalization reserve behaved as [AGT-012](#agt-012) requires. ## AGT-021 — A reservation is an authorization, and contention is not exhaustion **Applies to:** `chat_budget.BudgetController.reserve` / `_authorize_locked` / `projected_output_tokens`, `chat_graph._run_llm_tool_turn` and `_structured_decision`, `mcp_builtins/sandbox.py` `ainvoke`; `CHAT_BUDGET_OUTPUT_ESTIMATE_TOKENS`, `CHAT_BUDGET_OUTPUT_ESTIMATE_SAFETY`, `CHAT_BUDGET_CONTENTION_WAIT_SECONDS`, `CHAT_RUN_COST_BUDGET_USD` Three changes to the same mechanism, which together were what made the budget bind on *useful* work — the failure [AGT-017](#agt-017) named but only half fixed. **1. Reserve what a call will emit, not what it is allowed to emit.** Every call booked `chat_context.max_output_tokens(model)` — 32,768 under the default `CHAT_LLM_MAX_OUTPUT_TOKENS_CAP` — as if it would be spent, and `_authorize_locked` summed that fiction across every call in flight, so parallel steps reserved most of a run's spendable budget before emitting a token. Measured with `scripts/budget_probe.py` on `deepseek-v4-pro`, against that same 32,768 ceiling: | phase | actual output | the old reservation was | |-------|---------------|-------------------------| | router | 43, 46 tokens | **762×** the truth | | planner | 7,490 then 2,779 tokens | 4.4×, then 11.8× | So the error is not a uniform margin — it is enormous for the many small calls (router, verifier, and every worker and sandbox call that returns a short result) and modest for the few large ones. Note also that two identical planner calls differed threefold: an estimate has to carry headroom, which is what `CHAT_BUDGET_OUTPUT_ESTIMATE_SAFETY` is. `projected_output_tokens` now keys on the phase *family* (`worker:s1` and `worker:s2` share an estimate; a sandbox sub-agent keeps its own) and returns an EWMA of observed output times a safety multiple, bounded below by 256 and above by the model's real ceiling, seeded by `CHAT_BUDGET_OUTPUT_ESTIMATE_TOKENS` for the first call of a kind. A reservation only has to be an honest authorization; committed spend is exact, so the ledger self-corrects on every return and an outlier overshoots into the finalization reserve, which is what that reserve is for. The cold-start seed is deliberately allowed to *under*-reserve — the probe shows the planner's first call at 0.5× — for the same reason: one call's error is corrected the moment it commits. The two call paths were wrong in *opposite* directions, which is how it stayed hidden. The sandbox — measured at 69–84% of a delegating turn's usage — reserved `CHAT_LLM_MAX_TOKENS`, which defaults to `0` since AGT-019, so the biggest spender reserved no output at all while the smaller one reserved 32k it would never use. **2. A call refused for contention waits; only committed spend ends a run.** `_authorize_locked` judged every dimension on committed spend *plus* all in-flight estimates, and failing it called `begin_finalization` — which nothing ever reverses. A burst of concurrency, which is temporary, was therefore recorded as exhaustion, which is permanent, and `_dispatch_batch` then skipped every step the plan had left. **Going wider made a run likelier to abandon itself.** The check now separates the two: committed spend over the limit raises and finalizes as before; in-flight estimates over the limit return a reason, and `reserve` waits on a condition until a reservation commits or is released, re-authorizing against the actuals. The same applies to a step's scope ceiling, where a step's own parallel tool calls contend constantly. *The call count is deliberately exempt.* A reservation that commits raises `llm_calls` by exactly what it removes from the in-flight count, so waiting cannot change the projection. It stays a hard limit. **3. Cost is the runaway guard, and now has a default** (`$2.00` per run). AGT-017 already said a cost ceiling "remains the outer guard against genuine runaway, and is the one an operator should set" — and it shipped disabled, which left the token ceiling doing runaway prevention it is unsuited for: tokens vary ~100× in price across models, and a run that switches to `CHAT_LLM_ECONOMY_MODEL` changes its own token price mid-run. **What this does not change.** The distributed grant stays a hard cut — AGT-018 depends on slices being non-overlapping, and a worker in another process has no controller to wait on. Loop detection stays the thing that stops useless work (AGT-017); this only stops the budget refusing work it can afford. ### Measured on full orchestrated turns Two headless turns on `deepseek-v4-pro`, same multi-part request, four plan steps with sandbox delegation: | | run budget | outcome | committed | cost | contention | |---|---|---|---|---|---| | A | 2,000,000 | completed, mode `normal` | 272,341 tokens, 41 calls | $0.049 | 0 | | B | 400,000 (the default) | `budget_exhausted`, partial answer | 350,937 tokens, 36 calls | $0.056 | 0 | Per-phase reserved-vs-actual output in run B totalled **2.4x** (80,588 booked, 32,981 emitted), against a flat **32,768 per call** under the old sizing — about fifteen times less budget booked against output that never happens. The worst remaining ratio is the router's first call (152x), which is the cold-start seed on a phase that emits ~50 tokens; every phase with a second call converges. **Contention never fired in either run**, which is the intended outcome rather than an untested path: honest reservations are what make collisions rare, and the wait exists so that the collisions which do happen are survivable. Its behaviour is covered by unit tests instead. Two findings for later work, both visible above. Run B exhausted its **tokens** at 88% of budget while spending **5.6% of the cost limit** — the token ceiling binds first and arbitrarily, which is the case for cost being the real guard. And B was cut on committed spend, not on accounting: this workload genuinely does not fit 400,000 tokens, so what remains of "the budget stops long work" is now a question of quantity. **Not done, and deliberately:** sizing the run budget from the plan's own estimates, and lease-and-renew per step (renewal keyed on progress rather than quantity). Run B is the evidence that the first of these is now the binding constraint. ## AGT-020 — The plan is a validated DAG, and an invalid one is replanned once **Applies to:** `chat_orchestrator._Plan` / `_PlannedStep`, `_plan_problems`, `_init_plan`, `_truncate_plan`, `_replan_invalid_graph`, `_fail_unreachable_steps`, `_remaining_waves` / `_budget_divisor`; `CHAT_ORCHESTRATOR_MAX_STEPS`, `CHAT_ORCHESTRATOR_MAX_PARALLEL` `depends_on` is the edge set of a directed acyclic graph. The contract is stated in the planner's **schema** (the field description and the `_Plan` docstring, both of which travel to the model as JSON Schema) and in the prompt, and it is checked on the way in: unique non-empty ids, every edge naming a different step of the same plan, no self-edges, no cycles, and nothing left waiting on one. **Why validate at all: a cycle failed silently and answered anyway.** `_init_plan` dropped dangling references and self-edges, so those were survivable. A multi-node cycle (`s1 -> s2 -> s1`) was not: `_runnable_steps` waits for every dependency to reach `passed`, so no step is ever runnable, `route_from_dispatcher` finds nothing in `ran` and goes to the synthesizer, and the turn produces a confident answer from **zero** evidence with no error anywhere. Same failure class as the planner fallback in [AGT-019](#agt-019): the expensive machinery reports success while doing nothing. **Reject and replan, rather than repair first.** A repair is not neutral — dropping the edge that closes a cycle invents an execution order nobody chose, and dropping a dangling reference hands a step none of the data its goal assumes it has. So `_plan_problems` reports, and the planner is asked once more, shown its own graph and what is wrong with it. Once, not in a loop: a second planner call is a second paid call against the turn's budget, and a model that cannot honour the contract when shown its broken graph will not honour it on the fourth attempt. **Repair is the floor, not the single-step fallback.** If the replan is also invalid, `_init_plan` forces a DAG — dangling and self edges dropped, duplicate ids renamed rather than dropped (the step is real work; an edge naming the id binds to the first claimant either way), and cycles cut by freeing their *earliest* member in plan order. Falling back to one step instead would throw away every step the planner wrote in order to punish an edge. The single-step fallback stays where it was: for a planner that returned nothing usable at all. **Truncation is our edit, so it is repaired, not replanned.** Cutting the plan at `CHAT_ORCHESTRATOR_MAX_STEPS` orphans any edge pointing into the tail; `_truncate_plan` removes those edges itself, so the cut does not present as the planner having emitted a dangling reference and cost a replan. **An invalid plan never looks like a valid empty one.** Every diagnostic goes to `run_errors` (persisted in the assistant message's metadata) and into the plan detail's body on the stream. And when the dispatcher finds nothing runnable with nothing in flight, `_fail_unreachable_steps` fails each still-pending step with the dependency that stopped short, so it reaches `_terminal_errors` instead of disappearing from the plan the synthesizer answers from. **Budget slicing had to move from breadth to depth.** `_grant_for` and `_step_thresholds` divided what the run has left by the number of *outstanding steps*. That is the right denominator only for a chain or a single wide batch, where the step count and the schedule agree. A DAG breaks it: at a bottleneck one step runs alone while five wait behind it, and dividing by six starves the only step that is running — the steps behind it will divide their own remainder among themselves *concurrently* when their turn comes. `_budget_divisor` divides by `remaining waves × the width in flight` instead: each remaining dispatcher pass gets an equal share, and the steps within a pass split it. For a chain (`n` waves of one) and for a flat batch (one wave of `n`) this is exactly the old number; it differs only where the graph has depth. **`MAX_PARALLEL` matches `MAX_EXPANSION` (8).** It was 3, on the reasoning that a wider batch made every concurrent step poorer — true while the *token* budget was fair-shared by width. It is not true now: tokens keep only the safety bound and cost is what steps share ([AGT-025](#agt-025)), and a step's cost slice is far from binding. Measured against it: a stage of four expanded children ran as two sequential batches, so the stage cost twice its slowest step — about ten minutes — purely because the setting was narrower than the expansion it had to carry. **Measure shape with `scripts/plan_probe.py`**, which now prints the dispatch wave count beside the widest independent batch — the depth the budget divisor keys on. Measured on `deepseek/deepseek-v4-pro` with the DAG contract in place: a chained request ("find the highest-risk CVE, *then* trace it, *then* write it up") planned 5–6 steps one wave wide across three samples, and a request with independent parts planned 4–5 steps three wide, fanning into one answer step, in both samples. Neither shape produced an invalid graph, so the replan path costs nothing on the normal case. The second shape is exactly where the divisor changed: its lone first step is granted a third of the run rather than a fifth. ## AGT-019 — What a call may spend is derived from the model, never a constant **Applies to:** `reporting/services/chat_models.py`, `chat_graph.build_chat_model` / `get_chat_model`, `chat_context.max_output_tokens`, `chat_orchestrator._structured_invoke`; `CHAT_LLM_MAX_TOKENS`, `CHAT_LLM_MAX_OUTPUT_TOKENS_CAP`, `CHAT_LLM_*_REASONING_EFFORT`, `CHAT_ORCHESTRATOR_PLANNER_MAX_TOKENS` A `ModelSpec` — model id, output ceiling, reasoning effort — is resolved once per call and passed down. The ceiling is `min(cap, the model's own max_output_tokens)` read from litellm, not a configured constant. **Why: a constant here fails silently and catastrophically.** On a reasoning model the thinking and the answer come out of **one** allowance, so a ceiling that is too low does not produce a shorter answer — it produces *no* answer, and nothing distinguishes that from a model that could not satisfy the request. Measured on `deepseek-v4-pro` at the old `CHAT_LLM_MAX_TOKENS=4096`: **every** planner call returned `chars=0, finish_reason=length` twice, and `planner_node` fell back to a single step carrying the user's whole request. That fallback is invisible everywhere downstream — identical in the stream, the checkpoint and the harness (`steps_total: 1`), with `run_errors` the only record — and it **disables the orchestrator's parallelism entirely**, because parallelism (in-process and distributed, [AGT-018](#agt-018)) operates on independent plan *steps* and there was only ever one. So a feature can be correct, tested, and completely unreachable. Measured, 3 samples per arm: | `max_tokens` | real plans | widest batch | |---|---|---| | 4,096 | **0/3** | 1 | | 16,384 | 3/3 | 4 | | 32,768 | 3/3 | 4 | The planner emits **5,798–13,058 output tokens** per call on this model, so 4,096 could never have worked: the plan and the reasoning that produces it do not fit. **A constant is wrong in both directions at once.** The models we run report ceilings from 16,384 (`gpt-4o`) to 393,216 (`deepseek-v4-pro`). A constant large enough for the second is *refused outright* by the first — providers reject an over-ceiling request rather than reducing it. Only derivation is right for both, and it needs no per-model configuration. **`0` means derive.** `CHAT_LLM_MAX_TOKENS` and `CHAT_ORCHESTRATOR_PLANNER_MAX_TOKENS` both default to `0` and are still honoured when set — and still clamped to the provider's ceiling. **Don't:** reintroduce a default output size at a call site. `_structured_invoke` defaulted to `1,024`, which is why the router and verifier sat one hard question away from the same silent failure the planner hit. ### `reasoning_effort` is the intent; the provider's own parameter is what ships `max_tokens` is genuinely portable — litellm renames it to `max_completion_tokens` for OpenAI on its own. **Effort is not.** Its mapping is lossy on half the providers we run: every level collapses to `thinking: {"type": "enabled"}` on DeepSeek and `{"type": "adaptive"}` on Anthropic, so a "high" and a "minimal" arrive identical. That is why sweeping effort measured nothing on DeepSeek — there was no dial attached. Graded control does exist on all four, so `chat_models.reasoning_kwargs` renders the level into whatever that provider grades on: | Provider | Rendered from `low` | |---|---| | OpenAI | `reasoning_effort="low"` (native) | | Gemini | `reasoning_effort="low"` → `thinkingBudget` (native) | | Anthropic | `thinking={"type":"enabled","budget_tokens":N}` | | DeepSeek | `extra_body={"reasoning_effort":"low"}` | `budget_tokens` is a **share of the call's own ceiling**, not a constant, so one profile means the same thing on a 16k model and a 393k one — floored at Anthropic's 1024 minimum and capped at half the ceiling, because thinking and the answer come out of the same allowance. This is provider-specific code, which the previous revision of this entry argued against. The argument was wrong: keeping only the portable parameter did not avoid provider knowledge, it silently discarded control. The knowledge lives in the **one resolver that already knows the model**, so no call site learns a provider name. ### Two generation parameters are refused, and neither says so **OpenAI reasoning models reject `temperature` outright.** With `litellm.drop_params` False that raises, so `CHAT_LLM_TEMPERATURE=0.2` fails *every call* on `gpt-5` and `o3`. There is no capability flag for it — `get_supported_openai_params` reports `temperature` as supported for `gpt-5`, which then refuses it — so `chat_models` asks litellm's parameter transform directly and caches the answer. **Anthropic fixes temperature at 1 once extended thinking is on**, and litellm does not strip a different value. `temperature_for` therefore returns `1.0` for that combination and `None` where a temperature may not be sent at all. Both are the same shape as the ceiling bug: a flat generation constant applied to models that do not all accept it. ### Only the router has a measured default, and only because of its shape `CHAT_LLM_ROUTER_REASONING_EFFORT` defaults to `none`. Measured across 21 cases on `deepseek-v4-pro`: routing stayed **21/21 correct** while median output **halved**, 78 → 37 tokens, on every turn. **The planner looks like a far bigger win and is not takeable on that evidence.** With reasoning off it used **7.7x fewer tokens** (8,439 → 1,093) and was 12x faster, at the same plan *width*. But reading the plans showed they are not equivalent: with reasoning the planner produced a shared up-front fetch step that the four per-CVE steps depended on; without it, four independent steps each re-derived that data. Width is a structural metric and hid this entirely. The asymmetry is the point, and it generalizes. **A stage whose output is a single value can be measured by a cheap probe; a stage whose output is the structure of everything downstream cannot.** The router's reasoning can change nothing but one label. The planner's changes what every worker then does, so only an end-to-end run can price it — a saving at one call that adds work to dozens is not a saving. **Measured end to end, the planner saving reverses.** Three samples per arm on the same conversation: turning planner reasoning off cost **more** overall -- median 1,107k tokens against 846k, and $0.329 against $0.271 -- despite saving 7,300 tokens at the planner call. Exactly the mechanism the plans predicted: four steps re-deriving the data the shared prefetch would have fetched once, and the workers are where the tokens are. (Ranges overlap at n=3, so read this as directionally confirmed rather than settled; the mechanism is what makes it credible, not the sample size.) It *is* faster -- 684s against 1,019s -- because those four steps start immediately instead of serializing behind the prefetch. So it is a real cost-versus-latency trade, and a deployment that wants responsiveness over spend can take it deliberately. It is not a free win, which is how the probe made it look. **Don't:** convert the per-stage rationale table into defaults. It is where to look, not what to set. ### Effort keys on stage, not only role A worker's ReAct loop is deciding what to do next; its summary pass is writing down what the step already established, and **every "reasoning ate the allowance" failure in this codebase is in the latter**. Both used to resolve through `get_chat_model("worker", …)`, so the distinction could not be expressed. `worker_summary` and `worker_summary_retry` are now stages that run on the worker's model with their own effort, inheriting the worker's when unset. **It must go through `model_kwargs`.** `ChatLiteLLM` does not declare `reasoning_effort`, so passing it as a constructor argument is silently swallowed — no attribute, absent from `model_kwargs`, absent from `_default_params`. It shipped that way first, and every measurement taken against it was measuring nothing. `litellm.drop_params` is `False`, so an unsupported parameter **raises** rather than being dropped. That makes the `supports_reasoning` gate in `chat_models.resolve` load-bearing rather than tidy. Effort is **per role** because the stages want opposite things in principle: reasoning is what decomposition and judgment (planner, verifier) are for, while classification (router) and transcription (worker summaries, synthesis) only lose answer allowance to it. **Every per-role default is empty, because on this deployment the knob does nothing measurable.** LiteLLM collapses `minimal`/`low`/`medium`/`high` to a single value on DeepSeek (`thinking: {"type": "enabled"}`) and Anthropic (`{"type": "adaptive"}`) — only OpenAI and Gemini are genuinely graded. Measured on DeepSeek with the parameter actually reaching the wire: the router routes 21/21 correctly at every level with ~70 output tokens either way, and the planner produces a width-4 plan 3/3 at every level. Turning reasoning *off* (`none`) did not help either — it produced **more** output (11k vs 6.5k median), because the model writes its thinking into the visible answer instead. So the recommended-values table in the install docs is guidance for graded providers, not a measured win. Do not ship it as a default without measuring on the provider in question. ### Reasoning has to survive back into the next request A tool-calling assistant message must be replayed with its reasoning intact, and the shape differs: DeepSeek needs `reasoning_content`, Anthropic extended thinking needs the **signed `thinking_blocks`**, and a tool-use turn replayed without them is rejected. `_strip_reasoning_context` flattens list content to plain text, which would destroy Anthropic's blocks, so both shapes are preserved in `additional_kwargs` — where litellm reads them (`prompt_templates/factory.py`) and where the flattening cannot reach. **Anthropic + reasoning + tool loops is unverified.** This deployment runs DeepSeek, so that path is written to what litellm reads rather than to an observed failure. Verify it against a real Anthropic key before recommending reasoning on Anthropic. ### The spec is the cache key, and it travels `build_chat_model` is memoized on the **spec**, not on `(role, economy)`. That matters ahead of user-selected models: a role-keyed cache would hand one user's chosen model to another in the same process. For the same reason a distributed plan step carries the resolved spec ([AGT-018](#agt-018)) instead of the `economy: bool` it started with. A step must run on the model its turn was *admitted* with; re-resolving worker-side reads that worker's settings and can produce a different model — the failure `permission_cap` travelling already prevents ([AGT-006](#agt-006)). **Measuring this needs `scripts/plan_probe.py`**, which runs the planner alone — one LLM call, nothing executed — and reports the widest independent batch plus whether the plan was the fallback. A full harness turn costs ~553s and ~$0.20 to learn the same integer. ## AGT-018 — A plan's independent steps are Temporal activities, not coroutines **Applies to:** `reporting/temporal_workflows/chat_step_fanout.py`, `reporting/services/chat_step_worker.py`, `chat_orchestrator._dispatch_batch_distributed` / `_distribution_eligible` / `_grant_for`, `chat_budget.grant_ledger` / `BudgetController.absorb`, `report_store.append_chat_turn_events` (now allocating), `chat_turn_payloads`; `CHAT_ORCHESTRATOR_DISTRIBUTED_*`, `TEMPORAL_MAX_CONCURRENT_ACTIVITIES` The turn is still one activity. Its independent plan steps are not: each batch is handed to a `seizu_chat_step_fanout` workflow that schedules one `run_chat_worker_step` activity per step. **Why not leave them as `asyncio.gather`.** They were already parallel, so this buys nothing in wall-clock for a batch that fits on one worker. What it buys is everything that follows from a step being a *scheduled unit*: steps are placed across the fleet rather than sharing one process's CPU, memory and event loop; a step gets its own start-to-close timeout instead of being bounded only by the turn's; a step that fails or whose worker dies takes only itself down, where an unhandled exception inside the gather took the batch; and each step is separately visible and separately cancellable. The shape that motivated it is an investigation whose steps are genuinely independent and individually large — one per CVE, after a first step has fetched the finding set from the graph — where one worker holding all of them is the bottleneck and one crash is the whole answer. **Only the middle is distributed.** Routing, planning, verification and synthesis stay in the turn's own activity: they are sequential, they share a model context, and distributing them would add a serialization boundary per stage for no concurrency. Synthesis in particular stays single-producer, so the user-visible answer still has exactly one writer. **Not for headless runs.** A distributed step reports progress into the turn's event log and is scheduled by a workflow derived from the turn id; a headless run has neither, and is already an activity of its own. `build_turn_config` therefore carries `turn_id`, and an empty one is the switch. ### The store allocates `seq`, because a turn now has several producers `append_chat_turn_events` no longer takes a sequence number. It takes the batch, locks the turn row, and returns the number it assigned. AGT-008 requires one append-only log whose live delivery and replay are identical, and `read_chat_turn_events` **truncates at the first gap** — so a counter held by any one producer is not an option once there are several: two writers picking their own next number either collide or leave a hole, and a hole is a reader that stops mid-answer. Allocating under the row lock makes the numbering total across every writer and makes "row N exists" mean rows 1..N-1 were already committed. The coordinating turn's closing flush is still the highest number, because it happens after it has awaited every step — which is what keeps `last_seq` an honest end-of-log marker. **Don't:** restore caller-chosen sequence numbers for idempotency. Nothing here retries: the turn is `maximum_attempts=1` (AGT-008), the fan-out and each step are `maximum_attempts=1`, and a batch is named by a workflow id derived from the turn, the retry cycle and the step ids, so asking twice resolves to the batch already running rather than starting a second paid copy. ### Budgets are granted before the fan-out, not shared during it The run's `BudgetController` is an in-process object; a step on another machine cannot reserve against it. Each distributed step is therefore allocated a **grant** — a slice of `remaining_normal_tokens`, non-overlapping across the batch and summing to no more than what the run has left — runs against a `grant_ledger` of its own, and reports actuals that the coordinator folds back in with `BudgetController.absorb`. Concurrent workers cannot collectively overspend without any distributed transaction, because the arithmetic that prevents it happened before any of them started. The price is real and one-sided: a distributed step's slice is a **hard cut**, where an in-process step may overrun into a sibling's idle budget (AGT-017). The slice keeps the two-threshold shape internally — the run's own reserve fraction is held back so a step that spends its slice can still say what it found rather than being cut mid-work with nothing to report (AGT-012) — but a step that would have borrowed from an idle sibling now stops. Steps that need to borrow should be planned as fewer, larger steps, or the batch should not be distributed. **Don't:** grant a step the whole remaining budget "because it is the only one running". It is not: its siblings are running too, and the only reason nobody overspends is that the shares were disjoint when they were handed out. ### Results come back by reference when they are large A step's result carries every call it made and what each returned, bounded per call by `CHAT_TOOL_RESULT_MAX_BYTES` — megabytes for a step that made dozens. Returning that through Temporal copies it into workflow history on the way out of the activity and again on the way in. Anything over `CHAT_ORCHESTRATOR_DISTRIBUTED_INLINE_MAX_BYTES` is written to `chat_turn_payloads` and the reference travels instead; the payload is keyed within the turn, so the existing expiry sweep collects it. Truncating instead is not available: the trace is the evidence the synthesizer answers from and the retry resumes from (AGT-013, AGT-014). ### What a worker rebuilds, and what it must never be handed Everything a step read from ambient context in-process is an explicit field on `ChatWorkerStepInvocation`: the step, the trimmed plan, its dependencies' outputs, the conversation excerpt, the disclosed tool set, session memory, the sandbox id, the grant. The payload is versioned and a worker refuses one newer than it understands, because misreading a field is worse than not running. **Identity is rebuilt worker-side and intersected, never carried.** The payload names a `user_id` and the permission cap the turn was admitted under; the worker resolves the stored user and intersects (AGT-006, AGT-008). A resolved permission set travelling in a payload would be one nobody re-checked. The worker also re-verifies that the turn is still running and still owns its thread before doing any work, so a step scheduled just before a cancellation cannot keep spending or keep writing into a closed log. **Confirmation gating and chat-safe filtering are unchanged**, because the step reaches tools through the same `mcp_runtime` path — a mutating tool still fails closed (AGT-001) and progressive disclosure still is not an authorization boundary (AGT-002). ### Concurrency is bounded twice `CHAT_ORCHESTRATOR_MAX_PARALLEL` bounds one turn's batch, as before. Once steps are distributed that is no longer enough: N conversations fanning out at once can saturate the provider, Neo4j, the MCP proxies or the sandbox account even though each is individually well behaved. `TEMPORAL_MAX_CONCURRENT_ACTIVITIES` is the cluster-wide bound, and Temporal queues the overflow rather than dropping it. ### Falling back is safe only before anything is scheduled `_FanoutUnavailable` — no Temporal client, or the start call failed — runs the batch in-process, which bills it exactly once. Anything after the fan-out has started propagates instead: the steps are running somewhere, and a local rerun would pay for the same work twice and re-apply its tool side effects. A step the fan-out could not produce comes back as a recorded execution error for the verifier to judge, never as a silently missing step. ### The first question of a conversation admits its own turn `SeizuChatTransport.startTurn` admits a turn without going through the SDK's send path, and the page then attaches to it (`resumeStream`). Only the chat landing uses this: it creates the session and asks the question in one gesture, so `sendMessage` would be called on a chat keyed to that session in the same commit that created it — racing the SDK's own reattach probe, its message state and the transport's pending slot. Measured by its absence: no `chat_turns` row was ever written for those sessions, so nothing was sent at all. **A session created here is not hydrated at all.** History is fetched concurrently and `applyHistory` replaces the whole message list whenever what it fetched is at least as long as what is there, so fetching alongside the stream took the answer back off the screen. Gating the attach on the fetch instead only moved the problem: the attach then waited on a request that had no error path, and the conversation appeared only when history polling picked up the finished turn. There is nothing on the server a newborn session does not already have — so it does not ask. The skip is released in `onFinish`, after which the thread hydrates like any other. Admitting first makes the question the server's before the UI has to be right about anything. It is the same pair a send makes and keeps everything a send gets — the idempotency key, the 503 retry, expiry recovery, a stop asked for before the turn had an id. **The attach is the ordinary reattach, not a second route into it.** Calling `resumeStream` by hand from an effect did not show the turn at all, while a reload of the same conversation attached and streamed correctly — so the landing uses the reload's path: `resume` is left alone, and the turn is already in the transport's pending slot, so `reconnectToStream` finds its id without probing. The question is put in the transcript by seeding `useChat({messages})`, which is read when the `Chat` for the new thread is *constructed*; writing it from an effect is always either too early for that construction or late enough to overwrite what the attach has already pushed. The seed remains until the turn finishes rather than being cleared when navigation settles, because the SDK may read the options again while reconnecting. Admission can also precede the worker's first checkpoint by an arbitrarily long queue delay. During that interval the history endpoint projects the active turn's immutable command as a pending user message unless the checkpoint already contains it. A reload must show the question the server accepted even when the producer has not emitted its first event yet. Related, and a hazard in its own right: `reconnectToStream` deletes this thread's pending slot on a 204, and a turn admitted while that probe was in flight is newer than the answer. It now deletes only the slot it asked about. **The conversation opens on the session's response, not on the admission's.** Both are small writes — locally 10-70ms to create the session and ~30ms to admit the turn — but waiting for the pair meant the question left the composer and nothing took its place: no route change, no sidebar entry, no transcript. The session is enough to be right about everything the page shows, so navigation, the seed and the sidebar entry happen on it, and the admission is asked for *before* anything re-keys but awaited after. Only the failure report waits: a turn that was never admitted leaves the question on screen in a conversation that exists, so asking again is retyping nothing. **And the first turn a process serves is not a small write at all.** Measured against a freshly restarted web service, admission took 3.5s on the first request of each worker and 30ms on every one after it. Almost all of it is the deferred `import litellm` behind the first model-capability lookup (~3.2s in this image), with the Temporal client connect another ~100ms; both are one-time per process, and both were being paid by whoever opened the first conversation after a deploy or a worker recycle. `chat_turns.warm_chat_dispatch`, called from the app lifespan when chat is enabled, moves them to startup: the first admission after a restart now answers in 60ms. What it warms is deliberately narrow. The import is the cost, not the lookup, so it imports litellm (`chat_models.warm_model_metadata`) rather than resolving a model — resolving reads the profile store, and a startup that queries the database to warm an import is doing something it was not asked to. It is a warm-up, not a precondition: each half is logged and left to the lazy path it stood in for, because Temporal being unreachable at boot is not a reason to refuse every other request. That reordering is why the transport records an admission while it is in flight (`PendingSend.admission`). The reattach now lands inside that window, and the 204 hazard above is exactly what it would hit — the probe would answer about a turn being admitted right then and discard the record of it. Waiting on the admission it is already holding is the only answer that is not a guess; when that admission fails there is no stream to hand back, and whoever asked for it reports the failure rather than the reattach reporting it twice. The session is also *named* by the question at creation, rather than by the auto-title PATCH that follows the first turn. Same text, same truncation — but the sidebar entry arrives readable instead of blank, and a new conversation costs one fewer write. ## AGT-009 — Answer-only plan steps require complete evidence **Applies to:** `chat_orchestrator._PLANNER_PROMPT` The planner may reuse facts established earlier, but an answer-only step is valid only when those facts satisfy the step's success criteria. A prior answer mentioning the subject is not evidence for a missing property. If a request asks to determine, verify, investigate, cross-check, or trace something and the conversation identifies an evidence gap, the plan must gather that evidence with an available tool/skill or explicitly say the determination cannot be made. Attack-path and internet-exposure work illustrates the distinction: selecting CVEs from a prior ranked list may be answer-only, while claiming reachability when the prior result contains no deployment or network data may not. Use a direct graph tool for a bounded lookup and `sandbox__delegate` for iterative exploration. Do not manufacture tool activity merely for display; tool and subagent details represent actions that actually ran. **Why:** an attack-path follow-up asked which previously ranked CVEs were accessible from the internet. The prior result contained vulnerability and repository facts, but no deployment endpoints or network-exposure metadata. The planner nevertheless made both worker steps answer-only, recorded the missing evidence as an assumption, and then presented an accessibility conclusion without making an action call. The execution trace was accurate—the absence of tool/subagent rows reflected that no evidence gathering occurred—but the answer overstated what the available evidence could establish. ## AGT-010 — External MCP confirmation uses annotations with a local override **Applies to:** `reporting/services/external_mcp.py`, `mcp_runtime.list_tools_for_user(chat_safe_only=True)` External MCP tools are agent-only capabilities, namespaced as `ext____`. Confirmation policy is evaluated per tool. An exact match in `MCP_EXTERNAL_CONFIRMATION_REQUIRED_TOOLS` always requires confirmation. Otherwise an explicit `readOnlyHint:true` does not; a complete mutating profile of `destructiveHint:false`, `idempotentHint:true`, and `openWorldHint:false` also does not. An explicit mutation or risk hint requires confirmation. Missing or incomplete guidance falls back to the proxy's `require_confirmation` value, which defaults true. The same confirmation bypass permission and audit path used by built-ins applies to external calls. The autonomous sandbox subagent receives only the individual external tools that this policy classifies as confirmation-free. The client creates a fresh transport and header dictionary for every discovery or call. It does not pool an authenticated connection across users. Detached interactive turns and headless runs use a service credential plus target-user delegation because the browser bearer token is deliberately not persisted in a Temporal turn command (AGT-008). **Why:** the MCP protocol provides standard behavioral hints at the individual tool boundary, which is more precise than treating an entire proxy as mutating. They remain advisory, so Seizu accepts them only from an operator-configured proxy, keeps an exact local force-confirm list for known-sensitive tools, and uses a fail-closed fallback by default when annotations do not establish a clear profile. Per-operation connections prevent one worker's pooled headers from turning a subsequent user's call into a confused-deputy request. The web tool catalog surfaces configured proxies and their dynamically discovered tools as read-only synthetic toolsets. This is observability and a skill-authoring aid, not federation: external tools remain absent from Seizu's own MCP `tools/list`, and the catalog REST routes do not execute them. When a proxy returns an OAuth challenge, its catalog contains the synthetic `seizu_authenticate` tool until credentials are available. Catalog parameter metadata preserves the external JSON Schema property names verbatim (including names such as `perPage`); the lower-snake-case rule remains limited to Seizu-authored Cypher tool definitions. ## AGT-011 — An unfinished plan is discarded unless the next turn resumes it **Applies to:** `chat_orchestrator.router_node`, `_forced_route`, `_abandoned_plan_reset`, `reporting/temporal_workflows/activities.finalize_chat_turn` `plan`/`step_results` round-trip through the checkpoint so an orchestrated run can survive a turn boundary, but only `synthesizer_node` clears them. A plan is therefore meant to outlive its turn in exactly one case: the run stopped at `confirmation_pause` and the next turn carries the approval (or a continue-the-answer request). Both arrive as a marked `HumanMessage`. `router_node` — the graph's single entry point — clears an unfinished plan when the incoming turn is **not** one of those, and `_forced_route` pins a turn to the orchestrated path only for a genuine resume. A new user message therefore always gets a new plan. **Why:** a user stopped an investigation of one repository mid-run and asked for a different one. Cancelling a turn cancels the graph task wherever it is, so the dispatcher's last checkpointed write — steps at `pending`/`ran` — stayed in the thread state. On the next message `_has_pending_plan` forced the orchestrated route and the planner kept the stale plan, so the agent resumed the *abandoned* repository and never read what had just been asked. The same hole is open to any turn that does not reach synthesis: a crashed worker, a timeout. Clearing at the entry point covers all of them with one rule, rather than asking each producer to unwind state it was cancelled out of. **A cancellation is recorded as a cancellation, whichever writer wins.** Stopping a turn reaches both finalizers at once — Temporal cancels the activity and the workflow immediately schedules `finalize_chat_turn` — and the fallback usually wins by a second or two because the activity is still publishing its closing frames. It wrote a blanket `failed`, so a user-initiated stop surfaced as an error and the activity's own `canceled` lost the first-writer-wins race. The fallback now reads `cancel_requested` from the turn, which is set before either writer runs, so the two agree instead of racing over the outcome. ## AGT-014 — A step that made calls never reports nothing, and neither does a run **The synthesizer gets the same treatment as the step summary.** A run whose two steps had both *passed* still opened with "could not produce a final summary" and handed the user raw step output: the synthesizer call ran, spent its allowance and returned no text. From the user's seat that is a failed answer whatever the internals say. An empty synthesis is now retried once, asking for the answer and nothing else, before the fallback renders step output. Its output allowance is no longer capped at a concision ceiling either. 2,048 tokens is enough for the answer but not for a reasoning model to think *and* answer, and the observed result was a blank one — concision achieved by saying nothing. Length is the prompt's job; the allowance only has to leave room to answer at all. ### The step-level half **Applies to:** the summary pass in `chat_orchestrator._run_worker_step` When the summary pass returns nothing, a narrower retry asks for three things only — what was established, what was unfinished, what is still unknown — since that is far smaller to produce than a full summary and a model that spent its allowance thinking has a better chance at it. If that is also empty, the step reports its *state* deterministically: goal, completion condition, how many calls across how many tools, an explicit "still unknown", and only then the evidence. A raw dump was the first version of this and is not a report — it leaves the verifier and synthesizer to work out what the step was for, and an absent finding reads like a negative one unless something says otherwise. When a step's summary pass returns no text, its result is rendered from the calls it made and what they returned, rather than left empty. **Why:** the summary pass is a step's last chance to say what it found, and it can come back empty — refused by the budget, or a reasoning model spending its whole output allowance without emitting text. Observed on a step that had made 90 successful calls: `output=0, partial_output=0`, which fails verification for "Step produced no output", is retried from scratch, and loses the work. The allowance is also no longer a constant: `chat_context.max_output_tokens` returns the smaller of `CHAT_LLM_MAX_TOKENS` and what the model reports it accepts, and every call site that used to pick a number now goes through it. A hardcoded 1,024 bore no relation to what that model could have given, and asking *above* a provider's ceiling is refused outright rather than quietly reduced — so the clamp matters in both directions. The synthesizer keeps its deliberate concision ceiling as `min(model_limit, 2048)`; structured calls (router, planner, verifier) are clamped once inside `_structured_invoke`, where the model is chosen. The fallback is still the load-bearing half: it does not depend on knowing why the model went quiet. ## AGT-017 — Stop useless work; do not ration all work **Applies to:** `_step_thresholds`, `_looks_stuck` / `_note_call_signature` in `_run_worker_step`, `_prepare_retries`, `_stuck_notice` in `sandbox.py`; `CHAT_ORCHESTRATOR_STEP_SHARE_HARD_MULTIPLE`, `CHAT_ORCHESTRATOR_STUCK_CALL_WINDOW`, `SANDBOX_STUCK_REPEAT_LIMIT` A token ceiling cannot tell a run that is looping from one that is working: both spend. So the ceiling is no longer where a long investigation ends, and the looping is detected as itself. **The per-step share is a signal, not the execution cut.** Its purpose is that no step starves its siblings — a scheduling concern — and at a hard multiple of 1.0 it was also what ended the step. Measured across four consecutive CVE-reachability runs, **every one stopped on the step share** while the run budget sat ~80% unspent and the cost budget at ~16%. The default multiple is now 3.0: crossing the share still degrades the step and tells it to converge; what changes is that a step with no sibling contending may use what the run can actually spend. This re-breaks a tie that a three-arm sweep had left open. The sweep found no *quality* difference between multiples, so the tie went to sibling protection; it did not measure the case that matters here, which is a plan with one genuinely large step. **Three loop detectors, at the level each loop happens.** - *Within a step:* a full window of tool calls (`CHAT_ORCHESTRATOR_STUCK_CALL_WINDOW`, default 8) with no call the step had not already made. The step stops, keeps what it gathered, still runs its summary pass, and is marked terminal — a step that has run out of new calls to make will run out again. A full window is required so ordinary repetition (polling, re-reading a file just written) does not trip it. - *Across attempts:* a rejection the step has already been given once and not addressed is terminal. Three of four attempts in one measured run were the same verdict restated, and they cost the rest of the run's budget. - *Inside a delegation:* consecutive already-answered calls (`SANDBOX_STUCK_REPEAT_LIMIT`, default 3) escalate from the per-call note to an instruction to stop and report. The per-call note says one call was pointless; it does not say the task is. **Why this direction, and not a tighter cap:** a cap hit while answering a hard question does not save the tokens it appears to. The work is re-done in the next turn or the next session, from a cold context, and the failed run is a total loss on top. Cheap detection of *useless* work is what makes an expensive *useful* run affordable; a cost ceiling (`CHAT_RUN_COST_BUDGET_USD`) remains the outer guard against genuine runaway, and is the one an operator should set. ## AGT-016 — The planner does not supply identifiers the request did not **Applies to:** `_PLANNER_PROMPT`, the `repo_cve_reachability` skill A plan step must not name a repository, organization, account or host the request did not give it. Identifiers in this graph are whatever was scanned, and a familiar-looking name is the trap: the resource is very unlikely to be the upstream project it shares a name with. A bare name stays bare in the goal and is resolved against the graph at execution time. **Why:** asked about "the confidant repository", the planner wrote `lyft/confidant` into both step goals from its own knowledge. The findings step correctly resolved `mappedsky/confidant` and was then *failed by the verifier* for not reporting on `lyft/confidant` — the invented identifier had become the thing the step was judged against. Worse, the reachability step believed it: **all 100 of its GitHub reads went to `lyft/confidant`**, an unrelated public repository, so it was judging one codebase's recorded vulnerabilities against another codebase's source. That failure mode produces confident, cited, wrong-target verdicts, which is worse than producing nothing. The skill carries the same guard where the calls are actually made: if the repo it was handed disagrees with the one the findings step resolved, it uses the resolved one and says so, and it refuses to read a repository the graph has no record of. ## AGT-015 — What a retry is told, and what it is then judged on **Applies to:** `_worker_user_message` (the resume block), the required-action guard in `_run_worker_step`, `_dependency_context`, the verifier prompt in `_verify_step` Four rules, each from the same observed run: a reachability step produced a correct, cited review on its first attempt and was retried three times until the budget was gone. **A "cannot be determined" that names its missing evidence is a finding.** The verifier failed a review of 19 CVEs because one was `Undetermined` — a verdict the skill defines, and requires evidence for. [AGT-009](#agt-009) already allows a plan to "explicitly say the determination cannot be made"; the verifier now applies that to part of a result as well as the whole, and still fails anything left silently unaddressed or asserted beyond its evidence. **A rejected attempt is told it was rejected.** The resume block said "ran out of budget before finishing … do not re-gather what is already here" for *every* carry. Told that, a worker whose result had been rejected reasonably skipped the work the rejection asked for — including its required skill. **A step's contract is satisfied once, not once per attempt.** Having skipped the skill, the retry was failed for not calling it: the guard runs per attempt while `required_action` is a property of the step. It is now remembered on the step, so a later attempt is not failed for a contract an earlier one met. A first attempt that skips its required action still fails. **A dependency gets a budgeted share, not a fixed 2,000 characters.** The 19-finding list reached the dependent step truncated; the worker said so, and the verifier held the incomplete coverage against it. Split across the step's dependencies (`CHAT_ORCHESTRATOR_DEPENDENCY_CONTEXT_MAX_CHARS`, default 16,000), and a slice now says it is one — silent truncation is how a step comes to report missing coverage without knowing what it is missing. ## AGT-013 — A retry carries what the attempt fetched, not only what it wrote **Applies to:** `chat_orchestrator._prepare_retries`, `_worker_user_message` A failed step's retry resumes from `partial_output` when the worker wrote one, and otherwise from a bounded digest of the calls it made and what they returned. **Why:** the two conditions were mutually exclusive in practice. A worker cut at its step ceiling never gets to write a partial summary — that is precisely what produces `Step produced no output.`, which is what fails verification and sends the step back for a retry. So the carry-forward path existed for a case that could not reach it, and the retry re-gathered from scratch. Observed on a reachability step: `output=0, partial_output=0, budget_capped=True`, and the second attempt re-fetched files the first had already read. **An interrupted attempt also leaves its full trace in the sandbox.** The digest above is bounded by what fits in a prompt, which is the wrong shape for what a step that made ninety calls has to hand on, so `_persist_step_record` writes the whole trace to a file and records it as a receipt — the machinery that already tells a delegation about result files then tells it about this ([SBX-008](sandbox.md)). Best-effort, and only into a sandbox that is already open: it is a convenience for the next attempt, never a reason to open one or to fail a step that has otherwise finished. `tool_details` is thin for a *delegating* step — it records the delegations, not the sub-agent's calls — so this helps a directly-working step most. The sandbox layer has its own carry: the session digest and receipts already tell a later delegation what is on disk ([SBX-008](sandbox.md)), which is why the observed retry repeated 13 of 99 calls rather than all of them. ## AGT-012 — Running out of budget must not delete what the run already found **Applies to:** `chat_orchestrator._dispatch_batch` (the degraded/finalizing sweeps), `_budget_stop_result`, `_step_evidence`, `_rendered_step_status` When the run budget enters finalization the dispatcher marks every unfinished step `skipped`, which is what stops the retry loop. It must **annotate** the step's existing result rather than replace it: the stub it used to write (`output: ""`, `tools_used: []`, no `tool_details`) deleted the findings the step had already gathered. A step with retained findings is never *labelled* "skipped" either. `skipped` is the routing status; as a label above real evidence it tells the reader to discount it, which is the same failure one layer up. **Why:** a CVE-exploitability run made 33 tool calls, read the repository's manifests, lockfile and source, spent 302,679 input tokens — and answered "the step was skipped and produced no output or supporting evidence". Nothing hallucinated: the worker is killed at its share of the run budget *before* it writes its summary, so the result carried evidence and an empty `output`; the verifier failed it for having no summary; the retry pass met it at `failed` and the sweep overwrote it. `_synthesis_context` forwards `tool_details` precisely so a missing summary cannot take a step's findings down with it ([AGT-009](#agt-009) is the same concern from the planner's side), and the stub deleted that safeguard's input. The checkpoint shows it exactly: `step_results` went 110,930 bytes → 390 bytes → the answer. Replaying the real state through the fixed path hands the synthesizer 12.5k characters of evidence instead of "(no output)". **Identical tool results are charged once.** A worker that re-runs a tool with the same arguments records the result again, and an equal split of the evidence budget then pays repeatedly for one fact while genuinely new evidence falls off the end — 33 recorded calls, 25 distinct, on the run above. **Don't:** treat "the budget ended the run" as "the run found nothing". The terminal status is `budget_exhausted` and the answer must be the partial one the evidence supports, with the limit stated. ## AGT-036 — Agent Skill `allowed-tools` is a dependency declaration in Seizu **Applies to:** `plugin_packages.py`, `mcp_runtime._resolve_plugin_allowed_tools` For tool names Seizu recognizes, `allowed-tools` means the skill requires and discloses that tool. The skill is hidden for a user whose ordinary tool listing does not contain it. Unknown portable tokens are retained and ignored. **Why:** the existing `tools_required` field already meant that the workflow could not run without those tools. Keeping a second Seizu-only field would make portable packages declare the same dependency twice and allow the declarations to drift. Treating the standard field as permission instead would conflict with [AGT-002](#agt-002): disclosure is not authorization, and RBAC plus confirmation remain the only execution boundary. Logical `mcp:/` names resolve only to operator-configured proxies; the package endpoint is never contacted. URL matching has three modes. `none`, the default, ignores the package URL and binds an equally named proxy. `lax` prefers a configured or advertised URL alias and then falls back to the proxy name. `strict` requires exactly one URL alias match. Every mode also requires the user's discovered inventory to contain the exact remote tool. **Why:** in a server-side agent the operator-controlled proxy configuration is the execution boundary, while the package URL is deployment-specific metadata and neither grants authorization nor selects a network destination. URL matching still offers optional provenance and configuration checking. Name fallback remains narrower than matching a tool leaf globally: both the plugin server name and remote tool name must match, while RBAC determines whether the discovered tool is present. ## AGT-037 — A resource listing is a catalogue of skills, not of files **Applies to:** `mcp_runtime.list_plugin_resources_for_user`, `list_plugin_resource_templates_for_user`, `mcp_server._handle_list_resources` `resources/list` returns one resource per *enabled plugin skill* — its SKILL.md URI, title, description and declared `allowed-tools` — not one per packaged file. `resources/templates/list` advertises `seizu://plugins/{plugin_id}/versions/{revision}/files/{path}`, and `resources/read` still accepts any file in a published revision. **Why:** a listing exists so a caller can decide what is relevant, and that decision is made from a skill's identity and description. A skill's `references/`, `scripts/` and `assets/` are named by its own instructions and fetched by URI, so enumerating them made every file of every installed plugin the price of asking what was available — a listing bounded by `plugins x files` rather than by skills. It was also a query per plugin, and it was the one surface here that would have needed cursor pagination to stay bounded. Narrowing it removed all three at once. **Don't:** resolve `allowed-tools` against the caller's inventory to build this listing. Resolution fans out to every configured external MCP proxy, and a catalogue read must not pay for that; the names are carried as *declared*, and availability is still decided at render time (AGT-002, AGT-036). **Don't:** re-add file enumeration "for discoverability" — the template is the discoverability mechanism, and the render already hands over the skill's prefix. ## AGT-038 — External discovery is memoized per turn, cached across turns only on request **Applies to:** `external_mcp.discovery_scope` / `begin_discovery_scope` / `discover_proxy_tools` / `invalidate_discovery_cache`, `MCP_EXTERNAL_DISCOVERY_TTL_SECONDS` Discovering one proxy's tools costs a transport, an MCP `initialize` and a paginated `tools/list`. A single turn asks for that answer from the system prompt's capability listing, the planner's, and every skill render that resolves dependencies — five to ten identical fan-outs. Two layers now sit in front of it: - A **scope memo**, opened by `chat_agent_node`, `dispatcher_node`, the distributed step worker and the MCP ASGI middleware. Valid by construction: one identity, one turn, seconds wide, removing only duplicate work. Always on. - A **TTL cache** (`MCP_EXTERNAL_DISCOVERY_TTL_SECONDS`, default `0` = off) that makes a *cold* turn cheap. Bounded to 512 entries, LRU-evicted, and dropped for a user whenever an upstream refuses their identity. **Why the split:** the memo cannot be wrong; the TTL cache can. A tool the user just lost stays listed and one they just gained stays hidden until it expires. Neither is an authorization decision — the call is still checked by RBAC and by the upstream, and disclosure was never a boundary (AGT-002) — but both are visible, which is why the cross-turn layer is opt-in rather than a default. **Both key on the user.** A proxy's listing is what *that* delegated identity is authorized to see, so a cache keyed by proxy alone would hand one user another's view. This is AGT-010's rule against pooling the transport, applied to what the transport returned. **Don't** widen the key to make the hit rate look better. This is also what made resolving `allowed-tools` affordable in `resources/list` (AGT-037): the catalogue now resolves against the same inventory the rest of the request already discovered, and omits a skill whose dependencies are unreachable, exactly as the prompt listing does. ## AGT-039 — A rendered skill is two messages: static instructions, then its inputs **Applies to:** `render_skill_parts`, `render_skill_inputs`, `mcp_runtime._get_prompt_core`, `plugin_packages.parse_package` `prompts/get` returns the skill body unchanged and this invocation's argument values as a second message. `GetPromptResult.messages` is a list; the body is the same bytes on every run, and a skill refers to a value by name rather than having it substituted in. **Why:** the argument mechanism was always standard — MCP prompts take `arguments` — but the *template* lived in the portable `SKILL.md` body, and a consumer without Seizu's parameter extension reads `{% $repo %}` literally. The package was portable while its instructions were not. Keeping values in their own message makes the file readable anywhere and removes a second discrepancy: `materialize_plugin_skill` writes the raw package bytes into the sandbox, so a sub-agent re-reading `SKILL.md` used to see placeholders where the outer agent had values. Substitution still runs, so packages written the old way render exactly as before; publishing one records a non-blocking `templated_skill_body` warning. The legacy skillset projection is exempt — its bodies are generated from records whose author cannot restructure them — and the legacy REST render endpoint still returns the body alone, because a legacy skill inlines its values and an inputs block there would both duplicate them and change a response callers parse. **Not a caching change.** A rendered skill arrives as a tool result at the tail of the conversation, and only `Prompt.description` reaches the system prompt, so neither shape moves the cached prefix. Don't cite caching as the reason for this; the reason is that the body travels. ## AGT-040 — A package and a skill each have one identity **Applies to:** `plugin_packages.derive_seizu_id`, `SeizuPluginExtension`, `PluginCreateRequest`, `PluginSkillEditor.tsx` A plugin's Seizu id is derived from the package `name`, and a skill's from its portable name: hyphens and dots become underscores, and the result must be a valid MCP name component. `skillsetId` and `skillId` do not exist: the extension forbids unknown keys, so a package stating either is refused rather than reconciled. The whole `com.mappedsky.seizu` extension is optional, so a stock Agent Plugins 1.0 package installs unmodified. **Why:** the pair was immutable in both directions — a package carried a portable name *and* a Seizu id forever, with nothing keeping them related and no way to change either. `skill_id` already derived by default, so the two halves of the same idea disagreed. Naming a thing once is the whole feature; an author who wants a different id renames the package or the skill directory. A name that derives nothing valid — leading digit, over 31 characters — is refused at publish and at create, naming the constraint rather than silently inventing an id. **Known trade:** `STO-009` used an explicit same-ID package to bind a production cutover to existing skillsets, and a derived id could in principle adopt a legacy skillset that happens to match. Accepted deliberately: the legacy surface exists for one release and the collision needs a legacy skillset whose id is exactly the derived one. If that release is extended, put the check back before the derivation, not the field. **Consequence:** a revision published before this, whose stored manifest states either field, cannot be restored — `restore` republishes that revision's files and they no longer validate. Both fields were introduced unreleased, so this was settled by discarding the affected history rather than tolerating the keys. ## AGT-041 — Whether a skill is on is an operator's choice, not package content **Applies to:** `SeizuSkillExtension.enabled`, `publish_plugin`, `set_plugin_skill_enabled`, `PUT /api/v1/plugins/{id}/skills/{skill_id}`, `PluginDef.skills` A package does not say whether its skills are on. Enablement is store state, chosen when a plugin is installed or updated: every skill a revision introduces starts on, `publish_plugin` carries existing values forward, and an operator changes one through the API, the CLI, the seed configuration or the plugin detail dialog. `enabled` is not a field of the extension at all — a package carrying one is refused, because the extension forbids unknown keys. **Why:** it was neither in the Agent Plugins spec nor meaningful to any other consumer — only Seizu's extension carried it — and it sat on the wrong side of the authoring/runtime line. Disabling one skill meant editing a manifest and publishing a revision, while the *plugin* it belonged to was already toggled at runtime and already an install-time seed argument. The two halves of the same question worked differently. Removing it also removes a rule nobody would have got right: if a package ships a skill off, an operator turns it on, and a later revision ships it off again, what wins? With enablement outside the package there is nothing to reconcile — a republish simply never touches it. **Don't:** reintroduce it as a "default for first install". That is the rule above wearing a hat, and it makes a package's meaning depend on whether the store had seen it before. ## AGT-042 — Seizu's own tools are named like any other MCP server's **Applies to:** `plugin_packages.mcp_tool_ref`, `SEIZU_MCP_SERVER_NAME`, `_resolve_plugin_allowed_tools` `allowed-tools` entries are `mcp____`, and Seizu is the server named `seizu`: `mcp__seizu__graph__query`, `mcp__github__get_file_contents`. Anything that is not an MCP reference — `Read`, `Bash(git:*)` — is the consumer's own built-in, preserved and never resolved by us. `seizu` is reserved: an external proxy or an `mcp.json` entry of that name does not answer for it. **Why:** the package previously used three vocabularies at once — bare `graph__query` for Seizu, `mcp:/` for external MCPs, and `ext____` for their internal names — and only the first was even the tool's real name. `mcp:/` was ours alone; nothing else reads it. `mcp____` is what the ecosystem uses (it is Claude Code's permission-rule syntax, which is what `allowed-tools` values are), and Seizu's tool names are already `group__action`, so `mcp__seizu__graph__query` parses as server `seizu`, tool `graph__query` and resolves in a client that has Seizu configured. Neither the Agent Skills nor the Agent Plugins specification defines a tool-naming convention, so the one in use is the one worth matching. **The field means something different at each end, and we keep our meaning.** The Agent Skills spec calls `allowed-tools` "tools that are pre-approved to run" and marks it experimental; Claude Code grants those tools for the turn. Seizu treats it as a dependency: a skill is *absent* from a user's list when a listed tool is unavailable to them (AGT-002 still applies — it grants nothing). Gating is kept deliberately, because not offering a skill whose tools the caller cannot reach beats offering one that fails halfway. The consequence to know: a package authored elsewhere that lists tools defensively becomes unavailable here if any one of them is missing. **Don't:** resolve bare names as Seizu tools again "for convenience". That is a second spelling for a tool that already has one, and it collides with the built-in names the spec's own example uses. ## AGT-043 — Every stage resolves its model and effort the same way **Applies to:** `_get_sandbox_model`, `chat_models.model_id_for_role`, `_STAGE_PARENT`, `CHAT_LLM_ROUTER_MODEL`, `CHAT_LLM_SANDBOX_REASONING_EFFORT` The sandbox sub-agent built `ChatLiteLLM` itself whenever `SANDBOX_LLM_MODEL` was set, so it never reached `reasoning_kwargs`. Effort travels through `model_kwargs` (AGT-019), which means **no setting could reach the wire**: the highest-volume stage in the system ran on provider defaults by construction, and there was no configuration that could have said otherwise. It now resolves through `chat_models.resolve("sandbox_subagent")` like every other stage, which carries the derived output ceiling and temperature rules with it. **Measured.** Grading it `low` took the sub-agent from 955 reasoning tokens and 11.2s per call to 422 and 7.4s -- across a delegating turn, roughly half the tokens and a third off the wall clock, and the first sample in seven to finish the reachability step without exhausting its budget. Read the reasoning tokens, not the setting: DeepSeek collapses several levels into one value (AGT-033), so the token count is the only evidence the grade arrived. A stage's own model now wins before its parent's, or `SANDBOX_LLM_MODEL` would be unreachable — the sub-agent's parent is the worker, and the parent remap ran first. The router also gets its own model setting: it shared the planner's, so "put the planner on the strong model" silently moved a binary classifier that emits 48 tokens there too. **Don't:** trust `reasoning_effort` on a trace to tell you what a stage is graded at unless it is read off `model_kwargs`. The sub-agent's span reported `None` while the stage demonstrably ran at `low`, which is the one attribute someone would check. ## AGT-044 — Thinking is streamed as a detail, and filed by the phase that produced it **Applies to:** `_run_llm_tool_turn_inner`, `_reasoning_detail_data`, `_append_reasoning`, `detail_writer`, `buildDetailTree` The details pane shows a `thinking` entry per model call. It is emitted **while the reasoning arrives** (paced by `_REASONING_STREAM_MIN_CHARS` / `_REASONING_STREAM_INTERVAL_SECONDS`, never re-sent unchanged) rather than once at the end, and it carries the step it belongs to, so it renders inside that step's section instead of at the root of the turn. **Three things had to change together, and each is load-bearing.** *The channel.* `writer` was both the text channel and the detail channel, so a stage that must not ship prose — a worker step, a summary pass, a post-action turn in the single-agent loop, the synthesizer — switched off its details along with its text. Those are exactly the stages that spend the longest thinking and show the least while doing it. `detail_writer` is the second channel; a call with a `writer` still uses it for both. *The section.* The `phase` a call already carries is what files the entry: `worker:s2`, `verifier:s2`, `worker_summary:s2` — the grammar `chat_budget._observation_key` documents, which drops the step id again for estimation. `_structured_invoke` grew `step_id`/`writer` for the same reason. Nothing new is threaded through the call graph to say where a call belongs. *The order.* The pane grouped a detail under "the step we last saw", which is only correct when steps are sequential. They are not (AGT-018): two steps' tool calls and thinking interleave in one log, and the verifier thinks about a step after every step has opened. `buildDetailTree` now keys the step nodes by `step_id`, so ownership is stated rather than inferred from arrival order — the same fix a reload always needed. **Streamed thinking keeps its tail, not its head** (`_append_reasoning`, the one place in `chat_graph` that truncates that way). At the display bound a head-truncated body stops growing, which is indistinguishable from a stage that stopped thinking — and it happens precisely on the long calls this exists for. **The block is ordinary content at the top of its turn.** A thinking entry starts expanded — it reads as prose and is why the block is open — while a tool call starts closed, its value being the one line that names it. The block itself is in normal flow and scrolls with the conversation. It was briefly `position: sticky`, which pinned it over the answer it sat on top of, so it had to collapse and re-expand itself from a measurement of when it had pinned — inferring the user's intent from scroll position, and unpredictable to use. Nothing moves it now but a click, and its height is bounded (`min(300px, 40vh)`) rather than reserved, so a two-entry trace takes two rows. The pane follows streamed output while the reader is near the bottom, pauses when they scroll up, and resumes when they return to the bottom. That position is recorded on scroll, before the next content update: measuring it after an update mistakes a large chunk of new output for the reader having scrolled away and stops following. Reopening the pane also applies the recorded follow state. **What is *not* persisted:** a plan step's thinking is live-only. Worker `LLMTurnResult.details` are dropped, and `_orchestration_details` rebuilds a reloaded turn from the plan and the step results, which would have to carry up to 6,000 characters per step through Temporal history to keep it. The single-agent path and the synthesizer persist theirs, as they always have. **A native structured call has no thinking to show.** `with_structured_output` is one `ainvoke` and yields no reasoning chunks, so the planner's entry appears only on the JSON-prompt fallback. Do not stream the structured runnable to get one: it yields parsed objects, not chunks. ## AGT-045 — Model families lock on first use; reasoning remains selectable **Applies to:** `model_profiles.py`, `chat_models.ModelSpec`, `ChatTurnCommand.resolved_model_profile`, `ChatInterface.tsx` Admins manage versioned model profiles in the database. Every holder of `chat:use` selects a profile plus one of its admin-configured user reasoning levels; profiles default to `low`, `medium`, and `high`. The UI groups those levels beneath each profile, then shows only the locked profile after the first admitted turn atomically locks its family on the session. Its reasoning level remains selectable between turns. The resolved pair is copied as a complete snapshot into each admitted turn. Admission expands inheritance and deployment fallbacks into primary and economy specs for every runtime stage. Call sites only select the stage and whether the run is degraded; a missing stage or an incomplete spec is invalid and never falls back to a worker's environment. Scheduled chats and `agent_chat` workflow activities use the same catalog and snapshot their selection before Temporal dispatch. A deleted or disabled explicit choice never silently falls through to another profile: schedules and workflows require a replacement, while a locked interactive conversation must be restarted under a new profile. A profile has one primary base model. Direct assistant calls use that base, and every runtime stage inherits it unless explicitly overridden. Router, planner, worker, worker-summary, sandbox-subagent, verifier, and synthesizer may each override the model and may either inherit the user's selected reasoning level or fix an admin-configured value. There is no separate assistant override. One economy model and reasoning value applies wherever budget degradation needs the fallback; it is not duplicated per stage. The run cost ceiling is the lower positive value of the profile cap and `CHAT_RUN_COST_BUDGET_USD`. Admins choose the user-visible subset from LiteLLM's fixed `default`, `none`, `minimal`, `low`, `medium`, `high`, and `xhigh` vocabulary. `default` is rendered by omitting an effort so it remains safe for provider adapters whose nominal `default` mapping is not accepted by every underlying model. **Why:** changing effort keeps the same model family, while changing profile families moves the request to another model and guarantees that the next turn cannot reuse that model's prior cached prefix; its carried context is uncached input. A new conversation makes that cost boundary explicit. Capturing the expanded specs at admission keeps a profile edit or effort change from changing a running turn or one of its distributed workers. A single user level cannot safely flatten the profile: stages such as worker-summary are transcription passes where extra thinking crowds out the answer, while planner and worker may benefit from it (AGT-019). Router and verifier are structural classifiers, so a request for a stronger answer should not silently move them to a more expensive model. No built-in profiles are seeded. Until an admin creates the first enabled profile, chat uses the environment configuration unchanged; the first enabled profile becomes the default. Once profiles exist, exactly one enabled profile is the default. `CHAT_LLM_MODEL` remains the concise environment base and fallback, but startup accepts an enabled default profile in its place. Both the web process and the Temporal worker validate this after initializing the report store, so a profile-only deployment does not need a placeholder environment model. **Why no built-ins:** unmeasured provider/model combinations would present an operational guess as a safe cost preset. The environment fallback preserves an existing deployment without making that claim. ## AGT-046 — Seeded model profiles match by name and select the default first **Applies to:** `ReportingConfig.model_profiles`, `seizu seed`, `seizu export` Model profiles are portable configuration. Their YAML keys are local handles; seed matches stored profiles by exact name, the same value protected by the API's uniqueness constraint. Export preserves an existing local key by name and otherwise derives one. Reconciliation processes the declared default before every non-default profile, and profiles precede the rest of the seed pipeline. **Why:** profile ids are generated by the server, so exporting them would make a configuration deployment-specific. Changing away from the current default first is rejected by the store's exactly-one-enabled-default invariant, while selecting the target first atomically transfers that role without an invalid intermediate state. Running the section first gives later seedable resources a stable profile catalog to reference. ## AGT-047 — Trace content separates prompts from results and identifies skills **Applies to:** `telemetry.py`, chat model/tool spans, skill prompt metadata; `TELEMETRY_RECORD_CONTENT`, `TELEMETRY_RECORD_PROMPTS`, `TELEMETRY_CONTENT_MAX_CHARS` Skill identity is non-content trace metadata. A listed skill carries its stable skill id, display name, and immutable revision into the chat tool spec; the primary rendered skill is inherited by the step and its descendant model/tool spans. This makes runs from two revisions comparable without exporting their instructions. Tool arguments and successful results follow the existing content opt-in. System prompts, model inputs, and rendered skill bodies use a separate prompt opt-in because they are larger and expose the complete instruction context. Both switches remain off by default and share one operator-configured per- attribute character bound. **Why:** output-only traces identify a bad answer but cannot reproduce what led to it, and spans without skill revision identity cannot form the before/after populations needed to improve a skill. Prompt capture is materially more sensitive than bounded action evidence, so accepting the latter must not imply accepting the former. A shared limit makes the amount exported an operator choice instead of a collection of code-site constants. ## AGT-048 — Gateways own upstream user grants; service tokens renew automatically **Applies to:** `external_mcp`, `external_mcp_tokens`, `chat_connections`, `ExternalMCPProxy.client_credentials` / `user_authorization` Per-user delegation and recovery are experimental pending the end-to-end validation in [#312](https://github.com/mappedsky/seizu/issues/312). ContextForge v1.0.9 transport tests passed, but bearer-plus-target-header requests retained the bearer owner's authority; trusted-header mode needed email identity mapping and exposed a server-scoped CSRF incompatibility. Transport success therefore does not establish per-user grant isolation. Shared-token external access is outside this experimental designation. Per-user external authority is an explicit gateway contract. By default Seizu supplies the stored run owner's durable OIDC `(issuer, subject)` pair, never its local user ID, over either M2M bearer authentication or trusted mesh authentication. A gateway may instead map a nonempty identity-provider claim such as email into a target header. The gateway maps that identity to its own upstream grant and must never substitute a shared credential when the grant is missing. Shared static API tokens remain supported through `bearer` plus `token_env`. **Why:** Temporal deliberately holds no browser bearer (AGT-008), and Seizu's OIDC refresh token is an encrypted browser cookie, not a worker-accessible vault. An existing gateway already owns consent, refresh, account linking, and upstream policy. Its user directory cannot identify a Seizu-local ID; the durable OIDC pair is the identity boundary (AUTH-001). Duplicating that store in Seizu would introduce another grant lifecycle without proving the upstream account belongs to the asserted user. M2M client credentials acquire and renew service tokens in process memory; existing `token_env` configurations remain compatible. A token acquisition is coalesced per configuration and a rejection invalidates only the rejected cached token. Calls are not replayed for renewal because a transport failure need not prove a tool had no effect. Mesh-authenticated deployments need no second bearer. **Why explicit error classification:** a service-account 401 is an administrator problem, not evidence that the target user needs consent. HTTP 401 is a service failure and 403 is a permission denial. The initial custom `X-Seizu-Auth-Error` contract is replaced by standard MCP URL elicitation: `elicitation/create` and the 2025-11-25 `-32042` error, plus 2026-07-28 `InputRequiredResult` requests. The SDK's capability builder is narrowed to URL-only because its callback API otherwise advertises form support that this client does not provide. Detached workers cancel legacy callback requests and do not continue modern input-required results. Neither response represents human consent. Recovery is manual on Chat Connections; no tool is automatically replayed and completion notifications cannot establish a grant or resume a finished run. URL elicitation can also request payments or other interactions, so its status is generically `interaction_required`, not an assertion that the user needs OAuth consent. Recovery URLs are bounded and restricted to the operator-configured account page's origin, revalidated on read, and never included in model diagnostics. The owner sees the gateway's plain-text explanation and target host before choosing whether to navigate. This permits nonce-bearing protocol URLs without making arbitrary server redirects trusted destinations. Gateways must validate browser identity independently and must not put credentials in URLs/messages. Legacy non-opted-in proxies keep their existing OAuth path. Connection observations survive worker restarts (STO-013), including failed discovery that hides a skill. Their owner-facing page is gated by `chat:use`, so a custom chat-only role can recover without acquiring toolset administration. ## AGT-049 — External Streamable HTTP negotiates modern-first **Applies to:** `external_mcp`, `external_mcp_elicitation.ClientSession`, `ExternalMCPProxy.protocol_mode` Streamable HTTP uses SDK auto negotiation, trying modern `server/discover` before a bounded legacy handshake fallback. SSE remains legacy. Operators may force `protocol_mode: legacy`. Both paths create a fresh owner-scoped transport per operation and accept metadata from the negotiated result. **Why:** initializing unconditionally limits the client to the handshake era, even when the installed SDK and server support modern stateless requests and input-required results. The SDK supplies version selection and request stamping; its private auto-negotiation helper is isolated in our session adapter and covered by wire-level tests. The adapter restricts fallback to compatibility failures: authentication, rate limits, outages, timeouts, and positively disjoint version sets must not become attempts to negotiate a different protocol. We use the session API, not the high-level client's automatic input-required continuation driver, so negotiation cannot change the detached recovery and no-replay contract in AGT-048. The legacy override accommodates peers that reject requests made before initialization without treating their errors as consent to downgrade automatically. ## AGT-050 — MCP forms decide existing action confirmations **Applies to:** `mcp_server._handle_call_tool`, `mcp_runtime._ensure_tool_confirmation` MCP 2026-07-28 clients advertising form elicitation receive an `InputRequiredResult` for pending Seizu action approvals. The confirmation ID is the continuation state and input-response key. The runtime resolves the confirmation after current permission and argument validation, using the existing caller/session/tool/target/argument-hash scope. The form carries no fields: the elicitation action is the decision, so a matching continuation that accepts approves the record, decline denies it, and cancel leaves it pending. Execution consumes the ordinary atomic grant. Other clients retain the browser URL flow. **Why:** these approvals need a decision, not credentials, so a client form removes the browser round trip. The modern input-required flow works with our stateless HTTP transport; legacy server-to-client callbacks require a back-channel that this deployment does not retain. Reusing confirmation records preserves ownership, expiry, decision attribution, and the execution claim; trusting a bare accept response or setting `confirmation_pre_approved` would discard those checks. A required `confirm` boolean defaulting to false was tried and removed: it restates the accept/decline the protocol already carries, and a client that submits the form without toggling it silently denies the action and then holds the denial for the confirmation window. The SDK's capability checker tests elicitation presence but does not distinguish form from URL support, so the transport checks the per-request mode declaration explicitly. **Don't:** turn a form response into a permission grant or bypass flag, or replace upstream account authorization URL elicitation (AGT-048) with a form. ## AGT-051 — Keep HTTP connections alive between MCP requests **Applies to:** `gunicorn.conf`, MCP Streamable HTTP clients Gunicorn uses a five-second HTTP keep-alive interval with UvicornWorker. Legacy MCP clients still initialize normally, independently of the modern protocol and form-elicitation capability gates. **Why:** with `keepalive = 0`, Codex 0.153.4 received Seizu's legacy `initialize` response, then repeatedly failed sending `notifications/initialized` with a closed transport. Separate curl requests returned 200 and 202, concealing the connection-reuse failure. The same native Codex client, with `mcp_2026_07_28` explicitly disabled, completed initialization and listed all 105 tools against the same application with `--keep-alive 5`. This is an HTTP connection-lifetime issue, not a failure to negotiate an older MCP revision. After applying the change to the normal endpoint, native Codex discovered all 105 tools with both `2025-06-18` (flag disabled) and `2026-07-28` (flag enabled). The in-process ASGI tests do not exercise TCP connection reuse. **Don't:** disable keep-alive to make MCP stateless. Protocol session state and HTTP connection reuse are independent. ## AGT-051 — Confirmation elicitation mode is an operator's choice, defaulting to URL **Applies to:** `mcp_server._elicitation_mode`, `_elicitation_params`, `settings.MCP_CONFIRMATION_ELICITATION_MODE` `MCP_CONFIRMATION_ELICITATION_MODE` selects how an MCP client collects approval for a mutating action: `url` (default) points the client at Seizu's own confirmation page, `form` (AGT-050) renders a dialog in the client, `permission` picks `form` for callers holding `chat:bypass_permissions` and `url` for the rest, and `off` returns the payload as content. In `url` mode the client's response never carries a decision — the responder re-reads the record rather than writing one, so a continuation that claims approval without one gets the pending confirmation back. A client that cannot do the configured mode receives content, never the other mode. Only a first attempt elicits; a continuation that arrives unapproved is answered with the payload. **Why:** the protocol has no way to show that a person saw a form. The client reports the decision, so a client that answers automatically approves every action its caller is otherwise permitted to take, which is exactly what the confirmation exists to prevent — and an operator running clients it does not control cannot detect the difference. Deciding in Seizu costs a round trip and removes the client from the trust path entirely, so it is the default; `form` stays available where the client is trusted, and `permission` reuses the judgement a deployment already made about who may skip confirmations. Falling back from `url` to `form` when a client lacks URL support would let any client opt itself into the weaker flow, so the fallback is content instead. A bare `elicitation: {}` counts as form support but not URL support: Claude Code 2.1.263 advertises it and then rejects a URL request outright, which fails the call rather than degrading it. **Don't:** let a `url`-mode response decide a record, or treat a missing client capability as licence to downgrade the mode. ## AGT-052 — Modern URL elicitation has a Codex compatibility gap **Applies to:** `mcp_server._elicitation_params` Keep the SDK's protocol-version serialization for URL elicitation. Codex 0.153.4's modern URL path is not validated as compatible with Seizu. **Why:** a native Codex call failed with `Unexpected response type` before displaying the URL. The captured Seizu response contained a URL elicitation inside `InputRequiredResult`; Codex's generated schema requires `elicitationId`, while the installed Python SDK marks it as removed at MCP 2026-07-28 and strips it on serialization. Adding the field to the request object was tested and did not put it on the wire. The report remained present and its confirmation stayed pending. This is distinct from the working form flow and from capability-based fallback: Codex advertises URL support, so Seizu offers the configured URL mode. **Don't:** downgrade to form elicitation to work around a URL-client failure. ## AGT-053 — One converter writes `allowed-tools`, and the parser reports the other spellings **Applies to:** `plugin_packages.allowed_tool_entry`, `_allowed_tool_diagnostics`, `routes/skillsets._legacy_skill_markdown`, `legacy_skillset_package`, `report_store.reconcile_legacy_projection`, `src/pluginAuthoring.toolDeclaration` [AGT-042](#agt-042-seizus-own-tools-are-named-like-any-other-mcp-servers) made `mcp__seizu__` the only spelling that resolves, and deliberately left every other token alone as the consumer's own built-in. Three writers inside Seizu kept emitting the internal spelling into packages they generate — the startup projection of a legacy skillset, the legacy skill routes (which are what the `skillsets__*` built-ins call), and the plugin skill editor's tool picker, which wrote the tool catalog's `mcp_name` verbatim. **The failure mode is the one AGT-042 accepts for a foreign token, applied to our own.** The entry resolves to nothing, reports nothing *missing*, and the skill stays listed and renders. Under progressive disclosure the model then reads instructions naming tools it holds none of, because `tools_required` came back empty. Every skill projected from a legacy skillset lost every dependency this way, the skill-authoring skill included. Two rules keep it from returning: - **Every writer goes through `allowed_tool_entry`**, which qualifies only a name shaped like one of Seizu's own and is safe to apply twice. The frontend mirror is `toolDeclaration`, and the editor normalizes on load, so opening a stale skill and saving it heals the declaration. - **`parse_package` reports what it will not resolve.** A bare Seizu tool name, an `ext____`, and a `mcp____` naming a server `mcp.json` does not declare are each a warning, not an error: a wrong declaration is not a broken package, but it must not be an invisible one. **The projection is reconciled, not just created.** `_migrate_legacy_skillsets` re-projects every legacy skillset at startup and republishes when the digest moved, so a change to what the projection *writes* reaches deployments whose packages already exist. A package published over the projection is left alone. An unchanged skillset re-projects to the same digest, so the settled cost is still one read per skillset. **An `ext____` in a legacy skill stays in its legacy spelling.** The package form needs an `mcp.json` server entry, and a legacy skillset has nowhere to carry one; synthesizing it from the configured proxy would take skills on http upstreams from "this tool is not disclosed" to "this skill is unavailable", since `mcp.json` accepts only https or loopback URLs. **The seed ships skills as packages.** The dev seed's four legacy skillsets are now `plugins:` entries with package sources, which is what let `cve_response/dependency_provenance` declare the deps MCP it has always needed: as a skillset it could not, so its four external tools were dropped on every render. `seizu export` no longer writes an authored package back out as a skillset either — the projection has nowhere to put `mcp.json`, `scripts/` or `references/`, so re-seeding that export would have replaced each package with a lossy copy of itself. A package whose manifest carries the projection marker is still exported as a skillset, because there the legacy record is the source. **A capability the listing does not mention is a capability the model will not find.** Under progressive disclosure the agent chooses from skill descriptions and triggers; the body is only read once a skill is loaded. `reports__delete` was declared by the create/update skill, where it exists for the clone-cleanup step, and nothing in that skill's description or triggers said "delete". Asked to delete a report, the agent read the listing, found no such capability and answered that it had none — never loading the skill, so no confirmation was ever offered and the failure looked like a missing tool. The package now has a `delete-reports` skill that says so in its description and triggers. Declaring the tool is necessary; advertising it is what makes it reachable. **Don't:** re-derive the qualified name at each call site. The contract is held by `tests/unit/reporting/services/allowed_tools_contract_test.py`, which drives every writer off the built-in registry rather than a fixed list, and by `seed_config_test.py`, which resolves every seeded package's declarations against the registry and the seed's own toolsets. ## AGT-054 — Denial recovery has action and session bounds **Applies to:** `action_confirmations`, `report_store.sql`, `ConfirmationPage` An identical action gets one extra prompt after denial by default. Five live denials scoped to the user, source, and session refuse further prompts with `confirmation_denial_limit`. Both limits are configurable; the window uses the existing record expiry rather than a new timer or migration. Counts are SQL aggregates over unexpired denied rows, without the UI list's 500-row limit. Existing approved grants remain consumable. Already outstanding confirmations can still receive decisions; this is a denial budget, not a cap on concurrent pending requests. A reversed denial no longer contributes to the live count. Only the owner decision route enables denied-to-approved reversal, with an expiry- and status-conditional database update. Ownership implies this ability; execution still validates the caller's current permissions and claims the grant once. MCP form continuations never enable reversal or create retry prompts in response to a decline; a fresh call is needed for the bounded retry. **Why:** #314 showed that fingerprint stickiness locked out honest identical retries while changing one argument produced unlimited prompts. One extra prompt makes a mis-click recoverable with the smallest retry allowance, while the session budget survives argument variation. Five leaves room for several distinct actions without allowing an unbounded sequential denial loop. Owner reversal provides recovery after either budget is exhausted without adding a model-driven prompting surface. Exponential backoff would add state without bounding a patient caller. The existing TTL gives operators one window to tune. The new budget refusal is an MCP error and a distinct chat block reason. Existing pending/denied result `isError` semantics remain unchanged; changing those for every client is separate from the denial policy in this issue. ## AGT-055 — External input parks a call and resumes through an owner action **Applies to:** `chat_elicitations`, `external_mcp`, chat resume commands, orchestrated step results, `ChatElicitationCard` Interactive MCP 2026-07-28 input requests are stored as a bounded group, end the turn, and resume with the original arguments and opaque continuation state after the owner answers. Legacy callbacks still cancel. Form capability is advertised only for an opted-in interactive top-level or orchestrated call; discovery, detached runs, and sandbox subagents keep it stripped. URL capability remains available for AGT-048 recovery, including when interactive elicitation is disabled. Sampling and roots input requests are unsupported. **Why:** a Temporal activity has no inbound form channel (AGT-008), and a legacy callback needs an open upstream session. The modern continuation survives both the activity ending and a browser reload without holding a worker slot. Reusing the confirmation pause keeps distributed worker results portable: the coordinator resumes the parked call without restarting the remote step's model loop. Response records are owner-scoped and forwarded through `input_responses`, rather than inserted directly into model arguments or messages. Upstream tool results may include those values and enter chat history and model context. Forms warn users not to enter passwords, API keys, access tokens, or verification codes. Credential collection belongs in URL elicitation on the external site. **Why:** MCP forms are for ordinary input, not secrets. Substring redaction corrupted legitimate results, while length/type heuristics and exact matching could not guarantee confidentiality. Removing redaction preserves results and makes the UI and documentation state the actual data flow. Form schemas remain a bounded flat primitive subset rendered as escaped text. URLs retain the operator-pinned origin and are revalidated with the proxy configuration before display. Global and per-proxy opt-ins do not make upstream schemas trusted UI. Replay checks current permissions, current tool discovery and confirmation policy, and atomically consumes the group once. An approval needed during resume is linked to the continuation, so approval cannot run a fresh call. Declined, cancelled and expired requests are terminal without re-prompting. Resumed reads undergo normal verification, but are not retried. **Why:** answering a form is not an authorization grant. Verification may reject evidence without repeating a call that may already have had effects. This refines AGT-048 only for owner-initiated interactive continuations; detached runs still never replay. A one-shot decline path needs no additional denial budget. A sandbox delegation that unexpectedly elicits records the request and returns. After the owner answers, a later delegation may consume the continuation for the exact same tool, arguments, owner, thread and proxy. Its sandbox files and receipts survive; its earlier model loop does not (SBX-005, SBX-008). **Why:** retaining or reconstructing a subagent transcript would introduce a second continuation lifecycle. The persistent sandbox already carries work into a new delegation. Input-required tool details are awaiting input. A continuation records its outcome against the original elicitation group; the UI applies that outcome to the paused detail across turns and history reloads. Within a step's own trace the outcome is folded into the parked entry rather than appended, so a call is one row carrying both its arguments and its result. An answered card confirms in place and then closes, before the turn it releases starts; dismissal is local to the view, so a reload brings an unclaimed card back and a delivery that never dispatches restores it immediately. Consumed cards are hidden, while accepted cards remain available until the continuation is claimed. **Why:** consumption is not proof that an upstream operation succeeded. Using the recorded outcome avoids showing a failed continuation as successful, and retaining unconsumed answers preserves recovery after interrupted delivery. Appending instead replayed the wait beside its own answer on every reload, which live delivery never showed. Closing the card on the delivery promise holds it open for the whole turn: that promise settles when the turn does, not when it is dispatched. ## AGT-056 — Human-input resumes share dispatch, not approval semantics **Applies to:** `useChatHumanInputResume`, `ChatInterface` Approval decisions, external-input responses, and approval recovery links use one browser dispatcher for hidden ID-only continuation messages. Dispatch checks the originating conversation against the current conversation and sends exactly one kind-specific resume field. Decisions and response values remain in their respective owner-scoped APIs; their pending-group rules, cancellation semantics, and server-side execution claims remain separate. **Why:** the duplicated send paths had already diverged: approval resumes touched the session and carried a second, mutable fallback ID, while elicitation resumes did neither. A shared dispatcher removes that drift and prevents an answer finishing after navigation from resuming into another thread. Routing the agent through Seizu's own MCP endpoint would add transport, identity, and capability handling around the already shared runtime (AGT-050). Answering an external form supplies input, not an action grant, so combining the decision stores or treating acceptance as approval would erase a security boundary. **Don't:** put response values in the hidden message, use MCP loopback for first-party approvals, or make a generic resume bypass either kind's checks. ## AGT-057 — An elicitation resume offers the finish tool, and never answers with nothing **Applies to:** `chat_graph._resume_elicited_tool_turn` The turn that summarizes an answered external-input request is offered `respond_to_user`, and reads its answer from that call before falling back to the message content. If both are empty it answers with the tool result itself rather than persisting an empty assistant message. **Why:** the resume turn is post-action, and the base system prompt requires a post-action answer to arrive through `respond_to_user`. The turn ran with no tools at all, so the model obeyed the prompt, its call was dropped with the tools it was never given, and `content` was empty — one LLM call, a stream carrying `text-start`/`text-end` with no delta, and an empty message in the checkpoint. An empty assistant message is then dropped from history, so the turn disappeared on reload and the parked tool detail, having no recorded outcome to reconcile against (AGT-055), reverted to awaiting. Live delivery showed the settled result and a reload took it away, which reads as the answer never arriving. Observed intermittently, on whichever turns the model chose the tool over prose. The confirmation resume never showed this because it already falls back to the combined tool results. **Don't:** run a post-action summary turn with no terminal tool while the system prompt demands one, or let any resume path persist an empty answer — the message is what carries the turn, and history drops it when it is blank.