@sema-agent/core 5.9.0 → 5.10.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 (43) hide show
  1. package/CHANGELOG.md +40 -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 +30 -0
  5. package/dist/agents/subagent.js +61 -19
  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/memory-recall.js +8 -3
  14. package/dist/core/memory.d.ts +5 -0
  15. package/dist/core/memory.js +6 -4
  16. package/dist/core/runner/prepare-task.d.ts +8 -1
  17. package/dist/core/runner/prepare-task.js +45 -11
  18. package/dist/core/runner/runtask.d.ts +12 -0
  19. package/dist/core/runner/runtask.js +127 -22
  20. package/dist/core/runner/session-file-state-replay.d.ts +7 -0
  21. package/dist/core/runner/session-file-state-replay.js +56 -0
  22. package/dist/core/runner/synthetic-tools.js +1 -1
  23. package/dist/core/runner/tool-output-projection.js +5 -4
  24. package/dist/core/session-reconcile.d.ts +7 -3
  25. package/dist/core/session-reconcile.js +3 -2
  26. package/dist/core/strategy-store.d.ts +1 -1
  27. package/dist/core/strategy-store.js +27 -4
  28. package/dist/core/tools.js +9 -1
  29. package/dist/core/types.d.ts +5 -1
  30. package/dist/engine/loop/agent-loop.js +168 -22
  31. package/dist/orchestration/run-workflow-tool.js +1 -1
  32. package/dist/orchestration/workflow-governance.js +19 -0
  33. package/dist/orchestration/workflow-primitives.d.ts +1 -1
  34. package/dist/orchestration/workflow-primitives.js +4 -1
  35. package/dist/orchestration/workflow.js +1 -1
  36. package/dist/prompts/coordinator.d.ts +1 -1
  37. package/dist/prompts/coordinator.js +1 -1
  38. package/dist/stores/file/memory-store.js +3 -7
  39. package/dist/tools/fs/fs-bash.js +3 -3
  40. package/dist/tools/fs/fs-shared.d.ts +1 -0
  41. package/dist/tools/fs/fs-shared.js +4 -0
  42. package/dist/tools/web.js +0 -1
  43. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.10.0 — 2026-08-04
4
+
5
+ ### Added
6
+
7
+ - **`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.
8
+
9
+ ### Fixed
10
+
11
+ - **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.
12
+
13
+ - **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).
14
+
15
+ - **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).
16
+
17
+ - **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.
18
+
19
+ - **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.
20
+ - **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).
21
+ - **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.
22
+ - **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.
23
+ - **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.
24
+ - **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.
25
+ - **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.
26
+ - **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.
27
+
28
+ ### BREAKING
29
+
30
+ - **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.
31
+
32
+ - **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.
33
+ - **`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`.
34
+
35
+ - **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.**
36
+ - **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.
37
+ - **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.
38
+ - **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.
39
+ - **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.
40
+ - **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).
41
+ - **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.
42
+
3
43
  ## 5.9.0 (2026-08-04)
4
44
 
5
45
  ### 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 {
@@ -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;
@@ -232,6 +232,29 @@ export function classifySubagentError(child) {
232
232
  : "logic";
233
233
  return { errorKind, retryable: errorKind !== "logic" };
234
234
  }
235
+ export function completedAgentCard(child, extras) {
236
+ return {
237
+ type: "agent",
238
+ ...(extras.subagentType !== undefined ? { subagent_type: extras.subagentType } : {}),
239
+ status: child.status,
240
+ taskId: child.taskId,
241
+ sessionId: child.sessionId,
242
+ result: child.result,
243
+ ...(child.salvagedOutput !== undefined ? { salvagedOutput: child.salvagedOutput } : {}),
244
+ ...(child.structuredOutput !== undefined ? { structuredOutput: child.structuredOutput } : {}),
245
+ ...(child.blockedReason !== undefined ? { blockedReason: child.blockedReason } : {}),
246
+ ...(child.errorMessage !== undefined ? { errorMessage: child.errorMessage } : {}),
247
+ ...(child.errorCode !== undefined ? { errorCode: child.errorCode } : {}),
248
+ ...(child.retryAfterMs !== undefined ? { retryAfterMs: child.retryAfterMs } : {}),
249
+ ...(child.degraded !== undefined ? { degraded: child.degraded } : {}),
250
+ ...(extras.error ? { error_kind: extras.error.errorKind, retryable: extras.error.retryable } : {}),
251
+ stats: child.stats,
252
+ ...(child.model !== undefined ? { model: child.model, resolvedModel: child.model } : {}),
253
+ ...(extras.modelFallback !== undefined ? { modelFallback: extras.modelFallback } : {}),
254
+ ...(extras.worktreePath !== undefined ? { worktreePath: extras.worktreePath } : {}),
255
+ ...(extras.toolStats !== undefined ? { toolStats: extras.toolStats } : {}),
256
+ };
257
+ }
235
258
  export const SUBAGENT_SUSPENDED_AWAITING_APPROVAL = "suspended.awaiting_approval";
236
259
  export const SUBAGENT_SUSPENDED_NEEDS_REVIEW = "suspended.needs_review";
237
260
  export const SUBAGENT_SUSPENDED_CHECKPOINT_EXPIRED = "suspended.checkpoint_expired";
@@ -374,6 +397,10 @@ export function createSubagentResume(deps) {
374
397
  objective: createResumePrompt(marker, content),
375
398
  sessionId: entry.childSessionId,
376
399
  requireExistingSession: true,
400
+ ...(deps.currentOnQuestion !== undefined ? { onQuestion: deps.currentOnQuestion } : {}),
401
+ ...(entry.specSnapshot.handsReadOnly === true || deps.currentClamps?.handsReadOnly === true ? { handsReadOnly: true } : {}),
402
+ ...(entry.specSnapshot.interactiveTools === false || deps.currentClamps?.interactiveTools === false ? { interactiveTools: false } : {}),
403
+ ...(entry.specSnapshot.oneShot === true || deps.currentClamps?.oneShot === true ? { oneShot: true } : {}),
377
404
  signal: abort.signal,
378
405
  };
379
406
  if (deps.registry !== undefined && deps.taskId !== undefined && deps.taskAccess !== undefined) {
@@ -880,7 +907,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
880
907
  `- Finding a known symbol/class/definition, or searching within 2-3 known files → use Grep/Glob/Read.\n` +
881
908
  `- 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
909
  `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` +
910
+ `- 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
911
  `- State exactly what it must RETURN (e.g. "report the file:line of each call site", not "look into the call sites").\n` +
885
912
  `- Say explicitly whether you want it to WRITE CODE or only RESEARCH/REPORT — otherwise it may guess wrong.\n` +
886
913
  `- 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 +1098,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1071
1098
  }
1072
1099
  }
1073
1100
  }
1101
+ const modelFallback = !wantsFork && requestedModel !== undefined && perCallModel === undefined ? "inherit_no_tier_binding" : undefined;
1074
1102
  let worktreeDir;
1075
1103
  if (reviveClaim === undefined && (a.isolation ?? def?.isolation) === "worktree") {
1076
1104
  const iso = ctx.worktreeIsolation;
@@ -1333,11 +1361,17 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1333
1361
  };
1334
1362
  const drainAndReleaseObserver = async () => {
1335
1363
  if (observerPairing) {
1364
+ let drainRescue;
1336
1365
  const timeout = new Promise((resolve) => {
1337
- const t = setTimeout(() => resolve("timeout"), 60_000);
1338
- t.unref?.();
1366
+ drainRescue = setTimeout(() => resolve("timeout"), 60_000);
1339
1367
  });
1340
- const outcome = await Promise.race([observerPairing.drain().then(() => "drained"), timeout]);
1368
+ let outcome;
1369
+ try {
1370
+ outcome = await Promise.race([observerPairing.drain().then(() => "drained"), timeout]);
1371
+ }
1372
+ finally {
1373
+ clearTimeout(drainRescue);
1374
+ }
1341
1375
  observerPairing.retire(outcome === "timeout" ? "stopped" : "retired");
1342
1376
  if (outcome === "timeout")
1343
1377
  noteObserverFailure(new Error("observer unresponsive at settle (delivery drain timed out)"));
@@ -1456,7 +1490,12 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1456
1490
  ...(def?.memory ? { memory: def.memory } : {}),
1457
1491
  ...(def?.skills?.length ? { skills: def.skills } : {}),
1458
1492
  ...(ctx.principal !== undefined ? { principal: ctx.principal } : {}),
1493
+ ...(ctx.checkpointStoreDisabledForChildren === true ? { checkpointStore: null } : {}),
1459
1494
  ...(childOnAsk !== undefined ? { onAsk: childOnAsk } : {}),
1495
+ ...(ctx.onQuestion !== undefined ? { onQuestion: ctx.onQuestion } : {}),
1496
+ ...(ctx.interactiveTools === false ? { interactiveTools: false } : {}),
1497
+ ...(ctx.handsReadOnly === true ? { handsReadOnly: true } : {}),
1498
+ ...(ctx.oneShot === true ? { oneShot: true } : {}),
1460
1499
  ...(ctx.clientContext !== undefined ? { clientContext: ctx.clientContext } : {}),
1461
1500
  ...(ctx.excludeTools !== undefined ? { excludeTools: [...ctx.excludeTools] } : {}),
1462
1501
  ...(ctx.deferTools !== undefined ? { deferTools: [...ctx.deferTools] } : {}),
@@ -1473,7 +1512,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1473
1512
  if (!ledger)
1474
1513
  return undefined;
1475
1514
  const childSessionId = preMintedSessionId ?? uuidv7();
1476
- const { signal: _spawnSignal, ...plainSpec } = buildChildSpec(undefined);
1515
+ const { signal: _spawnSignal, onQuestion: _spawnTurnQuestionFace, ...plainSpec } = buildChildSpec(undefined);
1477
1516
  let entry = ledger.register(ctx.toolCallId, {
1478
1517
  childSessionId,
1479
1518
  runner: opts.runner,
@@ -1553,7 +1592,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1553
1592
  if (ledger !== undefined && !ledger.disposed) {
1554
1593
  if (opts.background)
1555
1594
  ensureSessionReapHook(opts.background.registry);
1556
- const { signal: _spawnSignal, ...plainSpec } = buildChildSpec(undefined);
1595
+ const { signal: _spawnSignal, onQuestion: _spawnTurnQuestionFace, ...plainSpec } = buildChildSpec(undefined);
1557
1596
  const entry = ledger.register(ctx.toolCallId, {
1558
1597
  childSessionId,
1559
1598
  runner: opts.runner,
@@ -1671,6 +1710,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1671
1710
  else if (!sessionScopedBg)
1672
1711
  ctx.signal.addEventListener("abort", onHostAbort, { once: true });
1673
1712
  }
1713
+ const dropTurnBoundQuestionFace = sessionScopedBg ? { onQuestion: undefined } : {};
1674
1714
  const shortDesc = `fork: ${(typeof a.description === "string" && a.description.trim() ? a.description.trim() : prompt).slice(0, 180)}`;
1675
1715
  const bgOwner = sessionScopedBg ? ctx.sessionId : ctx.taskId ?? bg.owner;
1676
1716
  const bgScope = treeScope;
@@ -1735,7 +1775,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1735
1775
  }, { priority: "later" }), "subagent.reapTerminalNotify");
1736
1776
  });
1737
1777
  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" }));
1778
+ 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
1779
  }
1740
1780
  const bgSink = ctx.onBackgroundChildEvent;
1741
1781
  const sinkEmit = (event) => {
@@ -1763,7 +1803,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1763
1803
  startedAt: Date.now(),
1764
1804
  });
1765
1805
  const forkBgStartedAt = Date.now();
1766
- const bgForkSpec = { ...buildChildSpec(abort.signal), sessionId: forkedId, requireExistingSession: true, objective: forkObjective };
1806
+ const bgForkSpec = { ...buildChildSpec(abort.signal), sessionId: forkedId, requireExistingSession: true, objective: forkObjective, ...dropTurnBoundQuestionFace };
1767
1807
  const s2ForkNotifyReady = (inject) => {
1768
1808
  bg.registry.attachAgentNotify(taskId, inject);
1769
1809
  };
@@ -2039,16 +2079,15 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2039
2079
  ].filter(Boolean);
2040
2080
  return {
2041
2081
  content: forkLines.join("\n"),
2042
- details: {
2043
- ...forkChild,
2044
- ...(forkErr ? { error_kind: forkErr.errorKind, retryable: forkErr.retryable } : {}),
2082
+ details: completedAgentCard(forkChild, {
2083
+ subagentType: FORK_SUBAGENT_TYPE,
2084
+ error: forkErr,
2045
2085
  ...(worktreeKeptPath !== undefined ? { worktreePath: worktreeKeptPath } : {}),
2046
- ...(forkChild.model !== undefined ? { resolvedModel: forkChild.model } : {}),
2047
2086
  ...(() => {
2048
2087
  const ts = toolStatsCounter.snapshot();
2049
2088
  return ts !== undefined ? { toolStats: ts } : {};
2050
2089
  })(),
2051
- },
2090
+ }),
2052
2091
  };
2053
2092
  }
2054
2093
  if (wantsBackground) {
@@ -2058,12 +2097,13 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2058
2097
  const reviveRow = reviveClaim?.row;
2059
2098
  const parkedResume = reviveClaim?.parkedResume;
2060
2099
  const sessionScopedBg = reviveRow !== undefined ? reviveRow.sessionScoped : ctx.backgroundScope === "session" && ctx.sessionId !== undefined;
2100
+ const outlivesThisRequest = sessionScopedBg || reviveRow !== undefined;
2061
2101
  const onHostAbort = () => abort.abort();
2062
2102
  const dropHostAbortListener = () => ctx.signal?.removeEventListener("abort", onHostAbort);
2063
2103
  if (ctx.signal) {
2064
2104
  if (ctx.signal.aborted)
2065
2105
  abort.abort();
2066
- else if (!sessionScopedBg && reviveRow === undefined)
2106
+ else if (!outlivesThisRequest)
2067
2107
  ctx.signal.addEventListener("abort", onHostAbort, { once: true });
2068
2108
  }
2069
2109
  const shortDesc = reviveRow?.description ?? String(a.description ?? "sub-agent").slice(0, 200);
@@ -2229,6 +2269,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2229
2269
  sessionId: bgChildSessionId,
2230
2270
  ...(forwardDurableApproval ? { durableApproval: { ...ctx.durableApprovalForChildren } } : {}),
2231
2271
  ...(reviveRow !== undefined ? { requireExistingSession: true } : {}),
2272
+ ...(outlivesThisRequest ? { onQuestion: undefined } : {}),
2232
2273
  };
2233
2274
  let reviveAttachedResolve;
2234
2275
  const reviveAttached = reviveRow !== undefined ? new Promise((r) => (reviveAttachedResolve = r)) : undefined;
@@ -2729,6 +2770,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2729
2770
  sink: ctx.onSubagentSpawn,
2730
2771
  ...(opts.background ? { registry: opts.background.registry } : {}),
2731
2772
  ...(opts.onObserverError !== undefined ? { onNotifyError: (f) => opts.onObserverError?.(f.error, { site: f.site }) } : {}),
2773
+ ...(ctx.onQuestion !== undefined ? { currentOnQuestion: ctx.onQuestion } : {}),
2732
2774
  });
2733
2775
  notifier.notify(() => ctx.onSubagentSpawn?.(createSteerHandle(stream, ctx.toolCallId, childAgentName, settled, {
2734
2776
  resume,
@@ -2854,16 +2896,16 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2854
2896
  ].filter(Boolean);
2855
2897
  return {
2856
2898
  content: lines.join("\n"),
2857
- details: {
2858
- ...child,
2859
- ...(errClass ? { error_kind: errClass.errorKind, retryable: errClass.retryable } : {}),
2899
+ details: completedAgentCard(child, {
2900
+ ...(def !== undefined ? { subagentType: def.name } : {}),
2901
+ error: errClass,
2902
+ ...(modelFallback !== undefined ? { modelFallback } : {}),
2860
2903
  ...(worktreeKeptPath !== undefined ? { worktreePath: worktreeKeptPath } : {}),
2861
- ...(child.model !== undefined ? { resolvedModel: child.model } : {}),
2862
2904
  ...(() => {
2863
2905
  const ts = toolStatsCounter.snapshot();
2864
2906
  return ts !== undefined ? { toolStats: ts } : {};
2865
2907
  })(),
2866
- },
2908
+ }),
2867
2909
  };
2868
2910
  },
2869
2911
  };
@@ -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 ?? "?"}"): ${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",
@@ -481,9 +481,17 @@ export function createOpenAIBrain(config = {}) {
481
481
  finalContent.push({ type: "text", text: textFace });
482
482
  const toolCalls = [];
483
483
  const malformed = [];
484
+ const unnamed = [];
485
+ let emptyPlaceholders = 0;
484
486
  for (const acc of [...toolAccum.entries()].sort((a, b) => a[0] - b[0]).map((e) => e[1])) {
485
- if (!acc.name)
487
+ if (!acc.name) {
488
+ if (!acc.id && acc.args === "") {
489
+ emptyPlaceholders++;
490
+ continue;
491
+ }
492
+ unnamed.push(`id="${acc.id || "?"}"(${acc.args.slice(0, 200)})`);
486
493
  continue;
494
+ }
487
495
  const tc = closeToolCallAccum(acc);
488
496
  if (tc) {
489
497
  toolCalls.push(tc);
@@ -508,16 +516,35 @@ export function createOpenAIBrain(config = {}) {
508
516
  }
509
517
  }
510
518
  }
511
- const toolError = malformed.length > 0
512
- ? `tool call argument(s) not valid JSON (likely truncated, finish_reason="${finishReason ?? "?"}"): ${malformed.join("; ")}`
513
- : undefined;
519
+ const emptySlots = `<empty slot>${emptyPlaceholders > 1 ? ` ×${emptyPlaceholders}` : ""}`;
520
+ const sawRealAction = toolCalls.length > 0 || malformed.length > 0 || unnamed.length > 0;
521
+ if (emptyPlaceholders > 0 && sawRealAction) {
522
+ unnamed.push(emptySlots);
523
+ }
524
+ else if (!sawRealAction && finishReason === "tool_calls") {
525
+ unnamed.push(emptyPlaceholders > 0 ? emptySlots : "<no tool_call delta arrived on the stream>");
526
+ }
527
+ const toolErrorParts = [];
528
+ if (malformed.length > 0) {
529
+ toolErrorParts.push(`tool call argument(s) not valid JSON (likely truncated, finish_reason="${finishReason ?? "?"}"): ${malformed.join("; ")}`);
530
+ }
531
+ if (unnamed.length > 0) {
532
+ toolErrorParts.push(`tool call(s) arrived with no tool name and cannot be executed (finish_reason="${finishReason ?? "?"}"): ${unnamed.join("; ")}`);
533
+ }
534
+ const toolError = toolErrorParts.length > 0 ? toolErrorParts.join(" | ") : undefined;
514
535
  const noUsableContent = toolCalls.length === 0 && !accumText.trim();
515
- if (toolError !== undefined) {
536
+ if (malformed.length > 0) {
516
537
  finalContent.push({
517
538
  type: "text",
518
539
  text: `\n[note: ${malformed.length} tool call(s) were truncated (finish_reason="${finishReason ?? "?"}") and dropped — re-issue them next turn: ${malformed.join("; ")}]`,
519
540
  });
520
541
  }
542
+ if (unnamed.length > 0) {
543
+ finalContent.push({
544
+ type: "text",
545
+ 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("; ")}]`,
546
+ });
547
+ }
521
548
  if (malformedFrames > 0) {
522
549
  finalContent.push({
523
550
  type: "text",
package/dist/core/a2a.js CHANGED
@@ -23,7 +23,7 @@ const A2A_CARD_DESCRIPTION_MAX_CHARS = 240;
23
23
  const A2A_ID_MAX_CHARS = 160;
24
24
  const A2A_RESULT_BODY_MAX_CHARS = 100_000;
25
25
  const A2A_ERROR_TEXT_MAX_CHARS = 240;
26
- const A2A_RESULT_FENCE_REASON = "the peer is an autonomous agent, so its output is worker text, not tool data";
26
+ const A2A_RESULT_FENCE_REASON = "the peer is an agent acting on its own, so its output is worker text, not tool data";
27
27
  const A2A_CARD_PATHS = ["/.well-known/agent-card.json", "/.well-known/agent.json"];
28
28
  const A2A_PROTOCOL_VERSION = "1.0";
29
29
  const A2A_JSONRPC_TRANSPORT = "JSONRPC";