@sema-agent/core 5.14.0 → 5.15.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.
package/CHANGELOG.md CHANGED
@@ -1,7 +1,129 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.15.0 — 2026-08-06
4
+
5
+ ### BREAKING
6
+
7
+ - **`TaskSpec.toolMaterializeStrategy` defaults to `"swap"` again** (it defaulted to `"static"` for one
8
+ release window). Under `"static"` a deferred tool's placeholder never swaps: the tools block keeps
9
+ advertising `{"type":"object","properties":{}}` and the real schema reaches the model only as text in
10
+ the `ToolSearch` result. That is fine for a model whose tool arguments are free-form JSON, and
11
+ structurally unusable for one served with **constrained decoding**, where the advertised wire schema is
12
+ enforced at the sampler. On the mainstream implementations — grammar compiled from the declared
13
+ `properties` — `{}` becomes the only argument object the model can emit, so the engine's corrective
14
+ invalid-arguments rejection cannot be acted on and the run burns turns without converging. (A strictly
15
+ draft-faithful decoder would read the absent `additionalProperties` as permissive; the model still has
16
+ no schema to form other keys from, so that path degrades to guessing rather than converging.)
17
+ The condition for `"static"` is also a property of the WHOLE serving route rather than of one model —
18
+ the degrading brain can fall back across providers mid-run and `limits.degrade` can hand the run to a
19
+ different model outright, and neither rebuilds the disclosure strategy — so it is now a strict explicit
20
+ opt-in: set it only if every model the run can reach, fallback targets included, has been verified to
21
+ read schemas from result text. **Who is affected:** a deployment that relied on the default to keep its
22
+ provider prompt cache intact across activations now sees the pre-`"static"` behavior (activation calls
23
+ `setTools`, invalidating the cache suffix) unless it sets `toolMaterializeStrategy: "static"` itself.
24
+ **Pinned faces that move with the default:** the `tools_delta` attachment says "now available … Their
25
+ full schemas are loaded" (not "now active"), the `ToolSearch` description says activation "loads its
26
+ full parameter schema" (not "returns … in the result"), the `ToolSearch` result no longer inlines
27
+ `parameters:` per matched tool, and the post-activation tools block carries the real schema again — a
28
+ consumer pinning any of these under the default must re-pin. `SEMA_TOOL_MATERIALIZE_STRATEGY` still
29
+ fills an absent spec value, and an env-sourced `"static"` on a `deferSelfResolve: false` task still
30
+ degrades to `"swap"` (the explicit spec pairing stays refused with `config.tool_materialize_unreachable`).
31
+
32
+ ### Added
33
+
34
+ - **`prompt.assembled` reports the resolved deferred-tool disclosure strategy** —
35
+ `toolDisclosure: {deferredTools, strategy, source}` (new exported type `ToolDisclosureManifest`;
36
+ absent when the leg deferred nothing). `strategy` is the EFFECTIVE value after the direct-call-lane
37
+ interlock, and `source` names the seat that chose it: `"spec"` / `"env"` / `"default"` /
38
+ `"degraded_no_direct_lane"` (the interlock narrowed a `"static"` request to `"swap"`; that value
39
+ implies an env-sourced request, since the spec pairing is refused at prepare and the default is not
40
+ `"static"`). "Which strategy did this leg run under, and who chose it" was previously answerable only
41
+ by re-deriving spec > env > release default by hand.
42
+
43
+ ### Fixed
44
+
45
+ - **Model-visible text stops teaching retired tool names** (#181 census F batch). Three faces taught
46
+ names a model cannot call (RB-476-A retired alias resolution — a retired name is a loud roster
47
+ miss): the selective-recall manifest affordance said "call Recall" (a name core registers as
48
+ removed; the affordance's tool name is now caller-supplied via `composeSelectiveBody`'s new
49
+ optional `recallToolName` parameter, name-free when omitted); both `ORCHESTRATION_GUIDANCE`
50
+ blocks said `run_workflow` — on the deferred short form a complete dead link, since `ToolSearch`'s
51
+ `select:` arm matches exact wire names — and now interpolate `RUN_WORKFLOW_TOOL_NAME`
52
+ (`"Workflow"`); TaskOutput's lanes description and `task_id` parameter said `RunWorkflow` and now
53
+ say `Workflow`. A consumer pinning any of these strings must re-pin. A new gate
54
+ (`test/tool-name-literal-gate.test.ts`) scans every src string literal against the whole
55
+ `RETIRED_TOOL_NAMES` table with a frozen per-bucket ratchet, so this class cannot regrow silently.
56
+ - **The `# Memory` write instruction requires the Write tool on the roster** (behavior face). The
57
+ memory engine's injection gated the CC-verbatim write instruction on a writable scope alone;
58
+ a `handsReadOnly` (or hands-less) run with a memory `writeScope` was instructed to write with a
59
+ tool its roster does not carry. `MemoryEngine.inject` takes an optional `writeToolMounted`
60
+ (omitted ⇒ historic behavior for engine-direct hosts; the Runner passes the real hands-write-half
61
+ predicate), and the RB-276 index seed follows the same gate. **Who is affected:** a deployment
62
+ running memory-enabled tasks with `handsReadOnly: true` (or no execution env) no longer receives
63
+ the `# Memory` instruction or the index read-seed on those runs — the fenced index still injects.
64
+ - **The gh rate-limit hint's Monitor clause follows the real Monitor mount.** The SR-2 reminder
65
+ unconditionally closed with "use the Monitor tool"; Monitor mounts only when background task tools
66
+ do. `ghRateLimitHint` (and `HandsToolkitOptions`/`createBashTool` `monitorToolActive`) drop exactly
67
+ that clause when the caller declares Monitor unmounted; omitted keeps the byte-identical historic
68
+ wording.
69
+ - **`ToolSearch` no longer activates a name the roster has withdrawn.** The deferred registry is frozen
70
+ at prepare; the roster is not — an MCP refresh can take a deferred name off the mount. An exact
71
+ `select:` still resolved through the stale registry, added the name to the active set, "succeeded" at
72
+ a rematerialize that mounted nothing, and announced a tool that could not be called. Withdrawn names
73
+ are now reported as `No longer available: <names> — withdrawn by its provider and cannot be activated
74
+ or called`, ride the result's `details.missing`, and never enter the active set.
75
+ - **The deferred placeholder's corrective round is now bounded.** A shape-invalid direct call is answered
76
+ with the real declared schema so the corrected call is one turn away — which assumes the caller can act
77
+ on a schema once it has one. A caller whose arguments are constrained to the ADVERTISED schema cannot,
78
+ and re-sent the identical rejected arguments for as many turns as the budget allowed. After
79
+ `DEFERRED_NO_PROGRESS_LIMIT` (3) consecutive rejections of the IDENTICAL failure shape on one
80
+ placeholder, the lane stops re-teaching: it returns a terminal rejection that names the condition with
81
+ the stable token `tool.deferred_schema_incompatible` (in the model-facing text and on the tool result's
82
+ `details.noProgress`) and votes the tool batch terminated. Any other outcome — a different validation
83
+ error, or a successful call — resets the count, so the bound tracks being STUCK, not being wrong.
84
+ `TaskResult.errorCode` is unchanged: this terminal is reported on the tool result, not as a task
85
+ failure code.
86
+ - **The invalid-arguments rejection no longer overstates where its schema lives.** It used to say "use it
87
+ for this and later calls" under both strategies; under `"static"` the schema is carried by that one
88
+ result and is gone with the next compaction. The text now says either "the next request's tools list
89
+ will advertise it too" (swap) or "carried by THIS result only" plus the `ToolSearch` re-select spelling
90
+ (static). A declaration too large to inline is called an "ABRIDGED copy" instead of "full" — past the
91
+ model-facing bound the serialization comes back with its middle spliced out and is not valid JSON.
92
+ - **A declaration the static face cannot carry in text is exempted from the static face per tool.** Under
93
+ `"static"` the only in-context schema carrier is result text; a declaration that does not serialize, or
94
+ that the model-facing bound truncates, has no carrier there, and the tool was announced as activated
95
+ with a schema the model never received in usable form. Such a tool now materializes into the tools block
96
+ on activation (the rest of the run stays static), the `ToolSearch` line says where its declaration is,
97
+ and the result head stops promising schemas it did not print. The `tools_delta` boundary frame carries
98
+ the same exception (one `Exception: <names> — too large to inline…` line) instead of asserting compact
99
+ placeholders for a name whose full schema is visibly on the wire; with no exemptions in play the frame
100
+ is byte-identical to before. The exemption is read from the LIVE roster on every call, so an MCP
101
+ refresh that changes a declaration's size cannot leave the lane describing the old carrier — and the
102
+ exemption is MONOTONE within a leg: a declaration that shrinks back under the bound does not put the
103
+ compact placeholder back on a tool whose schema was only ever delivered through the tools block.
104
+
105
+ ### Fixed (receive-time additions)
106
+
107
+ - Ruled at receive: the deferred no-progress terminal stays a TOOL-RESULT face (`tool.deferred_schema_incompatible`
108
+ marker + `details.noProgress`) and does NOT mint a `TaskResult.errorCode` — the run can continue on other
109
+ tools, so a run-level code would misclassify a per-tool condition.
110
+ - Ruled at receive: a malformed `SEMA_TOOL_MATERIALIZE_STRATEGY` still refuses the task loudly even when a
111
+ valid spec value is present — a deployment running with a broken env var must hear about it before the
112
+ first task that omits the spec value inherits the typo.
113
+
3
114
  ## 5.14.0 — 2026-08-06
4
115
 
116
+ > **Post-release addendum (2026-08-06, disclosure gap reported by a consumer):** the fail-open survey
117
+ > (shipped in 5.13.0's window, but its consequence surfaced against 5.14.0 pickups) hardened
118
+ > `canonicalizeTarget`'s existence probe — an ERRORED `exists` no longer reads as "does not exist yet"
119
+ > and instead reports `{ok:false, unresolvedSymlink:true}`. Combined with `createSensitivePathPolicy`'s
120
+ > deny on `unresolvedSymlink`, an execution env whose `exists` always throws (a stub/lexical env with
121
+ > no real filesystem) now sees every write target denied — the gate degrades from "guarded" to
122
+ > "closed". That is the intended fail-closed direction for a PROBE FAILURE, but such an env should
123
+ > answer honestly instead of throwing: return `ok(false)` ("this env cannot see a filesystem") and the
124
+ > gate resolves normally. Also: `DelegationTaskType` was named in the ship note but missed the package
125
+ > root; the export lands in the next release — type the `taskType` key as an open `string` until then.
126
+
5
127
  ### BREAKING
6
128
 
7
129
  - **`CheckpointStore.setPendingSteer` APPENDS to a bounded ordered queue instead of overwriting a single seat**
@@ -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,
@@ -243,6 +243,9 @@ export function winnerFromOutcome(outcome) {
243
243
  }
244
244
  const CHECKPOINT_CONTROL_CHARS_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
245
245
  export function validatePendingSteer(steer) {
246
+ if (steer === null || typeof steer !== "object") {
247
+ throw new CheckpointError("steering.invalid_content", `steering entry must be an object carrying text/trusted (got ${steer === null ? "null" : typeof steer})`);
248
+ }
246
249
  const allowed = new Set(PENDING_STEER_FROZEN_FIELDS.filter((f) => f !== "seq"));
247
250
  for (const key of Object.keys(steer)) {
248
251
  if (!allowed.has(key)) {
@@ -250,53 +253,76 @@ export function validatePendingSteer(steer) {
250
253
  `a field this worker does not know would be dropped silently on the parked leg`);
251
254
  }
252
255
  }
253
- if (steer.actor !== undefined)
254
- validateActorAssertion(steer.actor);
255
- if (steer.inputId !== undefined && (steer.inputId === "" || steer.inputId.length > MAX_STEER_INPUT_ID_CHARS)) {
256
+ const text = steer.text;
257
+ const trusted = steer.trusted;
258
+ const actor = steer.actor;
259
+ const inputId = steer.inputId;
260
+ const priority = steer.priority;
261
+ if (typeof text !== "string") {
262
+ throw new CheckpointError("steering.invalid_content", "steering text must be a string");
263
+ }
264
+ if (typeof trusted !== "boolean") {
265
+ throw new CheckpointError("steering.invalid_content", "steering trusted must be a boolean stated by the caller");
266
+ }
267
+ if (inputId !== undefined && typeof inputId !== "string") {
268
+ throw new CheckpointError("steering.invalid_content", "steering inputId must be a string when supplied");
269
+ }
270
+ if (priority !== undefined && priority !== "now" && priority !== "next" && priority !== "later") {
271
+ 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")`);
272
+ }
273
+ const actorTwin = actor === undefined ? undefined : captureActorAssertion(actor);
274
+ if (inputId !== undefined && (inputId === "" || inputId.length > MAX_STEER_INPUT_ID_CHARS)) {
256
275
  throw new CheckpointError("steering.invalid_content", `steering inputId must be a non-empty string of at most ${MAX_STEER_INPUT_ID_CHARS} characters`);
257
276
  }
258
- if (steer.inputId === LEGACY_PENDING_STEER_INPUT_ID) {
277
+ if (inputId === LEGACY_PENDING_STEER_INPUT_ID) {
259
278
  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
279
  }
261
- if (sanitizeUntrustedText(steer.text) !== steer.text) {
280
+ if (sanitizeUntrustedText(text) !== text) {
262
281
  throw new CheckpointError("steering.invalid_content", "steering text must not contain a system-reminder break-out tag");
263
282
  }
264
- if (CHECKPOINT_CONTROL_CHARS_RE.test(steer.text)) {
283
+ if (CHECKPOINT_CONTROL_CHARS_RE.test(text)) {
265
284
  throw new CheckpointError("steering.invalid_content", "steering text must not contain control characters");
266
285
  }
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})`);
286
+ if (text.length > MAX_PENDING_STEER_CHARS) {
287
+ throw new CheckpointError("steering.invalid_content", `steering text must be at most ${MAX_PENDING_STEER_CHARS} characters (got ${text.length})`);
269
288
  }
270
289
  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 } : {}),
290
+ text,
291
+ trusted,
292
+ ...(actorTwin !== undefined ? { actor: actorTwin } : {}),
293
+ inputId: inputId ?? uuidv7(),
294
+ ...(priority !== undefined ? { priority: priority } : {}),
276
295
  };
277
296
  }
278
297
  export const MAX_ACTOR_FIELD_CHARS = 256;
279
298
  export const MAX_STEER_INPUT_ID_CHARS = 128;
280
299
  export const ACTOR_ASSERTION_FROZEN_FIELDS = ["id", "hostAsserted", "issuer"];
281
- function validateActorAssertion(actor) {
300
+ function captureActorAssertion(actor) {
301
+ if (actor === null || typeof actor !== "object") {
302
+ throw new CheckpointError("steering.invalid_content", `actor must be an object carrying the attribution fields (got ${actor === null ? "null" : typeof actor})`);
303
+ }
304
+ const supplied = actor;
282
305
  const allowed = new Set(ACTOR_ASSERTION_FROZEN_FIELDS);
283
306
  for (const key of Object.keys(actor)) {
284
307
  if (!allowed.has(key)) {
285
308
  throw new CheckpointError("steering.invalid_content", `unknown actor field "${key}" — the persisted actor shape is frozen (${[...allowed].join(", ")})`);
286
309
  }
287
310
  }
288
- if (typeof actor.id !== "string" || actor.id === "" || actor.id.length > MAX_ACTOR_FIELD_CHARS) {
311
+ const id = supplied.id;
312
+ const hostAsserted = supplied.hostAsserted;
313
+ const issuer = supplied.issuer;
314
+ if (typeof id !== "string" || id === "" || id.length > MAX_ACTOR_FIELD_CHARS) {
289
315
  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
316
  }
291
- if (typeof actor.hostAsserted !== "boolean") {
317
+ if (typeof hostAsserted !== "boolean") {
292
318
  throw new CheckpointError("steering.invalid_content", "actor.hostAsserted must be a boolean stated by the host");
293
319
  }
294
- if (actor.issuer !== undefined && (actor.issuer === "" || actor.issuer.length > MAX_ACTOR_FIELD_CHARS)) {
320
+ if (issuer !== undefined && (typeof issuer !== "string" || issuer === "" || issuer.length > MAX_ACTOR_FIELD_CHARS)) {
295
321
  throw new CheckpointError("steering.invalid_content", `actor.issuer must be a non-empty string of at most ${MAX_ACTOR_FIELD_CHARS} characters`);
296
322
  }
297
323
  for (const [field, value] of [
298
- ["id", actor.id],
299
- ["issuer", actor.issuer],
324
+ ["id", id],
325
+ ["issuer", issuer],
300
326
  ]) {
301
327
  if (value === undefined)
302
328
  continue;
@@ -304,13 +330,7 @@ function validateActorAssertion(actor) {
304
330
  throw new CheckpointError("steering.invalid_content", `actor.${field} must not contain control characters or a system-reminder break-out tag`);
305
331
  }
306
332
  }
307
- }
308
- function freezeActorAssertion(actor) {
309
- return {
310
- id: actor.id,
311
- hostAsserted: actor.hostAsserted,
312
- ...(actor.issuer !== undefined ? { issuer: actor.issuer } : {}),
313
- };
333
+ return { id, hostAsserted, ...(issuer !== undefined ? { issuer } : {}) };
314
334
  }
315
335
  export function readPendingSteerQueue(state) {
316
336
  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;
@@ -38,7 +38,7 @@ import { pathToUri } from "../lsp-protocol.js";
38
38
  import { DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, createOffloadPersist, firstPartyOffloadPolicy, InMemoryToolResultStore, RunnerSharedToolResultStore, ScopedToolResultStore, isVolatileOffloadStore, OFFLOAD_TOOL_NAME, createReadToolResultTool, withToolResultOffload, } from "../tool-result-store.js";
39
39
  import { OUTPUT_TOOL_NAME, REPORT_FINDINGS_TOOL_NAME, SKILL_CONTENT_MAX_CHARS, SKILL_TOOL_NAME, createOutputTool, createReportBlockedTool, createReportFindingsTool, createSkillTool, normalizeSkills } from "./synthetic-tools.js";
40
40
  import { compileOutputSchema } from "./strict-output-schema.js";
41
- import { TOOL_SEARCH_NAME, buildDeferredRegistry, classifyDeferred, extractDiscoveredToolNames, createPlaceholderTool, createToolSearchTool, } from "./tool-disclosure.js";
41
+ import { TOOL_SEARCH_NAME, buildDeferredRegistry, classifyDeferred, extractDiscoveredToolNames, createPlaceholderTool, createToolSearchTool, staticSchemaRenderable, } from "./tool-disclosure.js";
42
42
  import { composeMemoryBlock } from "../memory.js";
43
43
  import { preflightLockedConfig } from "../locked-config.js";
44
44
  import { COMPLIANCE_CAPABILITIES, WEB_FETCH_TOOL_NAME, complianceCallDenial, resolveComplianceDenies } from "../compliance.js";
@@ -629,6 +629,13 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
629
629
  if (extraBodyCollisions.length > 0) {
630
630
  deps.onError?.(new Error(`model.extraBody contains reserved key(s) [${extraBodyCollisions.join(", ")}] that core owns and sets itself — they are ignored. Remove them from extraBody.`), { phase: "config", sessionId });
631
631
  }
632
+ if (model.extraBody !== undefined && Object.prototype.hasOwnProperty.call(model.extraBody, "max_completion_tokens")) {
633
+ const e = new Error(`model.extraBody supplies "max_completion_tokens" — this spelling is engine-owned (the overflow retry ` +
634
+ `adjusts the per-request output cap, and a constant extraBody value would override that adjustment on ` +
635
+ `every retry). Set the cap via limits.maxOutputTokens (per request) or model.maxTokens instead.`);
636
+ e.code = "config.extra_body_output_cap";
637
+ throw e;
638
+ }
632
639
  const nestedStats = { tokens: 0, turns: 0, tasks: 0, costMicroUsd: 0, anyUnpriced: false };
633
640
  if (resume) {
634
641
  nestedStats.tokens = resume.seed.nestedStats.tokens;
@@ -1592,6 +1599,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1592
1599
  return memoryWriteGateRef.current?.(w);
1593
1600
  },
1594
1601
  mountBackgroundTaskTools: false,
1602
+ monitorToolActive: backgroundTaskToolsActive && !(toolFaceSnapshot.exclude?.includes("Monitor") ?? false),
1595
1603
  });
1596
1604
  {
1597
1605
  const bandNames = new Set(band.flatMap((t) => [t.name, ...(t.aliases ?? [])]));
@@ -1844,6 +1852,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1844
1852
  sessionId,
1845
1853
  taskRootPath,
1846
1854
  memoryWriteGateRef,
1855
+ writeToolsMounted: handsEnabled && spec.handsReadOnly !== true && !(toolFaceSnapshot.exclude?.includes("Write") ?? false),
1847
1856
  admissionCtx: {
1848
1857
  orgMemoryDenied: complianceDenies.has("org_memory_mount"),
1849
1858
  complianceDegraded,
@@ -2282,6 +2291,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2282
2291
  .map((s) => ({ name: inlineUntrusted(s.name, 160), ...(s.error !== undefined ? { error: inlineUntrusted(s.error, 240) } : {}) }));
2283
2292
  let toolsDeltaRef;
2284
2293
  let toolMaterializeStatic = false;
2294
+ const staticFaceForRef = {};
2285
2295
  if (deferred.size > 0 || failedMcpServers.length > 0) {
2286
2296
  toolsDeltaRef = { pending: [], pendingRemoved: [], pendingReadded: [], pendingFailed: failedMcpServers };
2287
2297
  }
@@ -2293,6 +2303,33 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2293
2303
  throw e;
2294
2304
  }
2295
2305
  const registry = buildDeferredRegistry(deferred, tools);
2306
+ const envStrategy = process.env.SEMA_TOOL_MATERIALIZE_STRATEGY;
2307
+ if (envStrategy !== undefined && envStrategy !== "swap" && envStrategy !== "static") {
2308
+ const e = new Error(`SEMA_TOOL_MATERIALIZE_STRATEGY must be "swap" or "static" (got ${JSON.stringify(envStrategy)}).`);
2309
+ e.code = "config.tool_materialize_invalid";
2310
+ throw e;
2311
+ }
2312
+ const requestedStrategy = spec.toolMaterializeStrategy ?? envStrategy ?? "swap";
2313
+ const laneDegrade = requestedStrategy === "static" && spec.deferSelfResolve === false;
2314
+ const materializeStatic = requestedStrategy === "static" && !laneDegrade;
2315
+ toolMaterializeStatic = materializeStatic;
2316
+ promptManifest.toolDisclosure = {
2317
+ deferredTools: deferred.size,
2318
+ strategy: materializeStatic ? "static" : "swap",
2319
+ source: laneDegrade ? "degraded_no_direct_lane" : spec.toolMaterializeStrategy !== undefined ? "spec" : envStrategy !== undefined ? "env" : "default",
2320
+ };
2321
+ const everExempt = new Set();
2322
+ const staticFaceFor = (name) => {
2323
+ if (!materializeStatic)
2324
+ return false;
2325
+ if (everExempt.has(name))
2326
+ return false;
2327
+ if (staticSchemaRenderable(tools.find((t) => t.name === name)?.parameters))
2328
+ return true;
2329
+ everExempt.add(name);
2330
+ return false;
2331
+ };
2332
+ staticFaceForRef.current = staticFaceFor;
2296
2333
  let activationChain = Promise.resolve();
2297
2334
  const serializeActivation = (section) => {
2298
2335
  const p = activationChain.then(section);
@@ -2314,6 +2351,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2314
2351
  };
2315
2352
  },
2316
2353
  ...(executionMode !== undefined ? { executionMode } : {}),
2354
+ staticFace: () => staticFaceFor(name),
2317
2355
  activate: async () => serializeActivation(async () => {
2318
2356
  if (activeTools.has(name))
2319
2357
  return undefined;
@@ -2358,16 +2396,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2358
2396
  };
2359
2397
  offloadReachableToolsRef.current = callableToolNames;
2360
2398
  let toolSearch;
2361
- const envStrategy = process.env.SEMA_TOOL_MATERIALIZE_STRATEGY;
2362
- if (envStrategy !== undefined && envStrategy !== "swap" && envStrategy !== "static") {
2363
- const e = new Error(`SEMA_TOOL_MATERIALIZE_STRATEGY must be "swap" or "static" (got ${JSON.stringify(envStrategy)}).`);
2364
- e.code = "config.tool_materialize_invalid";
2365
- throw e;
2366
- }
2367
- const materializeStatic = (spec.toolMaterializeStrategy ?? envStrategy ?? "static") === "static" && spec.deferSelfResolve !== false;
2368
- toolMaterializeStatic = materializeStatic;
2369
2399
  const buildToolList = (active) => {
2370
- const list = tools.map((t) => (deferred.has(t.name) && (materializeStatic || !active.has(t.name)) ? placeholders.get(t.name) : t));
2400
+ const list = tools.map((t) => (deferred.has(t.name) && (staticFaceFor(t.name) || !active.has(t.name)) ? placeholders.get(t.name) : t));
2371
2401
  list.push(toolSearch);
2372
2402
  return list;
2373
2403
  };
@@ -2408,8 +2438,9 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2408
2438
  listingRide: (newly) => listingRideRef.current?.(newly),
2409
2439
  mountedNames: callableToolNames,
2410
2440
  directCallEnabled: spec.deferSelfResolve !== false,
2441
+ isMounted: (name) => tools.some((t) => t.name === name),
2411
2442
  ...(materializeStatic
2412
- ? { staticSchemaFor: (name) => tools.find((t) => t.name === name)?.parameters }
2443
+ ? { staticSchemaFor: (name) => (staticFaceFor(name) ? tools.find((t) => t.name === name)?.parameters : undefined) }
2413
2444
  : {}),
2414
2445
  serializeActivation,
2415
2446
  });
@@ -4108,7 +4139,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4108
4139
  : undefined;
4109
4140
  overheadState.promptChars = systemPrompt.length;
4110
4141
  const preparedHolder = {};
4111
- const buildPrepared = () => ({ harness, session, sessionId, taskRootPath, model, thinking, compModel, mcp: mcp, ...(a2a !== undefined && a2a.tools.length > 0 ? { a2a } : {}), blockedRef, outputRef, abortController, conflictRef, blockedToolCalls, nestedStats, ...(rewindNotes.length > 0 ? { rewindNotes } : {}), cwdRef: handsCwdRef, ...(worktreeSessionRef !== undefined ? { worktreeSessionRef } : {}), ...(workspaceStateSettle !== undefined ? { workspaceStateSettle } : {}), denyNarrowingPolicy, ...(basePolicyForResumeEdit !== undefined ? { basePolicyForResumeEdit } : {}), releaseSignal, settleContentAskBindings, cacheBreakDetector, cacheFingerprint, wiringManifest, promptManifest, epochDeclaredSections, activeTools, ...(deferred.size > 0 ? { deferredToolNames: deferred } : {}), toolMaterializeStatic, ownedEnv, suspendRef, suspendProgressRef, reviewRef, remoteEnvFailures, reviewRequestRef, suspendLoopRef, suspendForResource, ...(suspendForPlatformLimit !== undefined ? { suspendForPlatformLimit } : {}), ...(envLifetimeSuspendAt !== undefined ? { envLifetimeSuspendAt } : {}), ...(usageGovernance !== undefined ? { usageGovernance } : {}), callIssuedAtRef, brainCallGuardrailRef, suspendForReview, resourceLedger: priorLedger, liveSpendRef, humanReviewRef, now, tools, toolEffects, wakeRecovered, promptOverheadTokens, lastBrainContext, readTaskFile, recentlyReadFiles, normalizeAttachmentPath, isDedupStubResult, ...(onCompactionApplied ? { onCompactionApplied } : {}), compactionReuseRef, trimPressureRef, ...(memoryEngineSession ? { memoryEngineSession } : {}), ...(subagentRetain ? { subagentRetain } : {}), ...(lspDiagnostics && nudgeLspOnEdit ? { lspDiagnostics: { registry: lspDiagnostics, nudge: nudgeLspOnEdit, runIdent: lspRunIdent } } : {}), planModeRef, ...(dateChange ? { dateChange } : {}), ...(instructionSources ? { instructionSources } : {}), ...(workflowSizeGuideline ? { workflowSizeGuideline } : {}), ...(detectExternalChanges ? { detectExternalChanges } : {}), ...(toolsDeltaRef ? { toolsDeltaRef } : {}), ...(agentListing ? { agentListing } : {}), ...(skillsListing ? { skillsListing } : {}), announcedListingsRef, listBackgroundTasks, ...(turnSnapshotRef.current !== undefined ? { turnSnapshot: turnSnapshotRef.current } : {}), ...(centerCompactionCandidate !== undefined ? { centerCompactionCandidate } : {}) });
4142
+ const buildPrepared = () => ({ harness, session, sessionId, taskRootPath, model, thinking, compModel, mcp: mcp, ...(a2a !== undefined && a2a.tools.length > 0 ? { a2a } : {}), blockedRef, outputRef, abortController, conflictRef, blockedToolCalls, nestedStats, ...(rewindNotes.length > 0 ? { rewindNotes } : {}), cwdRef: handsCwdRef, ...(worktreeSessionRef !== undefined ? { worktreeSessionRef } : {}), ...(workspaceStateSettle !== undefined ? { workspaceStateSettle } : {}), denyNarrowingPolicy, ...(basePolicyForResumeEdit !== undefined ? { basePolicyForResumeEdit } : {}), releaseSignal, settleContentAskBindings, cacheBreakDetector, cacheFingerprint, wiringManifest, promptManifest, epochDeclaredSections, activeTools, ...(deferred.size > 0 ? { deferredToolNames: deferred } : {}), toolMaterializeStatic, ...(staticFaceForRef.current !== undefined ? { staticFaceFor: staticFaceForRef.current } : {}), ownedEnv, suspendRef, suspendProgressRef, reviewRef, remoteEnvFailures, reviewRequestRef, suspendLoopRef, suspendForResource, ...(suspendForPlatformLimit !== undefined ? { suspendForPlatformLimit } : {}), ...(envLifetimeSuspendAt !== undefined ? { envLifetimeSuspendAt } : {}), ...(usageGovernance !== undefined ? { usageGovernance } : {}), callIssuedAtRef, brainCallGuardrailRef, suspendForReview, resourceLedger: priorLedger, liveSpendRef, humanReviewRef, now, tools, toolEffects, wakeRecovered, promptOverheadTokens, lastBrainContext, readTaskFile, recentlyReadFiles, normalizeAttachmentPath, isDedupStubResult, ...(onCompactionApplied ? { onCompactionApplied } : {}), compactionReuseRef, trimPressureRef, ...(memoryEngineSession ? { memoryEngineSession } : {}), ...(subagentRetain ? { subagentRetain } : {}), ...(lspDiagnostics && nudgeLspOnEdit ? { lspDiagnostics: { registry: lspDiagnostics, nudge: nudgeLspOnEdit, runIdent: lspRunIdent } } : {}), planModeRef, ...(dateChange ? { dateChange } : {}), ...(instructionSources ? { instructionSources } : {}), ...(workflowSizeGuideline ? { workflowSizeGuideline } : {}), ...(detectExternalChanges ? { detectExternalChanges } : {}), ...(toolsDeltaRef ? { toolsDeltaRef } : {}), ...(agentListing ? { agentListing } : {}), ...(skillsListing ? { skillsListing } : {}), announcedListingsRef, listBackgroundTasks, ...(turnSnapshotRef.current !== undefined ? { turnSnapshot: turnSnapshotRef.current } : {}), ...(centerCompactionCandidate !== undefined ? { centerCompactionCandidate } : {}) });
4112
4143
  const prepared = buildPrepared();
4113
4144
  preparedHolder.current = prepared;
4114
4145
  return prepared;