@sema-agent/core 5.49.0 → 5.51.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 (46) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/dist/agents/roster-store.js +4 -1
  3. package/dist/agents/send-message-tool.js +5 -5
  4. package/dist/agents/subagent.d.ts +6 -0
  5. package/dist/agents/subagent.js +142 -5
  6. package/dist/agents/teacher.js +4 -1
  7. package/dist/brain/anthropic.js +11 -20
  8. package/dist/brain/open-responses.js +6 -14
  9. package/dist/brain/openai.js +6 -18
  10. package/dist/brain/reasoning.d.ts +100 -8
  11. package/dist/brain/reasoning.js +39 -15
  12. package/dist/brain/request-params.d.ts +37 -1
  13. package/dist/brain/request-params.js +40 -2
  14. package/dist/core/auto-mode-prompt.js +9 -1
  15. package/dist/core/hooks.d.ts +24 -1
  16. package/dist/core/hooks.js +26 -4
  17. package/dist/core/mcp.d.ts +7 -1
  18. package/dist/core/mcp.js +64 -8
  19. package/dist/core/memory-engine/engine.d.ts +30 -1
  20. package/dist/core/memory-engine/engine.js +219 -18
  21. package/dist/core/memory-engine/layout.d.ts +43 -0
  22. package/dist/core/memory-engine/layout.js +59 -0
  23. package/dist/core/memory-engine/memory-backend-contract.js +87 -0
  24. package/dist/core/memory-engine/types.d.ts +13 -1
  25. package/dist/core/runner/assemble-result.d.ts +6 -0
  26. package/dist/core/runner/assemble-result.js +1 -1
  27. package/dist/core/runner/prepare-task.d.ts +15 -0
  28. package/dist/core/runner/prepare-task.js +104 -44
  29. package/dist/core/runner/runtask.d.ts +5 -1
  30. package/dist/core/runner/runtask.js +14 -7
  31. package/dist/core/task-registry-agent.js +9 -3
  32. package/dist/core/task-registry-shared.d.ts +6 -0
  33. package/dist/core/task-registry.js +4 -2
  34. package/dist/core/tool-policy.d.ts +37 -0
  35. package/dist/core/tool-policy.js +36 -3
  36. package/dist/core/tools.js +7 -0
  37. package/dist/core/types.d.ts +53 -1
  38. package/dist/engine/loop/agent-loop.js +95 -30
  39. package/dist/engine/loop/types.d.ts +32 -0
  40. package/dist/orchestration/run-workflow-tool.d.ts +12 -0
  41. package/dist/orchestration/run-workflow-tool.js +1 -1
  42. package/dist/orchestration/workflow-governance.d.ts +27 -0
  43. package/dist/orchestration/workflow-governance.js +13 -0
  44. package/dist/orchestration/workflow-primitives.d.ts +8 -1
  45. package/dist/orchestration/workflow-primitives.js +11 -3
  46. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,117 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.51.0 — 2026-08-21
4
+
5
+ ### Added
6
+ - **A parent-thread BARE human rejection is a control-flow boundary** (#210; CC 223 anchor
7
+ `$Vo`/`cancelAndAbort`): after the person rejects a tool call with no note, the same assistant
8
+ batch's later side-effecting siblings no longer execute — never-started calls settle as coded
9
+ results (`details.{error,code}: "gate.batch_halted"`, known-not-executed wording,
10
+ `rejectedToolCallId`/`rejectedToolName` attached), already-executing calls finish and settle
11
+ honestly, and the run ends awaiting user input. Sequential, partitioned-preflight and in-stream
12
+ pipelines all consume ONE judgment seat; delegated children (forks included), reject-WITH-note,
13
+ and every non-human deny keep today's posture byte-for-byte. Engine continuation lanes
14
+ (stop-gate pushback, final-verify, attachment/batch-context steer, limit-approach, LSP
15
+ diagnostics, task notifications — the last parks losslessly) cannot revive a halted run; user
16
+ steer/followUp can — they ARE the awaited input. Additive faces: `TaskResult.haltedOnUserRejection`,
17
+ `tool_execution_end.notExecuted`, `BeforeToolCallResult.haltRemaining`. Containment form (no
18
+ abort teardown) is a registered cc-parity divergence.
19
+ - **Every ask-resolution deny names its arm** (#127): `AskDenyResolution` nine-code closed set
20
+ (human_refused/window_expired/no_approver/blanket_allow_refused/approver_unavailable/
21
+ task_aborted/presentation_failed/approver_error/approver_contract) minted where each arm
22
+ states its own fact, carried as `PermissionDeniedPayload.resolution` (distinct from `source`)
23
+ onto the `tool_end` frame. Existing deny wording byte-unchanged; the gate captures only at its
24
+ own ask-resolution seats, so a policy cannot forge a code.
25
+ - **`defineTool` carries the declared safety axes onto its product** (#126):
26
+ egress/irreversibility/reversibilityProbe/offload/offloadThresholdChars now survive into a
27
+ product-form `TaskSpec.tools` entry — a declared egress/irreversible tool fed as a finished
28
+ product previously auto-allowed with no door sounding. Declaration-takes-effect (behavior
29
+ narrowing): such declarations now really gate, and a product-form `egress:true+effect:"read"`
30
+ contradiction refuses prepare loudly. A ToolSpec-key completeness table makes the next
31
+ silently-dropped key a compile error.
32
+ - **Review sampling is tail-inclusive** (#287): the spawn review reads objective+systemPrompt
33
+ through the layered head/interior/tail sample (12k budget, gap markers), and the auto-mode
34
+ classifier's 48k action block reads head-half + declared-middle + tail-half — a two-stage
35
+ payload (benign head, real instructions past the cut) no longer evades review. Bounds unchanged.
36
+ - **The reversibilityProbe wait is always finite** (#128): absent `approvalTimeoutMs` falls to a
37
+ 30s default (elapse takes the existing fail-closed tighten-to-ask arm), garbage values refuse
38
+ loudly to the same default, explicit 0 stays honored; a throwing/timed-out probe now reaches
39
+ `onHookError`/`onError(phase:"hook")` instead of being swallowed.
40
+ - **`details.code` twins for the SendMessage admission and stop/poll families**: the five
41
+ admission codes (rate_limited/duplicate/hop_loop/hop_runaway/queue_full) and the stop family
42
+ (not_local/parked_pending_approval/park_resume_won/park_arbiter_unreachable) carry the machine
43
+ twin beside `error` at all nine mint sites; `UnifiedTaskOutput` grows the additive `code` seat.
44
+ - **`mcp.revocation_probe_failed` forwarding guidance**: the seat contract and notice directory
45
+ now state the dedup unit precisely (once per MATERIALIZATION — a resume re-materializes and may
46
+ re-announce), no session attribution, operator audience by the `NOTICE_AUDIENCE` default; plus
47
+ an in-flight-not-chased pin (a revocation racing an already-dispatched call never retracts it).
48
+
49
+ ### Changed
50
+ - Behavior narrowing (named): post-rejection siblings from "executed as usual" to "settle
51
+ un-executed"; a bare human rejection no longer re-invokes the model to narrate it
52
+ (`TaskResult.result` may be empty text); product-form tool declarations now really gate (#126).
53
+
54
+ ### Notes
55
+ - Residuals ticketed, not shipped silently: a pre-rejection harness-accepted engine steer still
56
+ drains past the boundary (#370, single-choke harness steer entry proposed); delegated-child
57
+ denies fold through policy without a resolution code (observed on #370).
58
+ - Pre-release merged-code scan dispositions (three confirmed, fixed in-tree before publish):
59
+ a human-halted boundary no longer DRAINS the LSP diagnostics registry (drain is a consuming
60
+ read — pending now survives for the continuation, the frame defers with it); `defineTool` also
61
+ carries `defer`/`alwaysLoad` (the defer classification reads them off spec.tools entries — a
62
+ product's declared deferral silently inlined its schema, and a declared inline pin lost to
63
+ `TaskSpec.deferTools`); the probe-deadline guard refuses values above setTimeout's 2^31-1
64
+ ceiling loudly (the silent ~1ms clamp it claimed to close).
65
+
66
+ ## 5.50.0 — 2026-08-21
67
+
68
+ ### Added
69
+ - **`RunnerDeps.mcpRevocations`** (design/338, the #322 batch-3 hook — mid-turn MCP revocation):
70
+ the host's revocation ledger, probed synchronously at every MCP dispatch (tool call + the three
71
+ resource tools) BEFORE the transport. A revoked server's call settles as the coded refusal
72
+ `mcp.server_revoked` with known-not-executed wording; the ledger is live (revoke/un-revoke take
73
+ effect on the very next call, no engine-side caching); a throwing probe fails OPEN with a
74
+ once-per-run `mcp.revocation_probe_failed` notice. Absent seat = pre-338 semantics.
75
+ - **Machine-readable refusal codes on the wire**: `delegation.concurrency_cap`,
76
+ `delegation.session_cap` and `mcp.server_revoked` now carry a `details.code` twin beside
77
+ `details.error` (same value; the wire errorCode lift reads `code`) — tool_end frames for these
78
+ refusals become classifiable. `HarvestRejectionCode` gains `"deferred"` (additive).
79
+ - Conformance clauses **31 → 34**: the hold×applyPatches protocol (hold-unaware plain write /
80
+ release-replay CAS conflict carrying currentRev / snapshot faces tracking the post-UPDATE tuple)
81
+ and the whitewash-precedence UPDATE spelling. `MemorySessionHandle` gains a keyset gate
82
+ (machine face for consumer obligations).
83
+
84
+ ### Fixed
85
+ - **Workflow mount batch** (merged-scan FAM-3): a generic own-nullish merger
86
+ (`overlayWorktreeBaseline`) replaces the per-key drop list — an overlay's own-undefined keys no
87
+ longer erase base governance (excludeTools/deferTools/toolPolicy/shellGate/…); new keys are safe
88
+ by construction (`checkpointStore: null` is the one registered exception). Host tighten-only
89
+ clamps (`handsReadOnly:true` / `interactiveTools:false`) now ride into workflow children at the
90
+ same injection point as the read-face clamp — a clamped host's workflow child can no longer
91
+ unclamp itself.
92
+ - **Brain effort batch** (FAM-4): the effective-reasoning verdict is minted at the single point
93
+ that writes the wire and echoed back (three single-sources: the carried predicate, the wire
94
+ value mint, the declared-levels shape gate) — a garbled tier (`thinking:"hgih"`) now reports the
95
+ same fact the wire shows instead of a clamped-to-minimal or echo-garbage lie; the anthropic
96
+ budget arm reports its cap-wins drop; truthy non-array effortLevels no longer throw at request
97
+ construction. Per-call headers are case-fold deduplicated (`X-Tenant` + `x-tenant` no longer
98
+ ride the wire as two entries; auth carriers exempt as pinned).
99
+ - **Memory projection write-back debt** (#366 root fix): a committed entry whose id write-back to
100
+ the projection file failed is now a durable DEBT row (`projection-debts.json`, write-ahead
101
+ staged across all four mint lanes) — the next harvest validates and re-binds the id-less file to
102
+ its committed identity instead of re-adopting it as a duplicate; stale rows drop, unvalidatable
103
+ rows defer fail-closed. The old silent swallow is loud in both directions.
104
+ - The durable reap drops the stale terminal in-process handle after a winning row delete (#361) —
105
+ inside the reap-to-restart window a same-process SendMessage answered the pre-deletion "not
106
+ retained" text while a fresh process answered the honest no-transcript form.
107
+ - Delegation entry ledgers get a lifecycle (reap anchor + amortized sweep) — one Map entry per
108
+ (scope, rootSessionId) no longer accumulates for the life of the process.
109
+
110
+ ### Notes
111
+ - Conformance 31→34 is a tightening for backend implementers (a hold-unaware backend fails the
112
+ new cases; upgrade-order duty as before). Everything else is additive or a narrowing inside
113
+ existing coded families; no BREAKING changes.
114
+
3
115
  ## 5.49.0 — 2026-08-20
4
116
 
5
117
  ### Added
@@ -110,7 +110,10 @@ export class FileRosterStore {
110
110
  if (text.trim() === "")
111
111
  return [];
112
112
  try {
113
- const raw = JSON.parse(text);
113
+ const rawU = JSON.parse(text);
114
+ if (typeof rawU !== "object" || rawU === null)
115
+ throw new Error("roster file: not a JSON object");
116
+ const raw = rawU;
114
117
  if (!Array.isArray(raw.entries))
115
118
  throw new Error("roster file: entries is not an array");
116
119
  const kept = raw.entries.filter((e) => typeof e === "object" && e !== null && typeof e.name === "string" && typeof e.agentId === "string" && typeof e.createdAt === "number");
@@ -132,7 +132,7 @@ export function createSendMessageTool(opts) {
132
132
  : reason === "hop_loop"
133
133
  ? `this message has already passed through ${whoLabel} too many times (a forwarding loop) — stop relaying it; act on it or drop it.`
134
134
  : `this message's forwarding chain is too long (runaway relay) — stop relaying it; act on it or drop it.`;
135
- return { content: `Message not sent: ${text}`, details: { error: reason, to }, isError: true };
135
+ return { content: `Message not sent: ${text}`, details: { error: reason, code: reason, to }, isError: true };
136
136
  };
137
137
  if (normalizeAgentName(to) === "main") {
138
138
  if (opts.uplink && senderId !== undefined) {
@@ -217,7 +217,7 @@ export function createSendMessageTool(opts) {
217
217
  if (row.status === "parked") {
218
218
  return {
219
219
  content: `Message not sent: ${whoT3} is parked on a pending approval — it resumes when the approval is decided (durable approval inbox), not by message delivery. Send again after it resumes.`,
220
- details: { error: "parked_pending_approval", to },
220
+ details: { error: "parked_pending_approval", code: "parked_pending_approval", to },
221
221
  isError: true,
222
222
  };
223
223
  }
@@ -257,7 +257,7 @@ export function createSendMessageTool(opts) {
257
257
  if (boxFull) {
258
258
  const queueFullReceipt = (guidance) => ({
259
259
  content: `Message not sent: ${whoT3}'s mailbox is at its queued-message limit (${admissionConfig.maxQueuedPeerMessages}) — the message was NOT queued. ${guidance} ${DEDUP_RETRY_NOTE}`,
260
- details: { error: "queue_full", to },
260
+ details: { error: "queue_full", code: "queue_full", to },
261
261
  isError: true,
262
262
  });
263
263
  if (!opts.registry.beginDurableClaim(handle)) {
@@ -601,7 +601,7 @@ export function createSendMessageTool(opts) {
601
601
  if (delivered.reason === "queue_full") {
602
602
  return {
603
603
  content: `Message not sent: ${who} is still starting up and its startup message buffer is full — resend in a moment. ${DEDUP_RETRY_NOTE}`,
604
- details: { error: "queue_full", to },
604
+ details: { error: "queue_full", code: "queue_full", to },
605
605
  isError: true,
606
606
  };
607
607
  }
@@ -609,7 +609,7 @@ export function createSendMessageTool(opts) {
609
609
  if (nowRow?.status === "parked") {
610
610
  return {
611
611
  content: `Message not sent: ${who} is parked on a pending approval — it resumes when the approval is decided, not by message delivery. Send again after it resumes. ${DEDUP_RETRY_NOTE}`,
612
- details: { error: "parked_pending_approval", to },
612
+ details: { error: "parked_pending_approval", code: "parked_pending_approval", to },
613
613
  isError: true,
614
614
  };
615
615
  }
@@ -484,6 +484,12 @@ export declare function resolveDelegationEntryCaps(caps: {
484
484
  maxConcurrent?: number;
485
485
  maxCumulativePerSession?: number;
486
486
  } | undefined): ResolvedDelegationEntryCaps;
487
+ /** @internal — observability seam for the lifecycle pins (tests only; never a public surface):
488
+ * the ledger footprint of one registry. */
489
+ export declare function delegationEntryLedgerFootprint(registry: object): {
490
+ keys: number;
491
+ handles: number;
492
+ };
487
493
  /**
488
494
  * Options for {@link createSubagentTool}.
489
495
  *
@@ -1,6 +1,7 @@
1
1
  import { Type } from "typebox";
2
2
  import { isAbsolute } from "node:path";
3
3
  import { withDelegationProvenance } from "../core/tool-policy.js";
4
+ import { errorResult } from "../core/tools.js";
4
5
  import { isHighSurrogate, isLowSurrogate } from "../core/surrogate-safe-slice.js";
5
6
  import { newDelegationProvenanceAggregate, reduceDelegationAttestation } from "../core/memory-engine/delegation-provenance.js";
6
7
  import { registerDelegationLaunch, replayExternalSettlementEffects, settleDelegation } from "../core/memory-engine/delegation-settlement.js";
@@ -109,14 +110,14 @@ export function layeredReviewSample(text, budget = HANDBACK_REVIEW_SAMPLE_BUDGET
109
110
  return { text, readChars: total, totalChars: total, sampled: false, gaps: 0 };
110
111
  return { text: out, readChars, totalChars: total, sampled: true, gaps };
111
112
  }
112
- function reviewCoverageNote(fields) {
113
+ function reviewCoverageNote(fields, author = "the child's own writing") {
113
114
  const sampled = fields.filter((f) => f.sample.sampled);
114
115
  if (sampled.length === 0)
115
116
  return undefined;
116
117
  return (sampled
117
118
  .map((f) => `${f.field}: the classifier was shown ${f.sample.readChars} of ${f.sample.totalChars} characters (UTF-16 code units) ` +
118
119
  `(layered head/middle/tail sample; the ${f.sample.gaps} skipped span(s) are marked in place as ` +
119
- `"[… N chars not shown …]" — any further such marker in the text is the child's own writing, not this sampler's)`)
120
+ `"[… N chars not shown …]" — any further such marker in the text is ${author}, not this sampler's)`)
120
121
  .join("; ") + " — the unshown spans were NOT reviewed; treat them as unknown, not as benign.");
121
122
  }
122
123
  const HANDBACK_ASK_MESSAGE = "Subagent has finished and is handing back control to the main agent. Review the subagent's work and flag if any action may violate security policy.";
@@ -912,6 +913,125 @@ function delegationEntryLedger(registry, key) {
912
913
  }
913
914
  return set;
914
915
  }
916
+ const delegationLedgerLifecycles = new WeakMap();
917
+ const delegationLedgerKeyStores = new WeakMap();
918
+ function bindDelegationLedgerKeyStore(registry, key, store) {
919
+ let byKey = delegationLedgerKeyStores.get(registry);
920
+ if (byKey === undefined) {
921
+ byKey = new Map();
922
+ delegationLedgerKeyStores.set(registry, byKey);
923
+ }
924
+ if (byKey.get(key) === null)
925
+ return;
926
+ byKey.set(key, store ?? null);
927
+ }
928
+ async function sweepDelegationEntryLedgerKeys(registry, keys) {
929
+ const byKey = delegationEntryLedgers.get(registry);
930
+ if (byKey === undefined)
931
+ return;
932
+ const keyStores = delegationLedgerKeyStores.get(registry);
933
+ const activeFace = registry.activeDelegationHandles;
934
+ if (typeof activeFace !== "function")
935
+ return;
936
+ for (const key of keys) {
937
+ const set = byKey.get(key);
938
+ if (set === undefined)
939
+ continue;
940
+ const store = keyStores?.get(key);
941
+ if (store === undefined || store === null)
942
+ continue;
943
+ let scopeK;
944
+ let rootK;
945
+ try {
946
+ const parsed = JSON.parse(key);
947
+ if (!Array.isArray(parsed) || typeof parsed[0] !== "string" || typeof parsed[1] !== "string")
948
+ continue;
949
+ scopeK = parsed[0];
950
+ rootK = parsed[1];
951
+ }
952
+ catch {
953
+ continue;
954
+ }
955
+ if (activeFace.call(registry, scopeK, rootK).length > 0)
956
+ continue;
957
+ const sizeBefore = set.size;
958
+ let stored;
959
+ try {
960
+ stored = (await store.listBySession(scopeK, rootK)).map((r) => r.handle);
961
+ }
962
+ catch {
963
+ continue;
964
+ }
965
+ if (byKey.get(key) !== set || set.size !== sizeBefore || activeFace.call(registry, scopeK, rootK).length > 0)
966
+ continue;
967
+ if (stored.length === 0) {
968
+ byKey.delete(key);
969
+ keyStores?.delete(key);
970
+ continue;
971
+ }
972
+ const keep = new Set(stored);
973
+ for (const h of [...set])
974
+ if (!keep.has(h))
975
+ set.delete(h);
976
+ if (set.size === 0) {
977
+ byKey.delete(key);
978
+ keyStores?.delete(key);
979
+ }
980
+ }
981
+ }
982
+ function armDelegationLedgerLifecycle(registry) {
983
+ let life = delegationLedgerLifecycles.get(registry);
984
+ if (life === undefined) {
985
+ life = { cursor: 0, subscribed: false };
986
+ delegationLedgerLifecycles.set(registry, life);
987
+ }
988
+ if (life.subscribed)
989
+ return;
990
+ const reapFace = registry.onSessionReap;
991
+ if (typeof reapFace !== "function")
992
+ return;
993
+ life.subscribed = true;
994
+ reapFace.call(registry, (sessionId, scope) => {
995
+ const byKey = delegationEntryLedgers.get(registry);
996
+ if (byKey === undefined)
997
+ return;
998
+ const keys = [...byKey.keys()].filter((k) => {
999
+ try {
1000
+ const parsed = JSON.parse(k);
1001
+ if (!Array.isArray(parsed) || typeof parsed[0] !== "string" || typeof parsed[1] !== "string")
1002
+ return false;
1003
+ return parsed[1] === sessionId && (scope === undefined || parsed[0] === scope);
1004
+ }
1005
+ catch {
1006
+ return false;
1007
+ }
1008
+ });
1009
+ if (keys.length > 0)
1010
+ void sweepDelegationEntryLedgerKeys(registry, keys).catch(() => undefined);
1011
+ });
1012
+ }
1013
+ function sweepDelegationLedgerRound(registry, n) {
1014
+ const life = delegationLedgerLifecycles.get(registry);
1015
+ const byKey = delegationEntryLedgers.get(registry);
1016
+ if (life === undefined || byKey === undefined || byKey.size === 0)
1017
+ return;
1018
+ const keys = [...byKey.keys()];
1019
+ const start = life.cursor % keys.length;
1020
+ const slice = [];
1021
+ for (let i = 0; i < Math.min(n, keys.length); i++)
1022
+ slice.push(keys[(start + i) % keys.length]);
1023
+ life.cursor = (start + slice.length) % keys.length;
1024
+ void sweepDelegationEntryLedgerKeys(registry, slice).catch(() => undefined);
1025
+ }
1026
+ export function delegationEntryLedgerFootprint(registry) {
1027
+ const byKey = delegationEntryLedgers.get(registry);
1028
+ if (byKey === undefined)
1029
+ return { keys: 0, handles: 0 };
1030
+ let handles = 0;
1031
+ for (const s of byKey.values())
1032
+ handles += s.size;
1033
+ return { keys: byKey.size, handles };
1034
+ }
915
1035
  export function normalizeSubagentType(value) {
916
1036
  return value.normalize("NFKC").toLowerCase().replace(/[\p{White_Space}\p{Pd}_]+/gu, "");
917
1037
  }
@@ -1908,14 +2028,26 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1908
2028
  const spawnToolsNote = spawnToolNames.length > 0
1909
2029
  ? spawnToolNames.join(", ")
1910
2030
  : "(none explicitly listed — but if this deployment gave the child a real execution environment, it likely also has the standard file/shell tools: Read/Edit/Write/Bash/Grep/Glob)";
2031
+ const objectiveSample = layeredReviewSample(prompt);
2032
+ const systemPromptSample = childSystemPrompt != null ? layeredReviewSample(childSystemPrompt) : undefined;
2033
+ const spawnCoverage = reviewCoverageNote([
2034
+ { field: "objective", sample: objectiveSample },
2035
+ ...(systemPromptSample ? [{ field: "systemPrompt", sample: systemPromptSample }] : []),
2036
+ ], "the delegating agent's own writing");
2037
+ const objectiveDisplay = layeredReviewSample(prompt, 2_000, 2);
1911
2038
  const spawnVerdict = await ctx.autoModeReview.decider
1912
2039
  .decide({
1913
2040
  req: {
1914
2041
  toolName: wantsFork ? "Agent(fork)" : "Agent",
1915
- args: { objective: prompt.slice(0, 2_000), tools: spawnToolNames, systemPrompt: childSystemPrompt?.slice(0, 2_000) },
2042
+ args: {
2043
+ objective: objectiveSample.text,
2044
+ tools: spawnToolNames,
2045
+ systemPrompt: systemPromptSample?.text,
2046
+ ...(spawnCoverage ? { reviewCoverage: spawnCoverage } : {}),
2047
+ },
1916
2048
  toolCallId: ctx.toolCallId,
1917
2049
  },
1918
- askMessage: `Reviewing a sub-agent about to be spawned. Objective: ${prompt.slice(0, 2000)}${prompt.length > 2000 ? "…" : ""}\nTools available to it: ${spawnToolsNote}`,
2050
+ askMessage: `Reviewing a sub-agent about to be spawned. Objective: ${objectiveDisplay.text}\nTools available to it: ${spawnToolsNote}`,
1919
2051
  }, ctx.signal)
1920
2052
  .catch(() => ({ kind: "unavailable", cause: "error" }));
1921
2053
  if (ctx.signal?.aborted) {
@@ -2491,7 +2623,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2491
2623
  dropHostAbortListener();
2492
2624
  await cancelObserver();
2493
2625
  const wt = await finishWorktree();
2494
- return { isError: true, content: `Sub-agent not started in background: ${text}${wt ? `\n${wt}` : ""}`, details: { error: code } };
2626
+ return errorResult(`Sub-agent not started in background: ${text}${wt ? `\n${wt}` : ""}`, { error: code, code });
2495
2627
  };
2496
2628
  const activeFace = bg.registry.activeDelegationHandles;
2497
2629
  const active = typeof activeFace === "function" ? activeFace.call(bg.registry, capScope, capRoot) : [];
@@ -2640,6 +2772,11 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2640
2772
  }
2641
2773
  if (capLedgerKey !== undefined && reviveRow === undefined)
2642
2774
  delegationEntryLedger(bg.registry, capLedgerKey).add(taskId);
2775
+ if (capLedgerKey !== undefined) {
2776
+ bindDelegationLedgerKeyStore(bg.registry, capLedgerKey, bg.agentStore);
2777
+ armDelegationLedgerLifecycle(bg.registry);
2778
+ sweepDelegationLedgerRound(bg.registry, 3);
2779
+ }
2643
2780
  childInternals.peerSelfRef?.addAxis("h", taskId);
2644
2781
  if (agentName !== undefined) {
2645
2782
  recordRosterSpawn(ctx.roster, { name: agentName, agentId: taskId, toolUseId: ctx.toolCallId, owner: bgOwner, scope: bgScope, ...((typeof childModel === "string" ? resolveModelDisplayLabel(childModel) : childModel?.id) !== undefined ? { model: typeof childModel === "string" ? childModel : childModel?.id } : {}), ...(reviveRow !== undefined ? ((reviveRow.rootSessionId ?? reviveRow.parentSessionId) !== undefined ? { rootSessionId: reviveRow.rootSessionId ?? reviveRow.parentSessionId } : {}) : (ctx.rootSessionId ?? ctx.sessionId) !== undefined ? { rootSessionId: ctx.rootSessionId ?? ctx.sessionId } : {}), ...(sessionScopedBg ? { sessionScoped: true } : {}), createdAt: reviveRow?.spawnedAt ?? Date.now() }, (err) => opts.onObserverError?.(err, { site: "roster.recordSpawn" }));
@@ -31,7 +31,10 @@ export function parseTeacherAdvice(text) {
31
31
  const end = text.lastIndexOf("}");
32
32
  if (start !== -1 && end > start) {
33
33
  try {
34
- const o = JSON.parse(text.slice(start, end + 1));
34
+ const oU = JSON.parse(text.slice(start, end + 1));
35
+ if (typeof oU !== "object" || oU === null)
36
+ throw new Error("not an object");
37
+ const o = oU;
35
38
  return {
36
39
  strategy: typeof o.strategy === "string" ? o.strategy : undefined,
37
40
  correction: typeof o.correction === "string" ? o.correction : undefined,
@@ -5,10 +5,9 @@ import { createRepetitionPoll, parseStreamedToolArgs } from "./stream-shared.js"
5
5
  import { mintFallbackToolCallId } from "./tool-call-id.js";
6
6
  import { emitBrainTelemetry } from "./status-sink.js";
7
7
  import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, modelSupportsVision, sendableImages } from "./media-degrade.js";
8
- import { ANTHROPIC_RESERVED, OUTPUT_CAP_KEYS, applyExtraBody, effectiveOutputCap, stripAuthHeaders } from "./request-params.js";
9
- import { isThinkingLevel, reasoningBudgetShare, resolveEffort } from "./reasoning.js";
8
+ import { ANTHROPIC_RESERVED, OUTPUT_CAP_KEYS, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders, stripAuthHeaders, takeHeaderCasefold } from "./request-params.js";
9
+ import { MIN_THINKING_TOKENS, budgetCapSkipsThinking, declaredEffortLevels, reasoningBudgetShare, reasoningRequestCarried, resolveEffort } from "./reasoning.js";
10
10
  import { runStreamingBrain } from "./stream-engine.js";
11
- const MIN_THINKING_TOKENS = 1024;
12
11
  function thinkingBudget(maxTokens, share, fixed, hardCap = false) {
13
12
  const max = hardCap ? maxTokens : Math.max(maxTokens, MIN_THINKING_TOKENS * 2);
14
13
  const want = fixed ?? Math.floor(max * share);
@@ -261,13 +260,13 @@ export function createAnthropicBrain(config = {}) {
261
260
  if (options?.temperature !== undefined && anthCompat.supportsTemperature !== false) {
262
261
  body.temperature = options.temperature;
263
262
  }
264
- if (model.reasoning && isThinkingLevel(options?.reasoning) && options.reasoning !== "off") {
263
+ if (reasoningRequestCarried(model, options?.reasoning)) {
265
264
  if (anthCompat.thinkingMode === "adaptive") {
266
265
  body.thinking = { type: "adaptive" };
267
266
  }
268
267
  else {
269
268
  const hardCap = overrides?.maxOutputTokens !== undefined || options?.maxTokens !== undefined;
270
- if (hardCap && body.max_tokens < MIN_THINKING_TOKENS * 2) {
269
+ if (budgetCapSkipsThinking(body.max_tokens, hardCap)) {
271
270
  body.thinking = undefined;
272
271
  delete body.thinking;
273
272
  }
@@ -283,13 +282,9 @@ export function createAnthropicBrain(config = {}) {
283
282
  }
284
283
  const betas = [];
285
284
  let sendEffortBeta = false;
286
- if (anthCompat.effortLevels &&
287
- anthCompat.effortLevels.length > 0 &&
288
- model.reasoning &&
289
- options?.reasoning &&
290
- options.reasoning !== "off" &&
291
- isThinkingLevel(options.reasoning)) {
292
- const { effective } = resolveEffort(options.reasoning, anthCompat.effortLevels.filter(isThinkingLevel));
285
+ const declaredEffort = declaredEffortLevels(anthCompat.effortLevels);
286
+ if (declaredEffort !== undefined && reasoningRequestCarried(model, options?.reasoning)) {
287
+ const { effective } = resolveEffort(options.reasoning, declaredEffort);
293
288
  const wireEffort = effective === "minimal" ? "low" : effective;
294
289
  body.output_config = { effort: wireEffort };
295
290
  sendEffortBeta = true;
@@ -303,23 +298,19 @@ export function createAnthropicBrain(config = {}) {
303
298
  if (anthCompat.interleavedThinking) {
304
299
  betas.push("interleaved-thinking-2025-05-14");
305
300
  }
306
- const headers = {
307
- ...model.headers,
308
- ...config.headers,
309
- ...options?.headers,
310
- };
301
+ const headers = mergeHeaders(model.headers, config.headers, options?.headers);
311
302
  if (options?.apiKey !== undefined)
312
303
  stripAuthHeaders(headers);
313
304
  if (betas.length > 0) {
314
- const existing = (headers["anthropic-beta"] ?? "").split(",").map((b) => b.trim()).filter(Boolean);
305
+ const existing = (takeHeaderCasefold(headers, "anthropic-beta") ?? "").split(",").map((b) => b.trim()).filter(Boolean);
315
306
  for (const beta of betas) {
316
307
  if (!existing.includes(beta))
317
308
  existing.push(beta);
318
309
  }
319
310
  headers["anthropic-beta"] = existing.join(",");
320
311
  }
321
- headers["content-type"] = "application/json";
322
- headers["anthropic-version"] = config.version ?? "2023-06-01";
312
+ lockHeader(headers, "content-type", "application/json");
313
+ lockHeader(headers, "anthropic-version", config.version ?? "2023-06-01");
323
314
  if (apiKey)
324
315
  headers["x-api-key"] = apiKey;
325
316
  const builtThinkingRequested = body.thinking !== undefined;
@@ -5,8 +5,8 @@ import { createRepetitionPoll, parseStreamedToolArgs } from "./stream-shared.js"
5
5
  import { mintFallbackToolCallId } from "./tool-call-id.js";
6
6
  import { emitBrainTelemetry } from "./status-sink.js";
7
7
  import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, imagesOmittedNoVisionNote, modelSupportsVision, sendableImages } from "./media-degrade.js";
8
- import { OUTPUT_CAP_KEYS, RESPONSES_RESERVED, applyExtraBody, effectiveOutputCap, stripAuthHeaders } from "./request-params.js";
9
- import { DEFAULT_EFFORT_LEVELS, isThinkingLevel, resolveEffort } from "./reasoning.js";
8
+ import { OUTPUT_CAP_KEYS, RESPONSES_RESERVED, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders, stripAuthHeaders } from "./request-params.js";
9
+ import { mintEffortWireValue, reasoningRequestCarried } from "./reasoning.js";
10
10
  import { runStreamingBrain } from "./stream-engine.js";
11
11
  const DEGENERATE_POLL_CHARS = 64;
12
12
  const MALFORMED_SAMPLE_CHARS = 160;
@@ -151,16 +151,12 @@ function toResponsesTools(ctx) {
151
151
  }));
152
152
  }
153
153
  function resolveWireEffort(model, reasoning) {
154
- if (!model.reasoning || !isThinkingLevel(reasoning) || reasoning === "off")
154
+ if (!reasoningRequestCarried(model, reasoning))
155
155
  return undefined;
156
156
  const compat = responsesCompat(model);
157
157
  if (compat.supportsReasoningEffort === false)
158
158
  return undefined;
159
- const effort = resolveEffort(reasoning, compat.reasoningEffortLevels ?? DEFAULT_EFFORT_LEVELS).effective;
160
- const mapped = model.thinkingLevelMap?.[effort];
161
- if (mapped === null)
162
- return undefined;
163
- return mapped ?? effort;
159
+ return mintEffortWireValue(reasoning, model, compat.reasoningEffortLevels).wireValue;
164
160
  }
165
161
  function computeUsage(model, raw) {
166
162
  const input = raw?.input_tokens ?? 0;
@@ -268,14 +264,10 @@ export function createOpenResponsesBrain(config = {}) {
268
264
  }
269
265
  if (effort !== undefined)
270
266
  body.reasoning = { effort };
271
- const headers = {
272
- ...model.headers,
273
- ...config.headers,
274
- ...options?.headers,
275
- };
267
+ const headers = mergeHeaders(model.headers, config.headers, options?.headers);
276
268
  if (options?.apiKey !== undefined)
277
269
  stripAuthHeaders(headers);
278
- headers["content-type"] = "application/json";
270
+ lockHeader(headers, "content-type", "application/json");
279
271
  if (apiKey)
280
272
  headers["authorization"] = `Bearer ${apiKey}`;
281
273
  const wire = applyExtraBody(body, model.extraBody, RESPONSES_RESERVED);
@@ -5,8 +5,8 @@ import { createRepetitionPoll, parseStreamedToolArgs } from "./stream-shared.js"
5
5
  import { mintFallbackToolCallId } from "./tool-call-id.js";
6
6
  import { emitBrainTelemetry } from "./status-sink.js";
7
7
  import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, imagesOmittedNoVisionNote, modelSupportsVision, sendableImages } from "./media-degrade.js";
8
- import { OPENAI_RESERVED, OUTPUT_CAP_KEYS, applyExtraBody, effectiveOutputCap, stripAuthHeaders } from "./request-params.js";
9
- import { DEFAULT_EFFORT_LEVELS, isThinkingLevel, resolveEffort } from "./reasoning.js";
8
+ import { OPENAI_RESERVED, OUTPUT_CAP_KEYS, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders, stripAuthHeaders } from "./request-params.js";
9
+ import { mintEffortWireValue, reasoningRequestCarried } from "./reasoning.js";
10
10
  import { runStreamingBrain } from "./stream-engine.js";
11
11
  function closeToolCallAccum(acc) {
12
12
  if (acc.closedTc)
@@ -86,19 +86,11 @@ function applyThinking(body, model, reasoning) {
86
86
  }
87
87
  return;
88
88
  }
89
- if (!model.reasoning || !isThinkingLevel(reasoning) || reasoning === "off")
89
+ if (!reasoningRequestCarried(model, reasoning))
90
90
  return;
91
91
  const compat = thinkingCompat(model);
92
92
  const supportsEffort = compat.supportsReasoningEffort ?? true;
93
- const effort = resolveEffort(reasoning, compat.reasoningEffortLevels ?? DEFAULT_EFFORT_LEVELS).effective;
94
- let effortWire = effort;
95
- if (model.thinkingLevelMap) {
96
- const mapped = model.thinkingLevelMap[effort];
97
- if (mapped === null)
98
- effortWire = undefined;
99
- else if (mapped !== undefined)
100
- effortWire = mapped;
101
- }
93
+ const effortWire = mintEffortWireValue(reasoning, model, compat.reasoningEffortLevels).wireValue;
102
94
  const format = compat.thinkingFormat ?? "openai";
103
95
  switch (format) {
104
96
  case "openai":
@@ -311,14 +303,10 @@ export function createOpenAIBrain(config = {}) {
311
303
  if (options?.stop)
312
304
  body.stop = options.stop;
313
305
  applyThinking(body, model, options?.reasoning);
314
- const headers = {
315
- ...model.headers,
316
- ...config.headers,
317
- ...options?.headers,
318
- };
306
+ const headers = mergeHeaders(model.headers, config.headers, options?.headers);
319
307
  if (options?.apiKey !== undefined)
320
308
  stripAuthHeaders(headers);
321
- headers["content-type"] = "application/json";
309
+ lockHeader(headers, "content-type", "application/json");
322
310
  if (apiKey)
323
311
  headers["authorization"] = `Bearer ${apiKey}`;
324
312
  const wire = applyExtraBody(body, model.extraBody, OPENAI_RESERVED);