@sema-agent/core 5.9.0 → 5.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/agents/roster-store.d.ts +1 -0
  3. package/dist/agents/send-message-tool.js +6 -0
  4. package/dist/agents/subagent.d.ts +31 -1
  5. package/dist/agents/subagent.js +67 -21
  6. package/dist/agents/teacher.js +15 -3
  7. package/dist/agents/team.js +10 -0
  8. package/dist/agents/verify.js +7 -0
  9. package/dist/brain/anthropic.js +27 -10
  10. package/dist/brain/open-responses.js +19 -4
  11. package/dist/brain/openai.js +32 -5
  12. package/dist/core/a2a.js +1 -1
  13. package/dist/core/background-agent-store.d.ts +2 -1
  14. package/dist/core/background-agent-store.js +1 -0
  15. package/dist/core/checkpoint-store.d.ts +2 -0
  16. package/dist/core/checkpoint-store.js +1 -0
  17. package/dist/core/memory-recall.js +8 -3
  18. package/dist/core/memory.d.ts +5 -0
  19. package/dist/core/memory.js +6 -4
  20. package/dist/core/runner/assemble-result.js +9 -0
  21. package/dist/core/runner/prepare-task.d.ts +12 -1
  22. package/dist/core/runner/prepare-task.js +110 -38
  23. package/dist/core/runner/runtask.d.ts +12 -0
  24. package/dist/core/runner/runtask.js +169 -37
  25. package/dist/core/runner/session-file-state-replay.d.ts +7 -0
  26. package/dist/core/runner/session-file-state-replay.js +56 -0
  27. package/dist/core/runner/synthetic-tools.js +1 -1
  28. package/dist/core/runner/tool-disclosure.d.ts +1 -0
  29. package/dist/core/runner/tool-disclosure.js +24 -9
  30. package/dist/core/runner/tool-output-projection.js +5 -4
  31. package/dist/core/runner/turn-attachments.d.ts +2 -0
  32. package/dist/core/runner/turn-attachments.js +14 -5
  33. package/dist/core/session-reconcile.d.ts +7 -3
  34. package/dist/core/session-reconcile.js +3 -2
  35. package/dist/core/strategy-store.d.ts +1 -1
  36. package/dist/core/strategy-store.js +27 -4
  37. package/dist/core/task-registry-agent.d.ts +3 -0
  38. package/dist/core/task-registry-agent.js +9 -2
  39. package/dist/core/task-registry-shared.d.ts +1 -0
  40. package/dist/core/task-registry.d.ts +2 -0
  41. package/dist/core/tools.js +9 -1
  42. package/dist/core/trace.d.ts +1 -0
  43. package/dist/core/types.d.ts +8 -1
  44. package/dist/engine/loop/agent-loop.js +168 -22
  45. package/dist/orchestration/run-workflow-tool.js +1 -1
  46. package/dist/orchestration/workflow-governance.js +19 -0
  47. package/dist/orchestration/workflow-primitives.d.ts +1 -1
  48. package/dist/orchestration/workflow-primitives.js +4 -1
  49. package/dist/orchestration/workflow.js +1 -1
  50. package/dist/prompts/coordinator.d.ts +1 -1
  51. package/dist/prompts/coordinator.js +1 -1
  52. package/dist/prompts/default.d.ts +1 -0
  53. package/dist/prompts/default.js +3 -0
  54. package/dist/stores/file/memory-store.js +3 -7
  55. package/dist/tools/fs/fs-bash.js +7 -4
  56. package/dist/tools/fs/fs-shared.d.ts +1 -0
  57. package/dist/tools/fs/fs-shared.js +4 -0
  58. package/dist/tools/fs/index.d.ts +1 -0
  59. package/dist/tools/fs/index.js +7 -4
  60. package/dist/tools/web.d.ts +21 -1
  61. package/dist/tools/web.js +126 -11
  62. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,68 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.11.0 — 2026-08-04
4
+
5
+ ### BREAKING
6
+
7
+ - **The restart-loop suspend cap counts CONSECUTIVE NO-PROGRESS suspends, not total approvals.** The cap's stated purpose is catching a restart-prone model re-issuing the same gated call forever — yet it counted distinct approved-and-executed gates identically, so under a doctrine that asks per call (`shellGate:"classify"` + out-of-root reads) a fully cooperating approver was killed on the 6th legitimate Yes (`suspend.loop`, default `maxSuspends: 5`). The chain base now resets the moment the resume leg starts EXECUTING the approved pending call (an execution that then errors still consumed the approval — it reset the chain); a park whose approved call never ran keeps accumulating. **Consumer flips**: checkpoint `suspendCount` restarts at 1 after each executed approval — probes pinning a monotonically increasing `suspendCount` across approve-execute cycles red; `maxSuspends` now bounds no-progress chains, never the total number of approvals a run may receive.
8
+ - **`SubagentErrorKind` closed set gains a sixth member: `"governance"`.** A FAILED child whose `errorCode` carries the `usage.*` prefix (deployment usage window refused/exhausted) now classifies as `governance` with `retryable: false` — it is DEFERRED-retryable: re-issuing NOW gets the same refusal, but the refusal lifts at the card's `retryAfterMs`. Previously these folded into `logic` ("re-delegating unchanged won't help"), which told a scheduler to abandon a subtask that merely needed to wait. The transient retry set (`rate_limit`/`overloaded`/`timeout`/`network` → `retryable: true`) is untouched. **Consumer flips**: switches over the closed five-member kind set add a `governance` arm; the completed agent card now pairs `error_kind: "governance"` with `retryAfterMs` on window-exhausted children.
9
+
10
+ - **Deferred-tool activation no longer rewrites the provider tools block — `toolMaterializeStrategy` defaults to `"static"`.** Tools serialize FIRST in the provider prompt-cache hierarchy, so the old activation swap (placeholder → full schema via setTools) invalidated the entire conversation cache — 29% of benchmarked trials paid an average ~31k tokens per break. Under the new default the placeholder bytes persist for the whole run; the full schema reaches the model through the ToolSearch result text (existing carrier) and engine-side validation against the REAL schema is unchanged. Ruled GO by a live A/B on the weakest BYOM model (15/15 argument instances correct, three scenario classes incl. nested/optional params). `"swap"` is the explicit opt-back (spec or `SEMA_TOOL_MATERIALIZE_STRATEGY`, invalid env value refused loudly as `config.tool_materialize_invalid`). **Consumer flips**: probes pinning the post-activation full schema in the tools block red — the activation announcement frames and genuine roster changes (MCP refresh) are unchanged.
11
+
12
+ ### Added
13
+
14
+ - **A shell-gated ask persists the live shellGate doctrine on its risk descriptor.** Field forensics could not answer "was `classify` or `always` live when this gate fired" from any persisted artifact. The mint now stamps `RiskDescriptor.shellGateDoctrine` (`"classify" | "always"`, present only on shell-gated asks — `"off"` never mints) so the checkpoint row self-reports its provenance. Additive optional field.
15
+ - **`TaskSpec.additionalReadDirectories` — a read-only fence widening.** Extra directories the READ faces admit and nothing else: the `shellGate:"classify"` read boundary (a provably read-only command like `head <dir>/settings.json` auto-allows instead of consulting the approver on every call — the interactive kill shape this exists for), plus read_file/grep/glob/repo_map and `bash_readonly` containment. The write faces (edit_file/write_file/notebook_edit) refuse these directories exactly as before — a read grant never silently becomes a write grant (`additionalDirectories` remains the read+write widening). Same canonicalization/fail-closed-skip rules as `additionalDirectories` (the skip trace gains an optional `field` discriminator); announced in `# Environment` as `Additional read-only directories:`; inherited down the delegation tree on the same seat as `additionalDirectories`. Additive — absent ⇒ byte-identical behavior.
16
+
17
+ ### Fixed
18
+
19
+ - **WebFetch stops laundering emptiness into authority (grounding contract).** A shell page (tiny extracted text, or near-zero text ratio) now gets a TRUSTED grounding verdict outside the untrusted fence, the full extracted text appended for verification, and additive `details.grounding{level,textChars,bytes,textRatio}`; an incomplete body is called a retrieved prefix. The summarizer input budget derives from the summary model's window (flat 100k retired; trimmed input disclosed structurally and in the rendered head; NaN model metadata refused loudly). Recovery prose names Bash/curl only when reachable in THIS run. The summary prompt gains a grounding clause (report absence rather than infer; a filename is only evidence about naming) ahead of the byte-fixed CC guidelines tail.
20
+ - **A truncated-tool-arguments disclosure names the request output cap when one was armed** (`request output cap in effect: N tokens`) on both brain lanes — a cut mid-arguments at the cap read as a generic provider error with no budget clue.
21
+ - **agentStream's birth window is steerable**: an immediate `steer()` after `agentStream()` waits (bounded) for the loop's first prompt and lands on turn 1; a finished run keeps the honest refusal.
22
+ - **A long-window zero-byte Bash timeout names its two honest readings** (stalled before producing output / holding it in a block buffer); short windows and abort arms stay bare.
23
+ - **The background faces carry the governance wait hint.** A `usage.window_exhausted` background child's settle dropped `retryAfterMs` — every bg face (poll text, poll details, notification-adjacent durable row) said `retryable:false` with no way to know when the refusal lifts, while the sync report card carried the hint. The classification family gains its fourth seat end-to-end: settle mint → registry row → durable archival row → poll `details.retryAfterMs` → the poll text clause (`(error_kind: governance, retryable: false, retry_after_ms: N)`); a revive clears it with its three siblings. Additive on every face.
24
+ - **The `suspend.loop` terminal diagnoses the every-approval-was-Yes kill.** When every recorded gate decision on the run was ALLOW, the message now names the shape (a gate that keeps asking × an approver that keeps approving consumed the suspend allowance on legitimate work) and the remedies (a live `onAsk` parks nothing; keep benign commands inside the read boundary; raise `maxSuspends` for approval-heavy tasks) instead of reporting a bare restart-loop mystery.
25
+
26
+ ## 5.10.0 — 2026-08-04
27
+
28
+ ### Added
29
+
30
+ - **`TaskSpec.checkpointStore` is three-valued**: `null` explicitly gives THIS run no durable store, even on a deployment that wired one (`undefined` still inherits `RunnerDeps.checkpointStore`; a store still overrides). All four legs into `status:"suspended"` (approval park / resource-slice / platform `env_lifetime`·`usage_window` / `plan_review`) are gated on a store being present, so a store-null run cannot reach `suspended` by construction and can never leave a pending checkpoint nobody resumes — the machine-started-run form (notification redelivery, maintenance sweeps). Existing no-store semantics apply: asks resolve at a live `onAsk` or fail-closed deny; limits are loud terminals; the plan/question durable faces are not mounted. Delegated children inherit it (sync/bg/fork/workflow spawn lanes). New Runner-filled `ToolExecuteContext.checkpointStoreDisabledForChildren` tells custom delegation tools to copy it. **Consumer notes**: `preempt()` becomes a documented no-op on store-null runs (existing `preempt.ignored` trace — "preemptible" is now two-valued; schedulers keep runs preemptible by leaving the field unset); a resume config carrying `null` refuses loudly (`checkpoint.not_found`) instead of silently resuming against deps.
31
+
32
+ ### Fixed
33
+
34
+ - **Eight `tool_end.structured` cards were minted and then dropped before the wire.** The projection allowlist (`details.type`) had never been reconciled against the tools that mint cards, so a host got nothing structured for: `ReportFindings` (worst case — its model face is deliberately compressed to `"N findings reported."`, so the findings reached *neither* the model *nor* the host), `ScheduleWakeup`, `SendMessage`, `AgentTranscript`, the A2A peer tools, the PDF `document` card (whose own 48K base64 budget was written against a size gate it never reached), the `bash_readonly` out-of-root refusal, and the **completed** delegation report — the one card `tool_end.structured`'s own contract names by name, while only the `async_launched` receipt could actually arrive. Registered words added: `report-findings`, `schedule-wakeup`, `send-message`, `agent-transcript`, `a2a`, `document`, `readonly_out_of_root`. Two mint sites gained the `type` discriminant they lacked (`ReportFindings`; the three `bash_readonly` refusal arms, following the `path_not_in_root` precedent) — `details.code` is untouched for existing readers. Model faces are byte-unchanged everywhere; this is purely the host face that was missing. **Consumer notes**: a shell that prose-matched any of these can now read fields; a probe asserting `structured === undefined` for them reds.
35
+
36
+ - **An interrupted tool call's synthetic `tool_end` finally carries a body — and the wake/crash leg emits one at all.** Both reconcile legs closed orphan calls in the transcript with the full `[INTERRUPTED]` explanation the model reads, while the event stream got `isError: true` and nothing else: a consumer rendering tool output from frames showed an empty result for every interrupted call, and the two faces of one call disagreed. The frames now carry `output` (the exact persisted text, same projection as every live tool result) and `errorCode` (`interrupted_never_started` | `interrupted_outcome_unknown` — the persisted `details.errorKind`, so a consumer discriminates on a code instead of prose-matching), plus the tool's display `label` and the usual `eventId`/subagent attribution. Separately, the **wake/crash** leg (a previous PROCESS died holding the calls) used to repair the transcript and tell the stream *nothing*: no `tool_end`, no `message_committed`. It now replays the same pair at run open — the only channel through which that news can ever arrive, since the process that owned those calls is gone. `ReconcileReport.recovered` entries gained `text` and `errorKind` (new exported type `RecoveredOrphan`, `ReconciledErrorKind`). **Consumer flips**: a probe pinning "the interrupted `tool_end` has no `output`" reds; a resumed run on a crashed session now emits one `tool_end` + one `message_committed` per orphan before its first turn, so an exact event-count or first-event assertion on that path reds (there is deliberately no matching `tool_start` — the start belonged to the dead process's stream).
37
+
38
+ - **The idle-park notification redelivery carries task identity.** A notification parked while the session sat idle is redelivered on the next run's stream as the same `task_notification` family — but that push shipped with no `eventId`, and for a subagent no `parentToolCallId`/`sourceTaskId`, so a consumer keying a child's frames by task silently attributed every redelivered frame to the wrong task. The cause was structural rather than a missing field: the identity minter was declared later in the run body than the redelivery drain, so the drain could not call it. It is now minted as soon as the prepared run exists. Live-lane frames are unchanged (they always had identity).
39
+
40
+ - **Read-before-write state follows the SESSION, not just the task.** An ordinary continuation turn used to start with an empty read-state (the seed lane existed only for durable-checkpoint resume), so turn 2 paid a forced re-read for every file turn 1 had read whole. The prepare step now replays the session transcript on continuation turns: whole-file-provable Read/Write records seed entries whose hash comes from what the transcript recorded — never from disk — so staleness is unchanged by construction (a file changed between turns is still refused), and Edit/NotebookEdit records retract their path (a self-edit is never misread as a foreign change). Resume legs, first turns, and post-Edit paths are byte-identical to before. **Consumer flip**: a probe pinning "a continuation-turn edit is always refused as not-read" reds.
41
+
42
+ - **Memory recall: an unknown write time is incomparable, not 1970.** A note whose `ts` failed to parse used to sort as epoch 0 — it lost every `[[name]]` collision (permanently unreachable through links) and rendered a fabricated age ("written 678 months ago"). `MemoryNoteHeader.timestampMissing` (new, optional) is minted at the single parse point; the one recency comparison treats missing as a tie (the incumbent keeps the name — an unknown-time note neither sinks nor poses as newest), and the render face says "write time unknown — verify it's still current". A literal 1970 timestamp string stays a real time (the numeric value is never the signal; negative pins lock this). Bonus: a wire row with NO `ts` key used to throw and kill the whole scope's manifest build. **Consumer flip**: a probe pinning the fabricated-age wording or the sink-to-bottom collision outcome reds.
43
+ - **Three awaited rescue timers are REF'd + cleared-on-settle** (subagent observer drain, teacher abortFallback, WebFetch deadline): unref'd, the process could exit before the rescue fired when the wedged arm held no live handles — the settle never completed. The bg notify drain window keeps its unref by design (a background lane must not pin the process).
44
+ - **A harness-side throw around ONE tool call becomes that call's disclosed error result — the batch survives.** Worker exceptions that are not cancellation (a rejected `tool_execution_update` delivery, a sink failure) used to reject the whole parallel batch, orphan the sibling calls, and on the worst path rewrite a COMMITTED side effect into `[tool execution harness failed: …]` — inviting the model to re-run an already-effective write. Progress deliveries now settle at one point; a delivery failure on an executed tool keeps `content`/`isError` untouched and rides `details.toolProgressDeliveryFailed` (total by construction: hostile accessors or exotic `details` decline the annotation, never the outcome; `afterToolCall` cannot erase it). Cancellation — including one wrapped in the harness's own `AgentHarnessError` (`cause`-chain walk) — stays a hard batch failure on every lane, and the in-stream lane closes admission immediately. The sequential lane shares the same disclosure band. **Consumer flips**: probes pinning the old `[tool execution harness failed]` rewrite or `isError:true` on a delivery-failed success red — re-pin on the new shape plus a retired-dialect tripwire.
45
+ - **openai lane: an evidence-free `tool_calls` fragment no longer fabricates an unnamed-call disclosure.** The evidence gate sits at the finalize disclosure point (slot assignment also routes id-less continuations, so gating there corrupted two real calls); the stream's own verdict wins — `finish_reason:"tool_calls"` with zero survivors, or an empty slot beside real calls, discloses loudly; an isolated terminal fragment with `stop`/`length` stays silent.
46
+ - **A throwing tool keeps its machine-readable marks on BOTH legs.** Two strip points, one class: the `defineTool` adapter rewrapped a ToolSpec throw as a bare `Error` (marks died even LIVE — a ToolSpec throw carrying `details.code` got no `errorCode` lift while a native tool's identical error did), and the durable-resume batch's catch stringified the message alone (breaking the live/resume frame-parity contract). Both carry `details`/`errorKind` through now; message text unchanged.
47
+ - **Cache-break attribution: an aborted / usage-missing row is "unknown", not a cold cache.** Feeding it to the design/31 detector minted a false `server-or-ttl` finding and poisoned the warm baseline for the next real turn. Such rows are skipped; a genuine cacheRead collapse still fires.
48
+ - **A usage-ledger charge that does not settle discloses loudly — and is never abandoned.** Both commit points (turn boundary, end-of-task flush) fire a single deployment-sink disclosure after 10s and keep awaiting (abandoning a sequential ledger write would double-charge on the next flush). A wedged ledger store still wedges what it always wedged, now visibly.
49
+ - **The `abortResultDetails` seam is total.** A throwing host callback degrades to "no details" (the abort text is the contract; the marker is additive) instead of escalating a per-tool abort into an unpaired `tool_execution_start` plus an uncaught task-level rejection.
50
+
51
+ ### BREAKING
52
+
53
+ - **The `tool_end.structured` card vocabulary is now a two-way contract — three words leave it, and `type:"agent"` gains a second shape.** Removed: `multiedit`, `memory-saved`, `memory-recall`. No tool in the tree has ever minted any of the three (a batch MultiEdit replay mints `type:"edit"` with an `edits[]` array; core mounts no memory tool at all), so they were a promise to consumers that nothing could keep — a host switching on the closed set had dead branches. Closed-set consumers: **drop those three arms**; a fixture that fabricates one of them to assert it passes the allowlist reds. Separately, `type:"agent"` used to imply "background launch receipt" because the completed delivery was silently dropped; it now carries **both** Agent shapes, told apart by `status` — `"async_launched"` is the receipt, any other value is the completed landing report (`result` / `stats` / `resolvedModel` / `toolStats` / kept `worktreePath` / failure attribution). **Branch on `status`, never on which keys are present.** The completed card is an explicit field whitelist over the child `TaskResult`, not a spread: `checkpointToken` (the resume capability) and `checkpointGate` never leave the process, and a future `TaskResult` field does not join the wire by default. A new test gate walks the tree in both directions (every minted card registered; every registered word produced), so neither direction can drift back.
54
+
55
+ - **Brain lanes disclose an unnamed tool call instead of skipping it in silence.** A `tool_use` block / `tool_calls` slot / `function_call` item arriving with NO tool name now rides the same disclosure path as a call with truncated arguments on all three lanes (anthropic / openai / open-responses): a `[note: N tool call(s) arrived with no tool name …]` block in the assistant content, and — when the turn produced nothing else usable — `stopReason: "error"` with the cause named. Previously such a turn finalized as `[{type:"text",text:""}]` with `stopReason: "stop"` and no error code, so a run whose only action was dropped reported `completed` with an empty answer. **Consumer flips**: a probe pinning the silent empty-`completed` shape reds; a probe asserting assistant content exact-equality gains a note block; "dropped tool call" is now two-cause (`not valid JSON` truncation | `no tool name`) — judge by the named cause, never by the single historic wording; the two causes may be joined with `" | "` in one errorMessage.
56
+ - **`StrategyStore` refuses non-honorable knobs instead of folding them to 0.** `find(scope, query, limit)` requires a non-negative integer or `Infinity` — `Infinity` now means "no cap" and returns everything (it used to return NOTHING); NaN/negative/fractional throw `config.strategy_find_limit_invalid`. `prune(scope, maxSize)` and the `InMemoryStrategyStore` constructor require a non-negative integer and refuse `Infinity` by name (a capacity cap can be widened, not turned off): `config.strategy_max_size_invalid`. A NaN capacity used to silently disable eviction (unbounded growth). Vocabulary additions (closed-set consumers admit two members): `config.strategy_find_limit_invalid`, `config.strategy_max_size_invalid`.
57
+
58
+ - **The three stop clocks gained a crossing contract: `usage_window` > `env_lifetime` > stall.** When the governance window and the environment lifetime come due at the SAME turn boundary, the window wins (loud terminal `usage.window_exhausted` + `retryAfterMs`, or a `usage_window` checkpoint with its resume hint) — it is the only cause carrying an actionable wait; env-first reporting dropped it. With an enforceable env lifetime, the ledger READ races an absolute deadline (a stalled ledger stops the run for the env and disclosingly skips window enforcement for that boundary); the CHARGE is never raced. **Probes pinning "both due ⇒ `env.lifetime_expired`" red.**
59
+ - **Durable-resume deferred siblings are fully paired: each `[DEFERRED]` `tool_end` is preceded by a synthetic `tool_start`** (same id/name/label, the model's real arguments recovered from the transcript, adjacent and unconditional; deliberately NOT in `startedToolCallIds` — the call never executed and interrupt-reconcile still reads it as safe-to-reissue). Exact event-count / first-frame probes on the resume leg red. Delegation tick counters count issued `tool_use` blocks (their declared semantics), so a deferred sibling's start now counts once as issued — the old shape UNDERcounted.
60
+ - **Manual compaction gains two mooted disclosure frames** (`compaction_outcome{mooted, manual, cancelled}` on parked-caller cancel; `{mooted, manual, task_ending}` at run settle/teardown with a parked request), and `TaskStream.compact()` called in a failed run's teardown window returns `"mooted"` instead of hanging forever. "No compaction frame on this path" probes red.
61
+ - **Delegation inheritance seats (one-way tighten, narrowings enumerable):** ① a read-only parent's delegated children carry NO write hands (`handsReadOnly` is a trusted ctx seat, one-way); ② a hard-headless parent's children no longer mount `AskUserQuestion` at all (the content-ask seat `onQuestion` is resolved once at prepare and handed down UNWRAPPED — parent and child share the SAME function object; identity questions read `AskQuestionRequest.sourceTaskId`/`principal`, never closure identity); ③ teacher helper/advisor legs inherit deployment auth/principal/clientContext/promptProfile plus the one-way clamps (a read-only/headless student's teacher legs lose write hands and questions); ④ the verify leg runs hard-headless always (`interactiveTools:false`) with principal + tool-face controls; ⑤ `oneShot` propagates true-ward. The child roster went from "never has AskUserQuestion" to "has it iff the parent face allows" — closed-set tool-roster probes on children red. Legs that OUTLIVE the spawning request (session-scoped/fork background, durable revival, SendMessage wake) never take turn-bound faces; resume rows take the union of clamps.
62
+ - **Every limits door refuses unevaluable values (`config.limit_invalid`), fourth wave of the no-silent-folding rule:** `runTeamDiscussion`'s cumulative `maxTokens`/`maxCostUsd` (NaN silently disarmed the only cumulative stop; refused before any dispatch, 0 stays an honest exhausted window); workflow governance's TRUSTED half (a baseline/`WorkflowChildCaps` NaN/Infinity read as the 0/negative "no ceiling" sentinel and could silently drop an axis — refused loudly, sentinel semantics unchanged); `TaskSpec.resourceSuspend.{totalBudgetUsd,totalTokens,maxSlices,ttlMs}` + non-empty `scope` (NaN blinded even the validated per-slice window through `Math.min`) — refused at the RB-458 door as a failed result before any resource acquisition.
63
+ - **Agent completed card vocabulary additions** (closed-set consumers admit members): `retryAfterMs` (machine-actionable pair of `errorCode` — `retryable` answers "re-issue as-is NOW?", `retryAfterMs` answers "when does the refusal lift"), `degraded` (reduced-quality attribution), and `modelFallback:"inherit_no_tier_binding"` (the requested model word did not bind — no roster / not on the roster — and the child ran on its inherited default; fallback behavior itself unchanged; also stamped on background roster rows). Workflow `agent_end` gains `modelResolved` (the served model, from `TaskResult.model`; `agent_start.model` stays the requested word verbatim — absent on replayed rows and model-less runs).
64
+ - **Two runtime prose strings changed with the retired-scenario vocabulary sweep** (an A2A fence reason and two prompt lines); probes byte-pinning them red. CC-anchored verbatim material (loop sentinels, prompt sections) is exempt by an explicit per-file allowance in the new vocabulary ratchet gate.
65
+
3
66
  ## 5.9.0 (2026-08-04)
4
67
 
5
68
  ### Added
@@ -5,6 +5,7 @@ export interface RosterEntry {
5
5
  toolUseId?: string;
6
6
  rootSessionId?: string;
7
7
  model?: string;
8
+ modelFallback?: "inherit_no_tier_binding";
8
9
  owner: string;
9
10
  scope: string;
10
11
  sessionScoped?: boolean;
@@ -528,6 +528,12 @@ export function createSendMessageTool(opts) {
528
528
  ...(row.createdAt !== undefined ? { rowSpawnedAt: row.createdAt } : {}),
529
529
  ...(opts.onNotifyError !== undefined ? { onNotifyError: opts.onNotifyError } : {}),
530
530
  ...(opts.notify ? { currentParentNotify: opts.notify } : {}),
531
+ ...(ctx.onQuestion !== undefined && row.sessionScoped !== true ? { currentOnQuestion: ctx.onQuestion } : {}),
532
+ currentClamps: {
533
+ ...(ctx.handsReadOnly === true ? { handsReadOnly: true } : {}),
534
+ ...(ctx.interactiveTools === false ? { interactiveTools: false } : {}),
535
+ ...(ctx.oneShot === true ? { oneShot: true } : {}),
536
+ },
531
537
  });
532
538
  const fromPrefix = senderIsChild ? `(message from teammate "${senderLabel}")\n` : "";
533
539
  try {
@@ -34,7 +34,7 @@ export interface SubagentWorktreeIsolation {
34
34
  }
35
35
  export declare function createSubagentWorktreeHelper(baseEnv: ExecutionEnv, repoRoot: string): SubagentWorktreeIsolation;
36
36
  export declare const REPORT_FIELD_MAX = 300;
37
- export type SubagentErrorKind = "rate_limit" | "overloaded" | "timeout" | "network" | "logic";
37
+ export type SubagentErrorKind = "rate_limit" | "overloaded" | "timeout" | "network" | "logic" | "governance";
38
38
  export declare function classifySubagentError(child: {
39
39
  status: string;
40
40
  errorCode?: string;
@@ -43,6 +43,30 @@ export declare function classifySubagentError(child: {
43
43
  errorKind: SubagentErrorKind;
44
44
  retryable: boolean;
45
45
  } | undefined;
46
+ export declare function completedAgentCard(child: {
47
+ taskId: string;
48
+ sessionId: string;
49
+ status: string;
50
+ model?: string;
51
+ result: string;
52
+ salvagedOutput?: string;
53
+ blockedReason?: string;
54
+ errorMessage?: string;
55
+ errorCode?: string;
56
+ retryAfterMs?: number;
57
+ degraded?: unknown;
58
+ structuredOutput?: unknown;
59
+ stats: unknown;
60
+ }, extras: {
61
+ subagentType?: string;
62
+ error?: {
63
+ errorKind: SubagentErrorKind;
64
+ retryable: boolean;
65
+ } | undefined;
66
+ worktreePath?: string;
67
+ toolStats?: SubagentToolStats;
68
+ modelFallback?: "inherit_no_tier_binding";
69
+ }): Record<string, unknown>;
46
70
  export declare const SUBAGENT_SUSPENDED_AWAITING_APPROVAL = "suspended.awaiting_approval";
47
71
  export declare const SUBAGENT_SUSPENDED_NEEDS_REVIEW = "suspended.needs_review";
48
72
  export declare const SUBAGENT_SUSPENDED_CHECKPOINT_EXPIRED = "suspended.checkpoint_expired";
@@ -66,6 +90,12 @@ export declare function createSubagentResume(deps: {
66
90
  currentParentNotify?: (n: TaskNotificationPayload, opts?: {
67
91
  priority?: "now" | "next" | "later";
68
92
  }) => void;
93
+ currentOnQuestion?: import("../core/ask-question.js").OnQuestion;
94
+ currentClamps?: {
95
+ handsReadOnly?: true;
96
+ interactiveTools?: false;
97
+ oneShot?: true;
98
+ };
69
99
  taskId?: string;
70
100
  taskAccess?: import("../core/task-registry.js").TaskAccess;
71
101
  bgSink?: (event: import("../core/types.js").BackgroundChildEvent) => void;
@@ -229,8 +229,33 @@ export function classifySubagentError(child) {
229
229
  ? "timeout"
230
230
  : code === "network" || code === "server"
231
231
  ? "network"
232
- : "logic";
233
- return { errorKind, retryable: errorKind !== "logic" };
232
+ : code !== undefined && code.startsWith("usage.")
233
+ ? "governance"
234
+ : "logic";
235
+ return { errorKind, retryable: errorKind !== "logic" && errorKind !== "governance" };
236
+ }
237
+ export function completedAgentCard(child, extras) {
238
+ return {
239
+ type: "agent",
240
+ ...(extras.subagentType !== undefined ? { subagent_type: extras.subagentType } : {}),
241
+ status: child.status,
242
+ taskId: child.taskId,
243
+ sessionId: child.sessionId,
244
+ result: child.result,
245
+ ...(child.salvagedOutput !== undefined ? { salvagedOutput: child.salvagedOutput } : {}),
246
+ ...(child.structuredOutput !== undefined ? { structuredOutput: child.structuredOutput } : {}),
247
+ ...(child.blockedReason !== undefined ? { blockedReason: child.blockedReason } : {}),
248
+ ...(child.errorMessage !== undefined ? { errorMessage: child.errorMessage } : {}),
249
+ ...(child.errorCode !== undefined ? { errorCode: child.errorCode } : {}),
250
+ ...(child.retryAfterMs !== undefined ? { retryAfterMs: child.retryAfterMs } : {}),
251
+ ...(child.degraded !== undefined ? { degraded: child.degraded } : {}),
252
+ ...(extras.error ? { error_kind: extras.error.errorKind, retryable: extras.error.retryable } : {}),
253
+ stats: child.stats,
254
+ ...(child.model !== undefined ? { model: child.model, resolvedModel: child.model } : {}),
255
+ ...(extras.modelFallback !== undefined ? { modelFallback: extras.modelFallback } : {}),
256
+ ...(extras.worktreePath !== undefined ? { worktreePath: extras.worktreePath } : {}),
257
+ ...(extras.toolStats !== undefined ? { toolStats: extras.toolStats } : {}),
258
+ };
234
259
  }
235
260
  export const SUBAGENT_SUSPENDED_AWAITING_APPROVAL = "suspended.awaiting_approval";
236
261
  export const SUBAGENT_SUSPENDED_NEEDS_REVIEW = "suspended.needs_review";
@@ -374,6 +399,10 @@ export function createSubagentResume(deps) {
374
399
  objective: createResumePrompt(marker, content),
375
400
  sessionId: entry.childSessionId,
376
401
  requireExistingSession: true,
402
+ ...(deps.currentOnQuestion !== undefined ? { onQuestion: deps.currentOnQuestion } : {}),
403
+ ...(entry.specSnapshot.handsReadOnly === true || deps.currentClamps?.handsReadOnly === true ? { handsReadOnly: true } : {}),
404
+ ...(entry.specSnapshot.interactiveTools === false || deps.currentClamps?.interactiveTools === false ? { interactiveTools: false } : {}),
405
+ ...(entry.specSnapshot.oneShot === true || deps.currentClamps?.oneShot === true ? { oneShot: true } : {}),
377
406
  signal: abort.signal,
378
407
  };
379
408
  if (deps.registry !== undefined && deps.taskId !== undefined && deps.taskAccess !== undefined) {
@@ -880,7 +909,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
880
909
  `- Finding a known symbol/class/definition, or searching within 2-3 known files → use Grep/Glob/Read.\n` +
881
910
  `- A lookup where you already know what you want and roughly where it is → just do it. Delegate open-ended INVESTIGATIONS (you don't know where the answer is), not lookups.\n\n` +
882
911
  `Writing the 'prompt' — brief the agent like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters:\n` +
883
- `- Make it fully self-contained and highly detailed: the sub-agent does the whole task autonomously in one shot — you can't course-correct mid-run.\n` +
912
+ `- Make it fully self-contained and highly detailed: the sub-agent does the whole task end-to-end in one shot — you can't course-correct mid-run.\n` +
884
913
  `- State exactly what it must RETURN (e.g. "report the file:line of each call site", not "look into the call sites").\n` +
885
914
  `- Say explicitly whether you want it to WRITE CODE or only RESEARCH/REPORT — otherwise it may guess wrong.\n` +
886
915
  `- Never delegate understanding. Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.\n\n` +
@@ -1071,6 +1100,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1071
1100
  }
1072
1101
  }
1073
1102
  }
1103
+ const modelFallback = !wantsFork && requestedModel !== undefined && perCallModel === undefined ? "inherit_no_tier_binding" : undefined;
1074
1104
  let worktreeDir;
1075
1105
  if (reviveClaim === undefined && (a.isolation ?? def?.isolation) === "worktree") {
1076
1106
  const iso = ctx.worktreeIsolation;
@@ -1333,11 +1363,17 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1333
1363
  };
1334
1364
  const drainAndReleaseObserver = async () => {
1335
1365
  if (observerPairing) {
1366
+ let drainRescue;
1336
1367
  const timeout = new Promise((resolve) => {
1337
- const t = setTimeout(() => resolve("timeout"), 60_000);
1338
- t.unref?.();
1368
+ drainRescue = setTimeout(() => resolve("timeout"), 60_000);
1339
1369
  });
1340
- const outcome = await Promise.race([observerPairing.drain().then(() => "drained"), timeout]);
1370
+ let outcome;
1371
+ try {
1372
+ outcome = await Promise.race([observerPairing.drain().then(() => "drained"), timeout]);
1373
+ }
1374
+ finally {
1375
+ clearTimeout(drainRescue);
1376
+ }
1341
1377
  observerPairing.retire(outcome === "timeout" ? "stopped" : "retired");
1342
1378
  if (outcome === "timeout")
1343
1379
  noteObserverFailure(new Error("observer unresponsive at settle (delivery drain timed out)"));
@@ -1456,13 +1492,19 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1456
1492
  ...(def?.memory ? { memory: def.memory } : {}),
1457
1493
  ...(def?.skills?.length ? { skills: def.skills } : {}),
1458
1494
  ...(ctx.principal !== undefined ? { principal: ctx.principal } : {}),
1495
+ ...(ctx.checkpointStoreDisabledForChildren === true ? { checkpointStore: null } : {}),
1459
1496
  ...(childOnAsk !== undefined ? { onAsk: childOnAsk } : {}),
1497
+ ...(ctx.onQuestion !== undefined ? { onQuestion: ctx.onQuestion } : {}),
1498
+ ...(ctx.interactiveTools === false ? { interactiveTools: false } : {}),
1499
+ ...(ctx.handsReadOnly === true ? { handsReadOnly: true } : {}),
1500
+ ...(ctx.oneShot === true ? { oneShot: true } : {}),
1460
1501
  ...(ctx.clientContext !== undefined ? { clientContext: ctx.clientContext } : {}),
1461
1502
  ...(ctx.excludeTools !== undefined ? { excludeTools: [...ctx.excludeTools] } : {}),
1462
1503
  ...(ctx.deferTools !== undefined ? { deferTools: [...ctx.deferTools] } : {}),
1463
1504
  ...(ctx.alwaysLoadTools !== undefined ? { alwaysLoadTools: [...ctx.alwaysLoadTools] } : {}),
1464
1505
  ...(ctx.promptProfile !== undefined ? { promptProfile: ctx.promptProfile } : {}),
1465
1506
  ...(ctx.additionalDirectories !== undefined ? { additionalDirectories: [...ctx.additionalDirectories] } : {}),
1507
+ ...(ctx.additionalReadDirectories !== undefined ? { additionalReadDirectories: [...ctx.additionalReadDirectories] } : {}),
1466
1508
  ...(ctx.envFacts !== undefined ? { envFacts: { ...ctx.envFacts } } : {}),
1467
1509
  ...(ctx.getApiKeyAndHeaders !== undefined ? { getApiKeyAndHeaders: ctx.getApiKeyAndHeaders } : {}),
1468
1510
  enableBlockedReport: true,
@@ -1473,7 +1515,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1473
1515
  if (!ledger)
1474
1516
  return undefined;
1475
1517
  const childSessionId = preMintedSessionId ?? uuidv7();
1476
- const { signal: _spawnSignal, ...plainSpec } = buildChildSpec(undefined);
1518
+ const { signal: _spawnSignal, onQuestion: _spawnTurnQuestionFace, ...plainSpec } = buildChildSpec(undefined);
1477
1519
  let entry = ledger.register(ctx.toolCallId, {
1478
1520
  childSessionId,
1479
1521
  runner: opts.runner,
@@ -1553,7 +1595,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1553
1595
  if (ledger !== undefined && !ledger.disposed) {
1554
1596
  if (opts.background)
1555
1597
  ensureSessionReapHook(opts.background.registry);
1556
- const { signal: _spawnSignal, ...plainSpec } = buildChildSpec(undefined);
1598
+ const { signal: _spawnSignal, onQuestion: _spawnTurnQuestionFace, ...plainSpec } = buildChildSpec(undefined);
1557
1599
  const entry = ledger.register(ctx.toolCallId, {
1558
1600
  childSessionId,
1559
1601
  runner: opts.runner,
@@ -1671,6 +1713,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1671
1713
  else if (!sessionScopedBg)
1672
1714
  ctx.signal.addEventListener("abort", onHostAbort, { once: true });
1673
1715
  }
1716
+ const dropTurnBoundQuestionFace = sessionScopedBg ? { onQuestion: undefined } : {};
1674
1717
  const shortDesc = `fork: ${(typeof a.description === "string" && a.description.trim() ? a.description.trim() : prompt).slice(0, 180)}`;
1675
1718
  const bgOwner = sessionScopedBg ? ctx.sessionId : ctx.taskId ?? bg.owner;
1676
1719
  const bgScope = treeScope;
@@ -1735,7 +1778,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1735
1778
  }, { priority: "later" }), "subagent.reapTerminalNotify");
1736
1779
  });
1737
1780
  if (agentName !== undefined) {
1738
- recordRosterSpawn(ctx.roster, { name: agentName, agentId: taskId, sessionId: forkedId, toolUseId: ctx.toolCallId, owner: bgOwner, scope: bgScope, ...((typeof childModel === "string" ? resolveModelDisplayLabel(childModel) : childModel?.id) !== undefined ? { model: typeof childModel === "string" ? childModel : childModel?.id } : {}), ...((ctx.rootSessionId ?? ctx.sessionId) !== undefined ? { rootSessionId: ctx.rootSessionId ?? ctx.sessionId } : {}), ...(sessionScopedBg ? { sessionScoped: true } : {}), createdAt: Date.now() }, (err) => opts.onObserverError?.(err, { site: "roster.recordSpawn" }));
1781
+ recordRosterSpawn(ctx.roster, { name: agentName, agentId: taskId, sessionId: forkedId, toolUseId: ctx.toolCallId, owner: bgOwner, scope: bgScope, ...((typeof childModel === "string" ? resolveModelDisplayLabel(childModel) : childModel?.id) !== undefined ? { model: typeof childModel === "string" ? childModel : childModel?.id } : {}), ...(modelFallback !== undefined ? { modelFallback } : {}), ...((ctx.rootSessionId ?? ctx.sessionId) !== undefined ? { rootSessionId: ctx.rootSessionId ?? ctx.sessionId } : {}), ...(sessionScopedBg ? { sessionScoped: true } : {}), createdAt: Date.now() }, (err) => opts.onObserverError?.(err, { site: "roster.recordSpawn" }));
1739
1782
  }
1740
1783
  const bgSink = ctx.onBackgroundChildEvent;
1741
1784
  const sinkEmit = (event) => {
@@ -1763,7 +1806,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1763
1806
  startedAt: Date.now(),
1764
1807
  });
1765
1808
  const forkBgStartedAt = Date.now();
1766
- const bgForkSpec = { ...buildChildSpec(abort.signal), sessionId: forkedId, requireExistingSession: true, objective: forkObjective };
1809
+ const bgForkSpec = { ...buildChildSpec(abort.signal), sessionId: forkedId, requireExistingSession: true, objective: forkObjective, ...dropTurnBoundQuestionFace };
1767
1810
  const s2ForkNotifyReady = (inject) => {
1768
1811
  bg.registry.attachAgentNotify(taskId, inject);
1769
1812
  };
@@ -2039,16 +2082,15 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2039
2082
  ].filter(Boolean);
2040
2083
  return {
2041
2084
  content: forkLines.join("\n"),
2042
- details: {
2043
- ...forkChild,
2044
- ...(forkErr ? { error_kind: forkErr.errorKind, retryable: forkErr.retryable } : {}),
2085
+ details: completedAgentCard(forkChild, {
2086
+ subagentType: FORK_SUBAGENT_TYPE,
2087
+ error: forkErr,
2045
2088
  ...(worktreeKeptPath !== undefined ? { worktreePath: worktreeKeptPath } : {}),
2046
- ...(forkChild.model !== undefined ? { resolvedModel: forkChild.model } : {}),
2047
2089
  ...(() => {
2048
2090
  const ts = toolStatsCounter.snapshot();
2049
2091
  return ts !== undefined ? { toolStats: ts } : {};
2050
2092
  })(),
2051
- },
2093
+ }),
2052
2094
  };
2053
2095
  }
2054
2096
  if (wantsBackground) {
@@ -2058,12 +2100,13 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2058
2100
  const reviveRow = reviveClaim?.row;
2059
2101
  const parkedResume = reviveClaim?.parkedResume;
2060
2102
  const sessionScopedBg = reviveRow !== undefined ? reviveRow.sessionScoped : ctx.backgroundScope === "session" && ctx.sessionId !== undefined;
2103
+ const outlivesThisRequest = sessionScopedBg || reviveRow !== undefined;
2061
2104
  const onHostAbort = () => abort.abort();
2062
2105
  const dropHostAbortListener = () => ctx.signal?.removeEventListener("abort", onHostAbort);
2063
2106
  if (ctx.signal) {
2064
2107
  if (ctx.signal.aborted)
2065
2108
  abort.abort();
2066
- else if (!sessionScopedBg && reviveRow === undefined)
2109
+ else if (!outlivesThisRequest)
2067
2110
  ctx.signal.addEventListener("abort", onHostAbort, { once: true });
2068
2111
  }
2069
2112
  const shortDesc = reviveRow?.description ?? String(a.description ?? "sub-agent").slice(0, 200);
@@ -2229,6 +2272,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2229
2272
  sessionId: bgChildSessionId,
2230
2273
  ...(forwardDurableApproval ? { durableApproval: { ...ctx.durableApprovalForChildren } } : {}),
2231
2274
  ...(reviveRow !== undefined ? { requireExistingSession: true } : {}),
2275
+ ...(outlivesThisRequest ? { onQuestion: undefined } : {}),
2232
2276
  };
2233
2277
  let reviveAttachedResolve;
2234
2278
  const reviveAttached = reviveRow !== undefined ? new Promise((r) => (reviveAttachedResolve = r)) : undefined;
@@ -2526,6 +2570,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2526
2570
  ...(!ok ? { error: reaped ? (collateral ? BG_AGENT_COLLATERAL_REAP_REASON : BG_AGENT_REAP_STOP_ERROR) : unparkedPauseReason ?? child.errorMessage ?? String(child.status) } : {}),
2527
2571
  ...(errCodeBg !== undefined ? { errorCode: errCodeBg } : {}),
2528
2572
  ...(errClassBg !== undefined ? { retryable: errClassBg.retryable, errorKind: errClassBg.errorKind } : {}),
2573
+ ...(failedBg && child.retryAfterMs !== undefined ? { retryAfterMs: child.retryAfterMs } : {}),
2529
2574
  }) ??
2530
2575
  (abort.signal.aborted ? "killed" : ok ? "completed" : "failed");
2531
2576
  if (!bgRetain && reviveRow === undefined && !(await bgRowConfirmed())) {
@@ -2729,6 +2774,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2729
2774
  sink: ctx.onSubagentSpawn,
2730
2775
  ...(opts.background ? { registry: opts.background.registry } : {}),
2731
2776
  ...(opts.onObserverError !== undefined ? { onNotifyError: (f) => opts.onObserverError?.(f.error, { site: f.site }) } : {}),
2777
+ ...(ctx.onQuestion !== undefined ? { currentOnQuestion: ctx.onQuestion } : {}),
2732
2778
  });
2733
2779
  notifier.notify(() => ctx.onSubagentSpawn?.(createSteerHandle(stream, ctx.toolCallId, childAgentName, settled, {
2734
2780
  resume,
@@ -2854,16 +2900,16 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2854
2900
  ].filter(Boolean);
2855
2901
  return {
2856
2902
  content: lines.join("\n"),
2857
- details: {
2858
- ...child,
2859
- ...(errClass ? { error_kind: errClass.errorKind, retryable: errClass.retryable } : {}),
2903
+ details: completedAgentCard(child, {
2904
+ ...(def !== undefined ? { subagentType: def.name } : {}),
2905
+ error: errClass,
2906
+ ...(modelFallback !== undefined ? { modelFallback } : {}),
2860
2907
  ...(worktreeKeptPath !== undefined ? { worktreePath: worktreeKeptPath } : {}),
2861
- ...(child.model !== undefined ? { resolvedModel: child.model } : {}),
2862
2908
  ...(() => {
2863
2909
  const ts = toolStatsCounter.snapshot();
2864
2910
  return ts !== undefined ? { toolStats: ts } : {};
2865
2911
  })(),
2866
- },
2912
+ }),
2867
2913
  };
2868
2914
  },
2869
2915
  };
@@ -174,9 +174,21 @@ async function runTeacherCore(runner, studentSpec, teacher) {
174
174
  };
175
175
  };
176
176
  const tools = studentSpec.tools?.map(wrap);
177
+ const inheritedDeploymentConfig = {
178
+ ...(studentSpec.getApiKeyAndHeaders !== undefined ? { getApiKeyAndHeaders: studentSpec.getApiKeyAndHeaders } : {}),
179
+ ...(studentSpec.principal !== undefined ? { principal: studentSpec.principal } : {}),
180
+ ...(studentSpec.clientContext !== undefined ? { clientContext: studentSpec.clientContext } : {}),
181
+ ...(studentSpec.promptProfile !== undefined ? { promptProfile: studentSpec.promptProfile } : {}),
182
+ ...(studentSpec.handsReadOnly === true ? { handsReadOnly: true } : {}),
183
+ ...(studentSpec.interactiveTools === false ? { interactiveTools: false } : {}),
184
+ ...(studentSpec.excludeTools !== undefined ? { excludeTools: [...studentSpec.excludeTools] } : {}),
185
+ ...(studentSpec.deferTools !== undefined ? { deferTools: [...studentSpec.deferTools] } : {}),
186
+ ...(studentSpec.alwaysLoadTools !== undefined ? { alwaysLoadTools: [...studentSpec.alwaysLoadTools] } : {}),
187
+ ...(studentSpec.checkpointStore === null ? { checkpointStore: null } : {}),
188
+ };
177
189
  const helperBase = () => teacher.helperModel
178
- ? { model: teacher.helperModel }
179
- : { model: studentSpec.model, modelRole: studentSpec.modelRole, roles: studentSpec.roles };
190
+ ? { ...inheritedDeploymentConfig, model: teacher.helperModel }
191
+ : { ...inheritedDeploymentConfig, model: studentSpec.model, modelRole: studentSpec.modelRole, roles: studentSpec.roles };
180
192
  const teacherModelFields = () => {
181
193
  if (teacher.model) {
182
194
  return { model: teacher.model };
@@ -317,7 +329,6 @@ async function runTeacherCore(runner, studentSpec, teacher) {
317
329
  let timer;
318
330
  const promise = new Promise((res) => {
319
331
  timer = setTimeout(() => res(syntheticAborted(sid)), 10_000);
320
- timer.unref?.();
321
332
  });
322
333
  return { promise, cancel: () => { if (timer)
323
334
  clearTimeout(timer); } };
@@ -338,6 +349,7 @@ async function runTeacherCore(runner, studentSpec, teacher) {
338
349
  const askTeacher = async (trace) => {
339
350
  const r = await runner.runTask({
340
351
  objective: trace,
352
+ ...inheritedDeploymentConfig,
341
353
  ...teacherModelFields(),
342
354
  systemPrompt: teacher.prompts?.teacher ?? TEACHER_PROMPT,
343
355
  enableBlockedReport: false,
@@ -51,6 +51,16 @@ export async function runTeamDiscussion(opts) {
51
51
  let turns = 0;
52
52
  let costMicroUsd = 0;
53
53
  let failures = 0;
54
+ for (const key of ["maxTokens", "maxCostUsd"]) {
55
+ const value = opts.limits?.[key];
56
+ if (value === undefined)
57
+ continue;
58
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
59
+ const e = new Error(`TeamDiscussionOptions.limits.${key} must be a finite, non-negative number (got ${String(value)}) — the team's cumulative budget gate cannot evaluate it, and running without the gate would spend under a limit nobody chose.`);
60
+ e.code = "config.limit_invalid";
61
+ throw e;
62
+ }
63
+ }
54
64
  const budgetMaxTokens = opts.limits?.maxTokens;
55
65
  const budgetMaxCostMicroUsd = opts.limits?.maxCostUsd !== undefined ? Math.round(opts.limits.maxCostUsd * 1_000_000) : undefined;
56
66
  let budgetStop;
@@ -99,10 +99,17 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
99
99
  roles: specBase.roles,
100
100
  tools: verifierTools,
101
101
  handsReadOnly: config.verifierHandsReadOnly ?? true,
102
+ interactiveTools: false,
102
103
  outputSchema: VerdictSchema,
103
104
  enableBlockedReport: false,
104
105
  limits: { ...(specBase.limits?.maxWalltimeMs !== undefined ? { maxWalltimeMs: specBase.limits.maxWalltimeMs } : {}) },
105
106
  getApiKeyAndHeaders: specBase.getApiKeyAndHeaders,
107
+ ...(specBase.principal !== undefined ? { principal: specBase.principal } : {}),
108
+ ...(specBase.excludeTools !== undefined ? { excludeTools: [...specBase.excludeTools] } : {}),
109
+ ...(specBase.deferTools !== undefined ? { deferTools: [...specBase.deferTools] } : {}),
110
+ ...(specBase.alwaysLoadTools !== undefined ? { alwaysLoadTools: [...specBase.alwaysLoadTools] } : {}),
111
+ ...(specBase.clientContext !== undefined ? { clientContext: { ...specBase.clientContext } } : {}),
112
+ ...(specBase.promptProfile !== undefined ? { promptProfile: specBase.promptProfile } : {}),
106
113
  signal: specBase.signal,
107
114
  });
108
115
  try {
@@ -516,6 +516,7 @@ export function createAnthropicBrain(config = {}) {
516
516
  const finalContent = [];
517
517
  const toolCalls = [];
518
518
  const malformed = [];
519
+ const unnamed = [];
519
520
  let anyText = false;
520
521
  let reasoningSeen = false;
521
522
  for (const [, acc] of [...blocks.entries()].sort((a, b) => a[0] - b[0])) {
@@ -537,28 +538,44 @@ export function createAnthropicBrain(config = {}) {
537
538
  anyText = true;
538
539
  }
539
540
  }
540
- else if (acc.type === "tool_use" && acc.toolName) {
541
- const tc = closeToolUseBlock(acc);
542
- if (tc) {
543
- toolCalls.push(tc);
544
- finalContent.push(tc);
541
+ else if (acc.type === "tool_use") {
542
+ if (!acc.toolName) {
543
+ unnamed.push(`id="${acc.toolId || "?"}"(${acc.toolJson.slice(0, 200)})`);
545
544
  }
546
545
  else {
547
- malformed.push(`${acc.toolName}(${acc.toolJson.slice(0, 200)})`);
546
+ const tc = closeToolUseBlock(acc);
547
+ if (tc) {
548
+ toolCalls.push(tc);
549
+ finalContent.push(tc);
550
+ }
551
+ else {
552
+ malformed.push(`${acc.toolName}(${acc.toolJson.slice(0, 200)})`);
553
+ }
548
554
  }
549
555
  }
550
556
  }
551
557
  const doneReason = mapStopReason(stopReason, toolCalls.length > 0);
552
558
  const noUsableContent = toolCalls.length === 0 && !anyText;
553
- const toolError = malformed.length > 0
554
- ? `tool call argument(s) not valid JSON (likely truncated, stop_reason="${stopReason ?? "?"}"): ${malformed.join("; ")}`
555
- : undefined;
556
- if (toolError !== undefined) {
559
+ const toolErrorParts = [];
560
+ if (malformed.length > 0) {
561
+ toolErrorParts.push(`tool call argument(s) not valid JSON (likely truncated, stop_reason="${stopReason ?? "?"}"${sentMaxTokens !== undefined ? `; request output cap in effect: ${sentMaxTokens} tokens — a cut mid-arguments commonly means the cap was hit` : ""}): ${malformed.join("; ")}`);
562
+ }
563
+ if (unnamed.length > 0) {
564
+ toolErrorParts.push(`tool call(s) arrived with no tool name and cannot be executed (stop_reason="${stopReason ?? "?"}"): ${unnamed.join("; ")}`);
565
+ }
566
+ const toolError = toolErrorParts.length > 0 ? toolErrorParts.join(" | ") : undefined;
567
+ if (malformed.length > 0) {
557
568
  finalContent.push({
558
569
  type: "text",
559
570
  text: `\n[note: ${malformed.length} tool call(s) were truncated (stop_reason="${stopReason ?? "?"}") and dropped — re-issue them next turn: ${malformed.join("; ")}]`,
560
571
  });
561
572
  }
573
+ if (unnamed.length > 0) {
574
+ finalContent.push({
575
+ type: "text",
576
+ text: `\n[note: ${unnamed.length} tool call(s) arrived with no tool name and were dropped — re-issue them next turn with an explicit tool name: ${unnamed.join("; ")}]`,
577
+ });
578
+ }
562
579
  if (malformedFrames > 0) {
563
580
  finalContent.push({
564
581
  type: "text",
@@ -599,6 +599,7 @@ export function createOpenResponsesBrain(config = {}) {
599
599
  const finalContent = [];
600
600
  const toolCalls = [];
601
601
  const malformed = [];
602
+ const unnamed = [];
602
603
  let anyText = false;
603
604
  let reasoningSeen = false;
604
605
  for (const [, acc] of [...items.entries()].sort((a, b) => a[0] - b[0])) {
@@ -629,19 +630,33 @@ export function createOpenResponsesBrain(config = {}) {
629
630
  else if (acc.name) {
630
631
  malformed.push(`${acc.name}(${acc.args.slice(0, MALFORMED_ARGS_CHARS)})`);
631
632
  }
633
+ else {
634
+ unnamed.push(`call_id="${acc.callId || "?"}"(${acc.args.slice(0, MALFORMED_ARGS_CHARS)})`);
635
+ }
632
636
  }
633
637
  }
634
638
  const noUsableContent = toolCalls.length === 0 && !anyText;
635
639
  const terminalLabel = terminalStatus ?? "?";
636
- const toolError = malformed.length > 0
637
- ? `tool call argument(s) not valid JSON (likely truncated, status="${terminalLabel}"): ${malformed.join("; ")}`
638
- : undefined;
639
- if (toolError !== undefined) {
640
+ const toolErrorParts = [];
641
+ if (malformed.length > 0) {
642
+ toolErrorParts.push(`tool call argument(s) not valid JSON (likely truncated, status="${terminalLabel}"): ${malformed.join("; ")}`);
643
+ }
644
+ if (unnamed.length > 0) {
645
+ toolErrorParts.push(`tool call(s) arrived with no tool name and cannot be executed (status="${terminalLabel}"): ${unnamed.join("; ")}`);
646
+ }
647
+ const toolError = toolErrorParts.length > 0 ? toolErrorParts.join(" | ") : undefined;
648
+ if (malformed.length > 0) {
640
649
  finalContent.push({
641
650
  type: "text",
642
651
  text: `\n[note: ${malformed.length} tool call(s) were truncated (status="${terminalLabel}") and dropped — re-issue them next turn: ${malformed.join("; ")}]`,
643
652
  });
644
653
  }
654
+ if (unnamed.length > 0) {
655
+ finalContent.push({
656
+ type: "text",
657
+ text: `\n[note: ${unnamed.length} tool call(s) arrived with no tool name and were dropped — re-issue them next turn with an explicit tool name: ${unnamed.join("; ")}]`,
658
+ });
659
+ }
645
660
  if (malformedFrames > 0) {
646
661
  finalContent.push({
647
662
  type: "text",