@sema-agent/core 5.14.0 → 5.16.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 (39) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/dist/agents/subagent.js +3 -2
  3. package/dist/brain/errors.js +21 -1
  4. package/dist/brain/retry.d.ts +5 -0
  5. package/dist/brain/retry.js +16 -4
  6. package/dist/brain/stream-engine.js +7 -3
  7. package/dist/core/checkpoint-store.d.ts +1 -0
  8. package/dist/core/checkpoint-store.js +48 -26
  9. package/dist/core/memory-engine/engine.d.ts +3 -1
  10. package/dist/core/memory-engine/engine.js +4 -3
  11. package/dist/core/memory-recall.d.ts +1 -1
  12. package/dist/core/memory-recall.js +3 -2
  13. package/dist/core/runner/prepare-memory.d.ts +1 -0
  14. package/dist/core/runner/prepare-memory.js +3 -3
  15. package/dist/core/runner/prepare-task.d.ts +3 -0
  16. package/dist/core/runner/prepare-task.js +43 -12
  17. package/dist/core/runner/runtask.js +124 -36
  18. package/dist/core/runner/tool-disclosure.d.ts +5 -0
  19. package/dist/core/runner/tool-disclosure.js +65 -16
  20. package/dist/core/runner/turn-attachments.d.ts +4 -0
  21. package/dist/core/runner/turn-attachments.js +15 -2
  22. package/dist/core/task-registry-agent.d.ts +2 -2
  23. package/dist/core/task-registry-agent.js +119 -5
  24. package/dist/core/task-registry.d.ts +2 -2
  25. package/dist/core/task-tool-shape.js +4 -3
  26. package/dist/core/trace.d.ts +6 -0
  27. package/dist/index.d.ts +3 -3
  28. package/dist/index.js +1 -1
  29. package/dist/prompts/default.d.ts +3 -3
  30. package/dist/prompts/default.js +2 -2
  31. package/dist/prompts/supervisor.d.ts +2 -2
  32. package/dist/prompts/supervisor.js +5 -4
  33. package/dist/tools/fs/fs-bash.d.ts +1 -0
  34. package/dist/tools/fs/fs-bash.js +1 -1
  35. package/dist/tools/fs/gh-rate-limit.d.ts +1 -1
  36. package/dist/tools/fs/gh-rate-limit.js +4 -3
  37. package/dist/tools/fs/index.d.ts +1 -0
  38. package/dist/tools/fs/index.js +1 -0
  39. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,7 +1,181 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.16.0 — 2026-08-07
4
+
5
+ ### BREAKING
6
+
7
+ - **`TaskRegistry.reviveBackgroundAgent` is now async and CLAIMS the durable row before reviving.**
8
+ A retained revive and a foreign tier-3 claim now arbitrate in one domain: the winner takes clean
9
+ write authority (`writerEpoch` bump via guarded CAS, in-memory flip only after the claim), the
10
+ loser gets the existing `still_running`/`not_found` refusals. A revive the store cannot confirm is
11
+ refused instead of running with a silently lost persistent identity; a pre-poisoned write lane
12
+ re-establishes ownership through the store and hands off to a fresh lane. Closes a window where a
13
+ session's narrowed org-admission verdict was silently dropped and a later cross-process revival
14
+ seeded from the stale wider one (dropped write-backs are now also disclosed via
15
+ `process.emitWarning`).
16
+ - **The memory engine's `# Memory` write instruction (and its index read-seed) require the `Write`
17
+ tool on the assembled roster.** `handsReadOnly: true`, hands-less runs, and
18
+ `excludeTools: ["Write"]` no longer receive the write instruction or seed files; the fenced index
19
+ still injects. Engine-direct hosts that manage their own mounts keep the historical behavior by
20
+ omitting the new `inject()` option. Downstream tests pinning the old always-on instruction must
21
+ re-pin under the new predicate.
22
+
23
+ ### Fixed
24
+
25
+ - An `env_failed` replay binds the recorded deny/review note: `ResolvedOutcome` gains an optional
26
+ persisted `reason`, recorded on the approval and review lanes and compared on replay. Additive
27
+ compatibility: a row with no recorded note leaves that dimension unbound, so pre-existing rows
28
+ replay unchanged.
29
+ - A resume presenting an already-consumed checkpoint token is answered honestly:
30
+ `checkpoint.already_resolved` (confirmed against the live row) or
31
+ `checkpoint.reopened_concurrently` (a resolve/reopen cycle raced the resume) — never a refusal
32
+ claiming the row is still pending. Terminal-state refusals name the observed status without
33
+ coercing store-supplied values.
34
+ - The approval lane's `reason` is validated as plain text at capture; malformed decisions and
35
+ hostile text shapes get typed refusals instead of raising inside the refusal path.
36
+ - Orchestration guidance, the TaskOutput tool card, and the selective-recall affordance stop
37
+ teaching retired tool names: the workflow tool is named by its wire name, and
38
+ `composeSelectiveBody` accepts an optional caller-supplied `recallToolName` (additive).
39
+ - The gh rate-limit hint's Monitor clause follows the real Monitor mount (dropped when the Monitor
40
+ tool is not on the roster).
41
+ - The exported `MEMORY_SAFETY`/`MEMORY_HYGIENE` prompt assets are reworded name-free (they taught
42
+ two retired tool names; semantics unchanged).
43
+
44
+ ### Added
45
+
46
+ - `test/tool-name-literal-gate.test.ts`: every src string literal is checked against the retired
47
+ tool-name table on a per-directory ratchet (per-file ceilings under `src/prompts`).
48
+ - Standing live release legs: a deferred tool with a required structured argument must converge to a
49
+ real schema-valid call after activation, and the search surface must answer a live hunt.
50
+
51
+ ## 5.15.0 — 2026-08-06
52
+
53
+ > Post-release addendum (2026-08-06): the new `toolDisclosure` diagnostics keys ride
54
+ > `prompt.assembled`. A service layer that projects that event through an allow-list must add the
55
+ > new keys explicitly, or they are silently dropped from its stored/forwarded copy.
56
+
57
+ ### BREAKING
58
+
59
+ - **`TaskSpec.toolMaterializeStrategy` defaults to `"swap"` again** (it defaulted to `"static"` for one
60
+ release window). Under `"static"` a deferred tool's placeholder never swaps: the tools block keeps
61
+ advertising `{"type":"object","properties":{}}` and the real schema reaches the model only as text in
62
+ the `ToolSearch` result. That is fine for a model whose tool arguments are free-form JSON, and
63
+ structurally unusable for one served with **constrained decoding**, where the advertised wire schema is
64
+ enforced at the sampler. On the mainstream implementations — grammar compiled from the declared
65
+ `properties` — `{}` becomes the only argument object the model can emit, so the engine's corrective
66
+ invalid-arguments rejection cannot be acted on and the run burns turns without converging. (A strictly
67
+ draft-faithful decoder would read the absent `additionalProperties` as permissive; the model still has
68
+ no schema to form other keys from, so that path degrades to guessing rather than converging.)
69
+ The condition for `"static"` is also a property of the WHOLE serving route rather than of one model —
70
+ the degrading brain can fall back across providers mid-run and `limits.degrade` can hand the run to a
71
+ different model outright, and neither rebuilds the disclosure strategy — so it is now a strict explicit
72
+ opt-in: set it only if every model the run can reach, fallback targets included, has been verified to
73
+ read schemas from result text. **Who is affected:** a deployment that relied on the default to keep its
74
+ provider prompt cache intact across activations now sees the pre-`"static"` behavior (activation calls
75
+ `setTools`, invalidating the cache suffix) unless it sets `toolMaterializeStrategy: "static"` itself.
76
+ **Pinned faces that move with the default:** the `tools_delta` attachment says "now available … Their
77
+ full schemas are loaded" (not "now active"), the `ToolSearch` description says activation "loads its
78
+ full parameter schema" (not "returns … in the result"), the `ToolSearch` result no longer inlines
79
+ `parameters:` per matched tool, and the post-activation tools block carries the real schema again — a
80
+ consumer pinning any of these under the default must re-pin. `SEMA_TOOL_MATERIALIZE_STRATEGY` still
81
+ fills an absent spec value, and an env-sourced `"static"` on a `deferSelfResolve: false` task still
82
+ degrades to `"swap"` (the explicit spec pairing stays refused with `config.tool_materialize_unreachable`).
83
+
84
+ ### Added
85
+
86
+ - **`prompt.assembled` reports the resolved deferred-tool disclosure strategy** —
87
+ `toolDisclosure: {deferredTools, strategy, source}` (new exported type `ToolDisclosureManifest`;
88
+ absent when the leg deferred nothing). `strategy` is the EFFECTIVE value after the direct-call-lane
89
+ interlock, and `source` names the seat that chose it: `"spec"` / `"env"` / `"default"` /
90
+ `"degraded_no_direct_lane"` (the interlock narrowed a `"static"` request to `"swap"`; that value
91
+ implies an env-sourced request, since the spec pairing is refused at prepare and the default is not
92
+ `"static"`). "Which strategy did this leg run under, and who chose it" was previously answerable only
93
+ by re-deriving spec > env > release default by hand.
94
+
95
+ ### Fixed
96
+
97
+ - **Model-visible text stops teaching retired tool names** (#181 census F batch). Three faces taught
98
+ names a model cannot call (RB-476-A retired alias resolution — a retired name is a loud roster
99
+ miss): the selective-recall manifest affordance said "call Recall" (a name core registers as
100
+ removed; the affordance's tool name is now caller-supplied via `composeSelectiveBody`'s new
101
+ optional `recallToolName` parameter, name-free when omitted); both `ORCHESTRATION_GUIDANCE`
102
+ blocks said `run_workflow` — on the deferred short form a complete dead link, since `ToolSearch`'s
103
+ `select:` arm matches exact wire names — and now interpolate `RUN_WORKFLOW_TOOL_NAME`
104
+ (`"Workflow"`); TaskOutput's lanes description and `task_id` parameter said `RunWorkflow` and now
105
+ say `Workflow`. A consumer pinning any of these strings must re-pin. A new gate
106
+ (`test/tool-name-literal-gate.test.ts`) scans every src string literal against the whole
107
+ `RETIRED_TOOL_NAMES` table with a frozen per-bucket ratchet, so this class cannot regrow silently.
108
+ - **The `# Memory` write instruction requires the Write tool on the roster** (behavior face). The
109
+ memory engine's injection gated the CC-verbatim write instruction on a writable scope alone;
110
+ a `handsReadOnly` (or hands-less) run with a memory `writeScope` was instructed to write with a
111
+ tool its roster does not carry. `MemoryEngine.inject` takes an optional `writeToolMounted`
112
+ (omitted ⇒ historic behavior for engine-direct hosts; the Runner passes the real hands-write-half
113
+ predicate), and the RB-276 index seed follows the same gate. **Who is affected:** a deployment
114
+ running memory-enabled tasks with `handsReadOnly: true` (or no execution env) no longer receives
115
+ the `# Memory` instruction or the index read-seed on those runs — the fenced index still injects.
116
+ - **The gh rate-limit hint's Monitor clause follows the real Monitor mount.** The SR-2 reminder
117
+ unconditionally closed with "use the Monitor tool"; Monitor mounts only when background task tools
118
+ do. `ghRateLimitHint` (and `HandsToolkitOptions`/`createBashTool` `monitorToolActive`) drop exactly
119
+ that clause when the caller declares Monitor unmounted; omitted keeps the byte-identical historic
120
+ wording.
121
+ - **`ToolSearch` no longer activates a name the roster has withdrawn.** The deferred registry is frozen
122
+ at prepare; the roster is not — an MCP refresh can take a deferred name off the mount. An exact
123
+ `select:` still resolved through the stale registry, added the name to the active set, "succeeded" at
124
+ a rematerialize that mounted nothing, and announced a tool that could not be called. Withdrawn names
125
+ are now reported as `No longer available: <names> — withdrawn by its provider and cannot be activated
126
+ or called`, ride the result's `details.missing`, and never enter the active set.
127
+ - **The deferred placeholder's corrective round is now bounded.** A shape-invalid direct call is answered
128
+ with the real declared schema so the corrected call is one turn away — which assumes the caller can act
129
+ on a schema once it has one. A caller whose arguments are constrained to the ADVERTISED schema cannot,
130
+ and re-sent the identical rejected arguments for as many turns as the budget allowed. After
131
+ `DEFERRED_NO_PROGRESS_LIMIT` (3) consecutive rejections of the IDENTICAL failure shape on one
132
+ placeholder, the lane stops re-teaching: it returns a terminal rejection that names the condition with
133
+ the stable token `tool.deferred_schema_incompatible` (in the model-facing text and on the tool result's
134
+ `details.noProgress`) and votes the tool batch terminated. Any other outcome — a different validation
135
+ error, or a successful call — resets the count, so the bound tracks being STUCK, not being wrong.
136
+ `TaskResult.errorCode` is unchanged: this terminal is reported on the tool result, not as a task
137
+ failure code.
138
+ - **The invalid-arguments rejection no longer overstates where its schema lives.** It used to say "use it
139
+ for this and later calls" under both strategies; under `"static"` the schema is carried by that one
140
+ result and is gone with the next compaction. The text now says either "the next request's tools list
141
+ will advertise it too" (swap) or "carried by THIS result only" plus the `ToolSearch` re-select spelling
142
+ (static). A declaration too large to inline is called an "ABRIDGED copy" instead of "full" — past the
143
+ model-facing bound the serialization comes back with its middle spliced out and is not valid JSON.
144
+ - **A declaration the static face cannot carry in text is exempted from the static face per tool.** Under
145
+ `"static"` the only in-context schema carrier is result text; a declaration that does not serialize, or
146
+ that the model-facing bound truncates, has no carrier there, and the tool was announced as activated
147
+ with a schema the model never received in usable form. Such a tool now materializes into the tools block
148
+ on activation (the rest of the run stays static), the `ToolSearch` line says where its declaration is,
149
+ and the result head stops promising schemas it did not print. The `tools_delta` boundary frame carries
150
+ the same exception (one `Exception: <names> — too large to inline…` line) instead of asserting compact
151
+ placeholders for a name whose full schema is visibly on the wire; with no exemptions in play the frame
152
+ is byte-identical to before. The exemption is read from the LIVE roster on every call, so an MCP
153
+ refresh that changes a declaration's size cannot leave the lane describing the old carrier — and the
154
+ exemption is MONOTONE within a leg: a declaration that shrinks back under the bound does not put the
155
+ compact placeholder back on a tool whose schema was only ever delivered through the tools block.
156
+
157
+ ### Fixed (receive-time additions)
158
+
159
+ - Ruled at receive: the deferred no-progress terminal stays a TOOL-RESULT face (`tool.deferred_schema_incompatible`
160
+ marker + `details.noProgress`) and does NOT mint a `TaskResult.errorCode` — the run can continue on other
161
+ tools, so a run-level code would misclassify a per-tool condition.
162
+ - Ruled at receive: a malformed `SEMA_TOOL_MATERIALIZE_STRATEGY` still refuses the task loudly even when a
163
+ valid spec value is present — a deployment running with a broken env var must hear about it before the
164
+ first task that omits the spec value inherits the typo.
165
+
3
166
  ## 5.14.0 — 2026-08-06
4
167
 
168
+ > **Post-release addendum (2026-08-06, disclosure gap reported by a consumer):** the fail-open survey
169
+ > (shipped in 5.13.0's window, but its consequence surfaced against 5.14.0 pickups) hardened
170
+ > `canonicalizeTarget`'s existence probe — an ERRORED `exists` no longer reads as "does not exist yet"
171
+ > and instead reports `{ok:false, unresolvedSymlink:true}`. Combined with `createSensitivePathPolicy`'s
172
+ > deny on `unresolvedSymlink`, an execution env whose `exists` always throws (a stub/lexical env with
173
+ > no real filesystem) now sees every write target denied — the gate degrades from "guarded" to
174
+ > "closed". That is the intended fail-closed direction for a PROBE FAILURE, but such an env should
175
+ > answer honestly instead of throwing: return `ok(false)` ("this env cannot see a filesystem") and the
176
+ > gate resolves normally. Also: `DelegationTaskType` was named in the ship note but missed the package
177
+ > root; the export lands in the next release — type the `taskType` key as an open `string` until then.
178
+
5
179
  ### BREAKING
6
180
 
7
181
  - **`CheckpointStore.setPendingSteer` APPENDS to a bounded ordered queue instead of overwriting a single seat**
@@ -447,12 +447,13 @@ export function createSubagentResume(deps) {
447
447
  signal: abort.signal,
448
448
  };
449
449
  if (deps.registry !== undefined && deps.taskId !== undefined && deps.taskAccess !== undefined) {
450
- const revived = deps.registry.reviveBackgroundAgent(deps.taskId, deps.taskAccess, abort);
450
+ const revived = await deps.registry.reviveBackgroundAgent(deps.taskId, deps.taskAccess, abort);
451
451
  if (!revived.ok) {
452
452
  entry.resumeCount -= 1;
453
453
  entry.cycleSeq -= 1;
454
454
  throw configError(revived.reason === "still_running"
455
- ? "resume unavailable: the agent's registry row is still running (a concurrent resume already revived it) — wait for its completion notification."
455
+ ?
456
+ "resume unavailable: the agent's registry row is not available for a resume right now (it is running, or another revival claimed it) — wait for its completion notification and send again."
456
457
  : "resume unavailable: the agent's registry row no longer exists (terminal GC) — relaunch a new agent instead.", revived.reason === "still_running" ? "steering.still_running" : "resume.row_gone");
457
458
  }
458
459
  reviveCycle = revived.cycle;
@@ -42,6 +42,25 @@ export function extractErrorCode(errorMessage) {
42
42
  export function stripErrorCodePrefix(errorMessage) {
43
43
  return errorMessage.replace(CODE_PREFIX_RE, "");
44
44
  }
45
+ const STALE_CONNECTION_CODES = new Set([
46
+ "ECONNRESET",
47
+ "EPIPE",
48
+ "ConnectionClosed",
49
+ "ETIMEDOUT",
50
+ "ECONNABORTED",
51
+ "ERR_SOCKET_CLOSED",
52
+ "StreamSuspended",
53
+ ]);
54
+ function hasStaleConnectionCode(e) {
55
+ let cur = e;
56
+ for (let depth = 0; depth < 5 && cur !== undefined && cur !== null; depth++) {
57
+ const code = typeof cur.code === "string" ? cur.code : undefined;
58
+ if (code !== undefined && STALE_CONNECTION_CODES.has(code))
59
+ return true;
60
+ cur = cur instanceof Error ? cur.cause : undefined;
61
+ }
62
+ return false;
63
+ }
45
64
  export function describeNetworkError(e) {
46
65
  const top = e instanceof Error ? e.message : String(e);
47
66
  const parts = [];
@@ -54,5 +73,6 @@ export function describeNetworkError(e) {
54
73
  parts.push(piece);
55
74
  cur = cur instanceof Error ? cur.cause : undefined;
56
75
  }
57
- return parts.length > 0 ? `${top} (${parts.join(" ← ")})` : top;
76
+ const body = parts.length > 0 ? `${top} (${parts.join(" ← ")})` : top;
77
+ return hasStaleConnectionCode(e) ? `${body} — possibly a stale connection; a fresh one may succeed` : body;
58
78
  }
@@ -1,3 +1,8 @@
1
1
  export declare function parseRetryAfter(res: Response | undefined): number | undefined;
2
2
  export declare function parseRateLimitReset(res: Response | undefined): number | undefined;
3
+ export interface ProviderWaitHint {
4
+ readonly ms: number;
5
+ readonly rawMs?: number;
6
+ }
7
+ export declare function providerWaitHint(res: Response | undefined): ProviderWaitHint | undefined;
3
8
  export declare function retryBackoffMs(baseDelayMs: number, attempt: number, res?: Response, rand?: () => number): number;
@@ -24,17 +24,29 @@ export function parseRateLimitReset(res) {
24
24
  const ms = Math.round(epochSecs * 1000 - Date.now());
25
25
  return ms > 0 ? ms : undefined;
26
26
  }
27
- function providerWaitHintMs(res) {
27
+ function clampHint(rawMs) {
28
+ return rawMs > MAX_HEADER_WAIT_MS ? { ms: MAX_HEADER_WAIT_MS, rawMs } : { ms: rawMs };
29
+ }
30
+ export function providerWaitHint(res) {
28
31
  const hints = [];
29
32
  const retryAfter = parseRetryAfter(res);
30
33
  if (retryAfter !== undefined)
31
- hints.push(Math.min(MAX_HEADER_WAIT_MS, retryAfter));
34
+ hints.push(clampHint(retryAfter));
32
35
  if (res?.status === RATE_LIMIT_STATUS) {
33
36
  const reset = parseRateLimitReset(res);
34
37
  if (reset !== undefined)
35
- hints.push(Math.min(MAX_HEADER_WAIT_MS, reset));
38
+ hints.push(clampHint(reset));
36
39
  }
37
- return hints.length === 0 ? undefined : Math.max(...hints);
40
+ if (hints.length === 0)
41
+ return undefined;
42
+ return hints.reduce((largest, next) => {
43
+ if (next.ms !== largest.ms)
44
+ return next.ms > largest.ms ? next : largest;
45
+ return (next.rawMs ?? next.ms) > (largest.rawMs ?? largest.ms) ? next : largest;
46
+ });
47
+ }
48
+ function providerWaitHintMs(res) {
49
+ return providerWaitHint(res)?.ms;
38
50
  }
39
51
  export function retryBackoffMs(baseDelayMs, attempt, res, rand = Math.random) {
40
52
  const exp = Math.min(MAX_BACKOFF_MS, baseDelayMs * 2 ** attempt);
@@ -1,7 +1,7 @@
1
1
  import { createAssistantMessageEventStream, } from "../internal/llm.js";
2
2
  import { FLOOR_OUTPUT_TOKENS, parseContextOverflow, planOutputCapAdjustment } from "./context-overflow.js";
3
3
  import { BrainError, classifyHttp, describeNetworkError } from "./errors.js";
4
- import { retryBackoffMs } from "./retry.js";
4
+ import { providerWaitHint, retryBackoffMs } from "./retry.js";
5
5
  import { emitBrainStatus, emitBrainTelemetry } from "./status-sink.js";
6
6
  import { createConnectController, resolveStallTimeoutMs } from "./timeout.js";
7
7
  const DEFAULT_MAX_RETRIES = 10;
@@ -372,6 +372,10 @@ export function runStreamingBrain(args) {
372
372
  const retryable = shouldRetryHeaderVerdict(r) ?? retryableByStatus;
373
373
  if (retryable && attempt < maxRetries) {
374
374
  const delayMs = retryBackoffMs(baseDelay, attempt, r);
375
+ const waitHint = providerWaitHint(r);
376
+ const truncationNote = waitHint?.rawMs !== undefined
377
+ ? ` (the requested wait was longer than the cap; honoring ${Math.ceil(waitHint.ms / 1000)}s instead of ${Math.ceil(waitHint.rawMs / 1000)}s)`
378
+ : "";
375
379
  const statusPhase = r?.status === 429 ? "rate_limited" : netErr !== undefined ? "reconnecting" : "retrying";
376
380
  emitBrainTelemetry({ kind: "retry", attempt: attempt + 1, phase: "connect" });
377
381
  announcedRetry = true;
@@ -381,10 +385,10 @@ export function runStreamingBrain(args) {
381
385
  await sleepAnnouncingRetry(delayMs, signal, (remainingMs) => ({
382
386
  phase: statusPhase,
383
387
  detail: statusPhase === "rate_limited"
384
- ? "rate limited, backing off"
388
+ ? `rate limited, backing off${truncationNote}`
385
389
  : statusPhase === "reconnecting"
386
390
  ? "connection lost, reconnecting"
387
- : "transient error, retrying",
391
+ : `transient error, retrying${truncationNote}`,
388
392
  retryInSec: Math.ceil(remainingMs / 1000),
389
393
  retryInMs: remainingMs,
390
394
  attempt: attempt + 1,
@@ -214,6 +214,7 @@ export interface ResolvedOutcome {
214
214
  decision: "allow" | "deny" | "approve" | "reject" | "edit";
215
215
  updatedInput?: unknown;
216
216
  answer?: QuestionAnswer;
217
+ reason?: string;
217
218
  }
218
219
  export type ReopenReason = "env_failed" | "tool_unavailable";
219
220
  export interface ResolveExpectation {
@@ -230,6 +230,7 @@ export function winnerFromOutcome(outcome) {
230
230
  boundCallId: `gate:${outcome.gate}`,
231
231
  decision: outcome.decision,
232
232
  ...(editedPlan !== undefined ? { updatedInput: editedPlan } : {}),
233
+ ...(outcome.reason !== undefined ? { reason: outcome.reason } : {}),
233
234
  };
234
235
  }
235
236
  if (outcome.gate !== "policy_ask")
@@ -239,10 +240,14 @@ export function winnerFromOutcome(outcome) {
239
240
  decision: outcome.decision,
240
241
  ...(outcome.updatedInput === undefined ? {} : { updatedInput: outcome.updatedInput }),
241
242
  ...(outcome.answer === undefined ? {} : { answer: outcome.answer }),
243
+ ...(outcome.reason === undefined ? {} : { reason: outcome.reason }),
242
244
  };
243
245
  }
244
246
  const CHECKPOINT_CONTROL_CHARS_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
245
247
  export function validatePendingSteer(steer) {
248
+ if (steer === null || typeof steer !== "object") {
249
+ throw new CheckpointError("steering.invalid_content", `steering entry must be an object carrying text/trusted (got ${steer === null ? "null" : typeof steer})`);
250
+ }
246
251
  const allowed = new Set(PENDING_STEER_FROZEN_FIELDS.filter((f) => f !== "seq"));
247
252
  for (const key of Object.keys(steer)) {
248
253
  if (!allowed.has(key)) {
@@ -250,53 +255,76 @@ export function validatePendingSteer(steer) {
250
255
  `a field this worker does not know would be dropped silently on the parked leg`);
251
256
  }
252
257
  }
253
- if (steer.actor !== undefined)
254
- validateActorAssertion(steer.actor);
255
- if (steer.inputId !== undefined && (steer.inputId === "" || steer.inputId.length > MAX_STEER_INPUT_ID_CHARS)) {
258
+ const text = steer.text;
259
+ const trusted = steer.trusted;
260
+ const actor = steer.actor;
261
+ const inputId = steer.inputId;
262
+ const priority = steer.priority;
263
+ if (typeof text !== "string") {
264
+ throw new CheckpointError("steering.invalid_content", "steering text must be a string");
265
+ }
266
+ if (typeof trusted !== "boolean") {
267
+ throw new CheckpointError("steering.invalid_content", "steering trusted must be a boolean stated by the caller");
268
+ }
269
+ if (inputId !== undefined && typeof inputId !== "string") {
270
+ throw new CheckpointError("steering.invalid_content", "steering inputId must be a string when supplied");
271
+ }
272
+ if (priority !== undefined && priority !== "now" && priority !== "next" && priority !== "later") {
273
+ throw new CheckpointError("steering.invalid_content", `steering priority "${typeof priority === "string" ? priority : priority === null ? "null" : typeof priority}" is outside the injection-priority domain ("now", "next", "later")`);
274
+ }
275
+ const actorTwin = actor === undefined ? undefined : captureActorAssertion(actor);
276
+ if (inputId !== undefined && (inputId === "" || inputId.length > MAX_STEER_INPUT_ID_CHARS)) {
256
277
  throw new CheckpointError("steering.invalid_content", `steering inputId must be a non-empty string of at most ${MAX_STEER_INPUT_ID_CHARS} characters`);
257
278
  }
258
- if (steer.inputId === LEGACY_PENDING_STEER_INPUT_ID) {
279
+ if (inputId === LEGACY_PENDING_STEER_INPUT_ID) {
259
280
  throw new CheckpointError("steering.invalid_content", `steering inputId "${LEGACY_PENDING_STEER_INPUT_ID}" is reserved for a pre-queue parked steer and cannot be supplied by a caller`);
260
281
  }
261
- if (sanitizeUntrustedText(steer.text) !== steer.text) {
282
+ if (sanitizeUntrustedText(text) !== text) {
262
283
  throw new CheckpointError("steering.invalid_content", "steering text must not contain a system-reminder break-out tag");
263
284
  }
264
- if (CHECKPOINT_CONTROL_CHARS_RE.test(steer.text)) {
285
+ if (CHECKPOINT_CONTROL_CHARS_RE.test(text)) {
265
286
  throw new CheckpointError("steering.invalid_content", "steering text must not contain control characters");
266
287
  }
267
- if (steer.text.length > MAX_PENDING_STEER_CHARS) {
268
- throw new CheckpointError("steering.invalid_content", `steering text must be at most ${MAX_PENDING_STEER_CHARS} characters (got ${steer.text.length})`);
288
+ if (text.length > MAX_PENDING_STEER_CHARS) {
289
+ throw new CheckpointError("steering.invalid_content", `steering text must be at most ${MAX_PENDING_STEER_CHARS} characters (got ${text.length})`);
269
290
  }
270
291
  return {
271
- text: steer.text,
272
- trusted: steer.trusted,
273
- ...(steer.actor !== undefined ? { actor: freezeActorAssertion(steer.actor) } : {}),
274
- inputId: steer.inputId ?? uuidv7(),
275
- ...(steer.priority !== undefined ? { priority: steer.priority } : {}),
292
+ text,
293
+ trusted,
294
+ ...(actorTwin !== undefined ? { actor: actorTwin } : {}),
295
+ inputId: inputId ?? uuidv7(),
296
+ ...(priority !== undefined ? { priority: priority } : {}),
276
297
  };
277
298
  }
278
299
  export const MAX_ACTOR_FIELD_CHARS = 256;
279
300
  export const MAX_STEER_INPUT_ID_CHARS = 128;
280
301
  export const ACTOR_ASSERTION_FROZEN_FIELDS = ["id", "hostAsserted", "issuer"];
281
- function validateActorAssertion(actor) {
302
+ function captureActorAssertion(actor) {
303
+ if (actor === null || typeof actor !== "object") {
304
+ throw new CheckpointError("steering.invalid_content", `actor must be an object carrying the attribution fields (got ${actor === null ? "null" : typeof actor})`);
305
+ }
306
+ const supplied = actor;
282
307
  const allowed = new Set(ACTOR_ASSERTION_FROZEN_FIELDS);
283
308
  for (const key of Object.keys(actor)) {
284
309
  if (!allowed.has(key)) {
285
310
  throw new CheckpointError("steering.invalid_content", `unknown actor field "${key}" — the persisted actor shape is frozen (${[...allowed].join(", ")})`);
286
311
  }
287
312
  }
288
- if (typeof actor.id !== "string" || actor.id === "" || actor.id.length > MAX_ACTOR_FIELD_CHARS) {
313
+ const id = supplied.id;
314
+ const hostAsserted = supplied.hostAsserted;
315
+ const issuer = supplied.issuer;
316
+ if (typeof id !== "string" || id === "" || id.length > MAX_ACTOR_FIELD_CHARS) {
289
317
  throw new CheckpointError("steering.invalid_content", `actor.id must be a non-empty namespaced string of at most ${MAX_ACTOR_FIELD_CHARS} characters (e.g. "slack:U123")`);
290
318
  }
291
- if (typeof actor.hostAsserted !== "boolean") {
319
+ if (typeof hostAsserted !== "boolean") {
292
320
  throw new CheckpointError("steering.invalid_content", "actor.hostAsserted must be a boolean stated by the host");
293
321
  }
294
- if (actor.issuer !== undefined && (actor.issuer === "" || actor.issuer.length > MAX_ACTOR_FIELD_CHARS)) {
322
+ if (issuer !== undefined && (typeof issuer !== "string" || issuer === "" || issuer.length > MAX_ACTOR_FIELD_CHARS)) {
295
323
  throw new CheckpointError("steering.invalid_content", `actor.issuer must be a non-empty string of at most ${MAX_ACTOR_FIELD_CHARS} characters`);
296
324
  }
297
325
  for (const [field, value] of [
298
- ["id", actor.id],
299
- ["issuer", actor.issuer],
326
+ ["id", id],
327
+ ["issuer", issuer],
300
328
  ]) {
301
329
  if (value === undefined)
302
330
  continue;
@@ -304,13 +332,7 @@ function validateActorAssertion(actor) {
304
332
  throw new CheckpointError("steering.invalid_content", `actor.${field} must not contain control characters or a system-reminder break-out tag`);
305
333
  }
306
334
  }
307
- }
308
- function freezeActorAssertion(actor) {
309
- return {
310
- id: actor.id,
311
- hostAsserted: actor.hostAsserted,
312
- ...(actor.issuer !== undefined ? { issuer: actor.issuer } : {}),
313
- };
335
+ return { id, hostAsserted, ...(issuer !== undefined ? { issuer } : {}) };
314
336
  }
315
337
  export function readPendingSteerQueue(state) {
316
338
  const legacy = state.pendingSteer === undefined
@@ -49,7 +49,9 @@ export declare class MemoryEngine {
49
49
  constructor(opts: MemoryEngineOptions);
50
50
  private discloseAnnounceFailure;
51
51
  materialize(scopes: readonly string[], writeScope: string | null): Promise<MemorySessionHandle>;
52
- inject(handle: MemorySessionHandle): MemoryInjection;
52
+ inject(handle: MemorySessionHandle, opts?: {
53
+ writeToolMounted?: boolean;
54
+ }): MemoryInjection;
53
55
  gateWrite(handle: MemorySessionHandle, canonicalPath: string, content: string): {
54
56
  ok: true;
55
57
  } | {
@@ -188,14 +188,15 @@ export class MemoryEngine {
188
188
  }
189
189
  return handle;
190
190
  }
191
- inject(handle) {
192
- const instruction = handle.writeScope !== null ? buildMemoryInstruction(handle.writableRoot) : "";
191
+ inject(handle, opts) {
192
+ const writeChannel = handle.writeScope !== null && opts?.writeToolMounted !== false;
193
+ const instruction = writeChannel ? buildMemoryInstruction(handle.writableRoot) : "";
193
194
  const indexPath = join(handle.writableRoot, MEMORY_INDEX_FILENAME);
194
195
  const onDisk = readSafe(indexPath);
195
196
  const indexText = onDisk !== undefined && onDisk.trim() !== "" ? onDisk : handle.indexText;
196
197
  const truncated = truncateIndex(indexText);
197
198
  const index = composeMemoryBlock(truncated, handle.writeScope ?? handle.scopes[0] ?? "memory");
198
- const indexSeed = handle.writeScope !== null && onDisk !== undefined && (onDisk.trim() === "" || truncated === onDisk)
199
+ const indexSeed = writeChannel && onDisk !== undefined && (onDisk.trim() === "" || truncated === onDisk)
199
200
  ? { path: indexPath, content: onDisk }
200
201
  : undefined;
201
202
  let announcements;
@@ -36,7 +36,7 @@ export type SelectiveRecallResult = {
36
36
  export declare function resolveLinkedIds(headers: MemoryNoteHeader[], selected: MemoryNoteRecord[], max: number): string[];
37
37
  export declare function buildManifestText(headers: MemoryNoteHeader[]): string;
38
38
  export declare function validateSelectedIds(headers: MemoryNoteHeader[], ids: string[], max: number): string[];
39
- export declare function composeSelectiveBody(manifestText: string, selected: MemoryNoteRecord[], nowMs: number, linked?: MemoryNoteRecord[], recallable?: boolean): string;
39
+ export declare function composeSelectiveBody(manifestText: string, selected: MemoryNoteRecord[], nowMs: number, linked?: MemoryNoteRecord[], recallable?: boolean, recallToolName?: string): string;
40
40
  export declare function encodeSurfacedKey(scope: string, id: string): string;
41
41
  export interface ScopedNoteHeader extends MemoryNoteHeader {
42
42
  scope: string;
@@ -102,7 +102,7 @@ export function validateSelectedIds(headers, ids, max) {
102
102
  }
103
103
  return out;
104
104
  }
105
- export function composeSelectiveBody(manifestText, selected, nowMs, linked = [], recallable = true) {
105
+ export function composeSelectiveBody(manifestText, selected, nowMs, linked = [], recallable = true, recallToolName) {
106
106
  const renderNote = (r, label) => {
107
107
  const ageMs = nowMs - r.mtimeMs;
108
108
  const verify = r.timestampMissing || ageMs > ONE_DAY_MS ? " — verify it's still current" : "";
@@ -124,8 +124,9 @@ export function composeSelectiveBody(manifestText, selected, nowMs, linked = [],
124
124
  body = `${buf.subarray(0, cut).toString("utf8")}\n<selected notes truncated to ${SELECTED_BODY_MAX_BYTES} bytes>`;
125
125
  }
126
126
  const selectedSection = selected.length > 0 ? `\n\nRelevant notes:\n${body}` : "\n\n(no notes selected as relevant to this task)";
127
+ const recallCall = recallToolName !== undefined ? `call ${recallToolName}` : `search the memory-recall tool bound to this scope`;
127
128
  const manifestSection = recallable
128
- ? `Memory index — to load a note in full, call Recall with keywords from its description:\n${sanitizeUntrustedText(manifestText, ["user_memory"])}`
129
+ ? `Memory index — to load a note in full, ${recallCall} with keywords from its description:\n${sanitizeUntrustedText(manifestText, ["user_memory"])}`
129
130
  : "";
130
131
  return `${manifestSection}${selectedSection}`;
131
132
  }
@@ -8,6 +8,7 @@ export interface PrepareMemoryInput {
8
8
  memoryWriteGateRef: {
9
9
  current?: BeforeWriteHook;
10
10
  };
11
+ writeToolsMounted: boolean;
11
12
  admissionCtx: {
12
13
  orgMemoryDenied: boolean;
13
14
  complianceDegraded: boolean;
@@ -135,7 +135,7 @@ export async function prepareMemory(input) {
135
135
  const writeIsPersonal = p.writePlane === "personal";
136
136
  writeEngine = writeIsPersonal ? personalEngine : projectEngine;
137
137
  writeHandle = writeIsPersonal ? personalHandle : projectHandle;
138
- injectFn = () => mergeInjections(projectEngine.inject(projectHandle), personalEngine.inject(personalHandle));
138
+ injectFn = () => mergeInjections(projectEngine.inject(projectHandle, { writeToolMounted: input.writeToolsMounted }), personalEngine.inject(personalHandle, { writeToolMounted: input.writeToolsMounted }));
139
139
  harvestBoth = async () => {
140
140
  const writeFirst = writeIsPersonal ? [personalEngine, personalHandle] : [projectEngine, projectHandle];
141
141
  const readOther = writeIsPersonal ? [projectEngine, projectHandle] : [personalEngine, personalHandle];
@@ -159,7 +159,7 @@ export async function prepareMemory(input) {
159
159
  const handle = await personalEngine.materialize(memorySpec.scopes, memorySpec.writeScope);
160
160
  writeEngine = personalEngine;
161
161
  writeHandle = handle;
162
- injectFn = () => personalEngine.inject(handle);
162
+ injectFn = () => personalEngine.inject(handle, { writeToolMounted: input.writeToolsMounted });
163
163
  harvestBoth = () => personalEngine.harvest(handle);
164
164
  }
165
165
  else {
@@ -171,7 +171,7 @@ export async function prepareMemory(input) {
171
171
  const handle = await engine.materialize(memorySpec.scopes, memorySpec.writeScope);
172
172
  writeEngine = engine;
173
173
  writeHandle = handle;
174
- injectFn = () => engine.inject(handle);
174
+ injectFn = () => engine.inject(handle, { writeToolMounted: input.writeToolsMounted });
175
175
  harvestBoth = () => engine.harvest(handle);
176
176
  }
177
177
  memoryWriteGateRef.current = (w) => writeEngine.gateWrite(writeHandle, w.key, w.content);
@@ -16,6 +16,7 @@ import { type BrainCallGuardrailRef } from "../../brain/timeout.js";
16
16
  import { type OutputRef, type BlockedRef, type SkillListingEntry } from "./synthetic-tools.js";
17
17
  import type { MemoryEngine } from "../memory-engine/engine.js";
18
18
  import { type ToolManifestRow } from "../../prompt-assembly/tool-catalog.js";
19
+ import type { ToolDisclosureManifest } from "../trace.js";
19
20
  import type { TaskNotificationPayload } from "../task-notification.js";
20
21
  import { type CwdRef } from "../../tools/fs/index.js";
21
22
  import { type WorkflowSizeGuideline } from "../../orchestration/workflow-size-guideline.js";
@@ -111,6 +112,7 @@ export interface Prepared {
111
112
  elements: import("../../prompt-assembly/turn-snapshot.js").CacheIdentityElements;
112
113
  };
113
114
  lowering?: import("../../prompt-assembly/turn-snapshot.js").LoweringRecord;
115
+ toolDisclosure?: ToolDisclosureManifest;
114
116
  };
115
117
  epochDeclaredSections: import("../../prompt-assembly/epoch.js").EpochDeclaredSections;
116
118
  turnSnapshot?: import("../../prompt-assembly/turn-snapshot.js").TurnPromptSnapshot;
@@ -125,6 +127,7 @@ export interface Prepared {
125
127
  activeTools: Set<string>;
126
128
  deferredToolNames?: ReadonlySet<string>;
127
129
  toolMaterializeStatic: boolean;
130
+ staticFaceFor?: (name: string) => boolean;
128
131
  memoryEngineSession?: {
129
132
  engine: MemoryEngine;
130
133
  handle: MemorySessionHandle;