@sema-agent/core 5.24.0 → 5.26.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 (54) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/dist/agents/agent-definition.js +5 -0
  3. package/dist/agents/send-message-tool.js +1 -0
  4. package/dist/agents/subagent.d.ts +1 -0
  5. package/dist/agents/subagent.js +5 -0
  6. package/dist/core/checkpoint-store.d.ts +47 -8
  7. package/dist/core/checkpoint-store.js +1 -0
  8. package/dist/core/hooks.d.ts +12 -5
  9. package/dist/core/hooks.js +22 -4
  10. package/dist/core/memory-engine/dual-root.js +3 -1
  11. package/dist/core/memory-engine/engine.d.ts +45 -1
  12. package/dist/core/memory-engine/engine.js +40 -7
  13. package/dist/core/memory-engine/index.d.ts +1 -1
  14. package/dist/core/memory-engine/index.js +1 -1
  15. package/dist/core/permission-rule-consent.js +8 -1
  16. package/dist/core/permission-rule-org.d.ts +9 -0
  17. package/dist/core/permission-rule-org.js +12 -5
  18. package/dist/core/runner/compaction-call-options.d.ts +4 -4
  19. package/dist/core/runner/compaction-call-options.js +3 -4
  20. package/dist/core/runner/prepare-memory.d.ts +34 -15
  21. package/dist/core/runner/prepare-memory.js +85 -17
  22. package/dist/core/runner/prepare-task.d.ts +2 -0
  23. package/dist/core/runner/prepare-task.js +63 -11
  24. package/dist/core/runner/runtask.js +25 -8
  25. package/dist/core/store-contracts/tool-result-store-contract.d.ts +6 -0
  26. package/dist/core/store-contracts/tool-result-store-contract.js +24 -0
  27. package/dist/core/task-registry-agent.js +3 -3
  28. package/dist/core/task-registry-monitor.js +6 -5
  29. package/dist/core/tool-policy.d.ts +11 -0
  30. package/dist/core/tool-result-budget.d.ts +1 -1
  31. package/dist/core/tool-result-budget.js +3 -3
  32. package/dist/core/tool-result-store.d.ts +164 -9
  33. package/dist/core/tool-result-store.js +82 -23
  34. package/dist/core/types.d.ts +68 -0
  35. package/dist/core/untrusted-text.d.ts +6 -2
  36. package/dist/core/untrusted-text.js +1 -1
  37. package/dist/engine/session/import-validate.js +2 -1
  38. package/dist/index.d.ts +4 -4
  39. package/dist/index.js +4 -4
  40. package/dist/orchestration/workflow.js +2 -0
  41. package/dist/prompts/default.d.ts +11 -0
  42. package/dist/prompts/default.js +3 -0
  43. package/dist/stores/file/adoption/adopt.d.ts +23 -3
  44. package/dist/stores/file/adoption/adopt.js +1 -0
  45. package/dist/stores/file/adoption/marker.d.ts +26 -11
  46. package/dist/stores/file/fs-atomic.d.ts +1 -1
  47. package/dist/stores/file/permission-rule-store.d.ts +15 -1
  48. package/dist/stores/file/permission-rule-store.js +4 -1
  49. package/dist/stores/file/task-list-store.d.ts +15 -1
  50. package/dist/stores/file/task-list-store.js +2 -2
  51. package/dist/stores/file/tool-result-store.d.ts +45 -9
  52. package/dist/stores/file/tool-result-store.js +76 -9
  53. package/dist/tools/fs/fs-shared.js +26 -9
  54. package/package.json +1 -1
@@ -28,14 +28,89 @@ export interface ToolResultStore {
28
28
  * durable store still owns key hygiene for its storage layer). RB-266: the floor of that hygiene is
29
29
  * NOT per-backend — every implementation rejects the unsafe ref set via {@link assertSafeToolResultRef}
30
30
  * (a volatile backend silently accepting what a durable one rejects is the drift this closes).
31
+ *
32
+ * `provenance` (backlog #119, 2026-08-10) states WHO the stored bytes belong to, so a host-side read
33
+ * face can authorize a fetch without parsing the ref. Its rules, uniform on every backend:
34
+ * - **the write-once election decides the owner too** — whoever wins the race to store the content
35
+ * is the owner, and no later writer can change that. A backend that publishes content and owner
36
+ * as two objects (the file one does) may, if it is interrupted between them, leave an entry
37
+ * content-complete and UNOWNED; that is the fail-closed direction (a read face refuses it) and it
38
+ * is never mis-attribution, but it does mean "unowned" is a reachable state, not only a
39
+ * pre-provenance one. A single-row backend (memory/SQL) has no such window;
40
+ * - a later `put` on the same ref carrying the **same** provenance (or none) is the usual idempotent
41
+ * no-op — the stored owner is neither re-written nor erased;
42
+ * - a later `put` carrying a **different** provenance is a typed refusal
43
+ * ({@link ToolResultRefConflictError}, `code: "tool_result.ref_conflict"`), never a silent no-op:
44
+ * `ref` is the storage PRIMARY KEY, and with a host-supplied `sessionId` two distinct owners can
45
+ * land on one ref — under a silent write-once the second owner would read back the first owner's
46
+ * bytes, which is an identity fault, not a caching detail;
47
+ * - provenance must never be FINER-GRAINED than the ref's own key. A ref keyed on
48
+ * (session, tool call) is legitimately written by two different tasks of one session — the
49
+ * Runner shares one store across its tasks on purpose, and a BYOM brain may mint the same
50
+ * tool-call id in both — so stamping a `taskId` there would turn a designed sharing case into a
51
+ * refusal. Write sites whose ref carries the task (the background-agent and monitor spills key
52
+ * on the handle id) state it; the offload/budget/projection sites state the session only;
53
+ * - an entry stored **without** provenance (written by an older engine, or by a call site that has
54
+ * none) stays UNOWNED: `put` never back-fills an owner onto it (that would be adoption by a party
55
+ * that cannot prove it wrote it) and {@link ownerOf} keeps answering `undefined`, which a read face
56
+ * reads fail-closed. Back-filling such rows is an explicit migration, never a side effect of a write.
31
57
  */
32
- put(ref: string, content: string): Promise<void> | void;
58
+ put(ref: string, content: string, provenance?: ToolResultProvenance): Promise<void> | void;
33
59
  /** Read a slice of the stored content. Unknown `ref` → undefined. */
34
60
  get(ref: string, opts?: {
35
61
  offset?: number;
36
62
  limit?: number;
37
63
  }): Promise<ToolResultSlice | undefined> | ToolResultSlice | undefined;
64
+ /**
65
+ * Backlog #119 — the READ-side half of provenance: who owns `ref`. `undefined` for an unknown ref AND
66
+ * for a stored-but-unowned one (a row whose write stated no owner), which a read face treats identically:
67
+ * fail-closed, nobody is authorized. Without this operation a `put`-only provenance parameter would be
68
+ * write-only bookkeeping — the read face would still have nothing to decide on.
69
+ *
70
+ * Typed OPTIONAL so an implementation compiled against an older engine still satisfies the interface;
71
+ * the published contract kit (`toolResultStoreContract`) nevertheless REQUIRES it, because a backend
72
+ * that cannot answer this cannot back a host read face at all.
73
+ */
74
+ ownerOf?(ref: string): Promise<ToolResultProvenance | undefined> | ToolResultProvenance | undefined;
75
+ }
76
+ /**
77
+ * Backlog #119 — WHO a stored tool result belongs to. Structured (not a single opaque string) because
78
+ * the read face authorizes by COMPARING fields it already knows; `taskId` is present when the write site
79
+ * has one (it is a narrowing of the session, never a substitute for it).
80
+ *
81
+ * Deliberately NOT part of it: `toolCallId`. It is a retention/addressing coordinate, not an ownership
82
+ * one — two different tool calls in one session are the same owner.
83
+ */
84
+ export interface ToolResultProvenance {
85
+ readonly sessionId: string;
86
+ readonly taskId?: string;
38
87
  }
88
+ /** Backlog #119 — the `code` on {@link ToolResultRefConflictError}, so callers branch on a constant. */
89
+ export declare const TOOL_RESULT_REF_CONFLICT_CODE = "tool_result.ref_conflict";
90
+ /**
91
+ * Backlog #119 — a `put` whose provenance disagrees with the stored one. Typed (a `code`, like
92
+ * `CheckpointError`/`WorkflowRunStoreError`) rather than a silent no-op: this is the one write-once
93
+ * outcome where "keep what is there" would hand the second caller a reference to somebody else's bytes.
94
+ */
95
+ export declare class ToolResultRefConflictError extends Error {
96
+ readonly ref: string;
97
+ readonly stored: ToolResultProvenance;
98
+ readonly incoming: ToolResultProvenance;
99
+ readonly code = "tool_result.ref_conflict";
100
+ constructor(ref: string, stored: ToolResultProvenance, incoming: ToolResultProvenance);
101
+ }
102
+ /**
103
+ * Backlog #119 — the ONE comparison every backend uses, so "same owner" cannot mean two things in two
104
+ * stores. A missing PROVENANCE on either side ⇒ no verdict (an unowned entry is never adopted, and a
105
+ * put with no provenance never disturbs a stored owner). Both present ⇒ every field must agree,
106
+ * including `taskId`: a missing task id is a DIFFERENT owner from a stated one, not a wildcard that
107
+ * matches it — two writers that disagree about how narrowly they own a key have not proven they are
108
+ * the same writer. Unequal ⇒ {@link ToolResultRefConflictError}.
109
+ */
110
+ export declare function assertToolResultProvenanceMatch(ref: string, stored: ToolResultProvenance | undefined, incoming: ToolResultProvenance | undefined): void;
111
+ /** Normalize to the stored shape: an ABSENT `taskId` and an explicit `undefined` one are one value, so
112
+ * round-tripping through a durable backend (NULL column, missing JSON key) compares equal. */
113
+ export declare function normalizeToolResultProvenance(p: ToolResultProvenance): ToolResultProvenance;
39
114
  /**
40
115
  * RB-266 — the ref-safety half of the {@link ToolResultStore} contract, shared by every backend.
41
116
  *
@@ -64,7 +139,7 @@ export interface ToolResultStore {
64
139
  export declare function assertSafeToolResultRef(ref: string): void;
65
140
  /**
66
141
  * RB-273 — the SINGLE mint point for a tool-result `ref`. Three call sites compose the same
67
- * `tr_<sessionId>_<toolCallId>` string (`withToolResultOffload` here, the aggregate budget's preview
142
+ * `tr_<sessionId>~<toolCallId>` string (`withToolResultOffload` here, the aggregate budget's preview
68
143
  * pass, and the runner's clear-with-offload persist); this is that string's one definition.
69
144
  *
70
145
  * Why it must sanitize: `sessionId` is engine-minted, but **`toolCallId` is PROVIDER-minted** — it is
@@ -87,8 +162,76 @@ export declare function assertSafeToolResultRef(ref: string): void;
87
162
  * and an unsafe one only ever lived in a memory/pg deployment, where the ref the model reads back comes
88
163
  * from the `<persisted-output ref="…">` text already in its context — the read path does not re-mint,
89
164
  * and `get` is keyed on that literal string.
165
+ *
166
+ * ── Backlog #119 (2026-08-10): the composition is now INJECTIVE ───────────────────────────────────
167
+ * It was not. With `_` between the segments — a character legal INSIDE both — `tr_team_blue_x`
168
+ * decomposed as ("team","blue_x") and as ("team_blue","x"), i.e. two distinct (session, tool call)
169
+ * pairs minted one ref. `ref` is the store's PRIMARY KEY under a write-once contract, so the second
170
+ * pair did not get an error, it got the FIRST pair's bytes back — and `sessionId` is host-supplied in a
171
+ * BYOM deployment, so the two pairs need not be the same tenant. (Measured on the published artifact:
172
+ * ("s","1_a_b") and ("s_1","a_b") produced the same ref.)
173
+ *
174
+ * The separator is now {@link REF_SEGMENT_SEPARATOR} — a character OUTSIDE {@link NATIVE_REF_CHARSET}.
175
+ * `refSegment` emits only characters from that charset (identity branch by test; fold branch by
176
+ * construction: a readable base with every out-of-charset char replaced, plus hex), so the separator
177
+ * can never occur inside a segment and `tr_<seg>~<seg>` decomposes exactly one way. Injectivity of the
178
+ * whole map then follows from `refSegment`'s own injectivity per segment.
179
+ *
180
+ * Why this is NOT a breaking change, spelled out because it looks like one:
181
+ * - refs are OPAQUE HANDLES. A ref reaches the model inside the preview text and comes back verbatim
182
+ * to `ReadToolResult`; `get` is keyed on that literal string and no read path re-mints;
183
+ * - so an entry written under the old form stays readable forever: an old ref in an old context reads
184
+ * back byte-for-byte, on every backend (the file backend's filename encoding is likewise keyed on
185
+ * the literal ref, and is identity for the old form);
186
+ * - the mint is deterministic per process, so a ref MINTED here is also RESOLVED here — there is no
187
+ * window in which one process writes `~` and another expects `_` for the same pair. The upgrade
188
+ * boundary is a process restart, and it needs no double-read window;
189
+ * - the only observable change is the ref STRING for newly offloaded results, which no wire contract
190
+ * pins (it is generated text, and the length ceiling downstream validators apply is unchanged).
191
+ * The store-key charset is unchanged as far as the shared rule is concerned ({@link assertSafeToolResultRef}
192
+ * accepts `~`: it is not empty/dot/separator/control). A backend whose NATIVE key space excludes `~`
193
+ * (the file one's filename charset) already owns an injective encoding into it — the RB-273 rule — so
194
+ * for that backend this simply moves engine refs from the identity branch to the digest branch.
195
+ *
196
+ * `extraSegments` (also #119) is how a call site that addresses something FINER than one tool call —
197
+ * a `details` member's JSON path, a stale-projection content digest — keeps that coordinate in its own
198
+ * segment instead of splicing it into the `toolCallId` one. Splicing was the ticket's other residue:
199
+ * a ref built from the compound `"<callId> <path>"` is exactly the ref another call would mint if its
200
+ * provider-assigned toolCallId happened to BE that whole string. With the coordinate in its own
201
+ * segment the two can no longer coincide — the separator cannot occur inside a segment, so refs with
202
+ * different segment COUNTS are always distinct, and same-count refs are distinct unless every segment
203
+ * matches.
204
+ */
205
+ export declare function buildToolResultRef(sessionId: string, toolCallId: string, ...extraSegments: string[]): string;
206
+ /**
207
+ * Backlog #119 — the ceiling on a ref this module can mint, so a durable backend can size its key
208
+ * column from a stated invariant instead of a guess (TiDB's was `VARCHAR(191)`, which a long
209
+ * three-segment ref overruns — a truncating key aliases distinct refs).
210
+ *
211
+ * Derivation: `"tr_"` (3) + four segments at their own ceiling + three separators. A segment is either
212
+ * an identity one (≤ {@link MAX_REF_SEGMENT_CHARS}) or a folded one (marker + ≤32 base + `-` + 64 hex
213
+ * = 102), so {@link MAX_REF_SEGMENT_CHARS} is the larger. Four segments is the engine's own widest
214
+ * shape (session + tool call + a `details` path + the content coordinate); `buildToolResultRef` is
215
+ * variadic, so a caller that passes more owns the wider bound.
216
+ */
217
+ export declare const MAX_MINTED_TOOL_RESULT_REF_CHARS: number;
218
+ /**
219
+ * Backlog #119 (adversarial review, round 2) — the CONTENT coordinate of an offload ref.
220
+ *
221
+ * A tool-call id carries no cross-turn uniqueness contract: a BYOM brain legitimately mints recurring
222
+ * ids like `call-1`, and the Runner shares one store across a session's tasks on purpose. Keyed on
223
+ * (session, tool call) alone, the second result under a recurring id hits a write-once no-op — a
224
+ * SUCCESSFUL put that stored nothing — while its own preview goes out naming that ref, so paging it
225
+ * back returns the FIRST result's bytes. Provenance cannot see this (both writers are the same
226
+ * session, and they legitimately are), so the ref itself has to carry content identity.
227
+ *
228
+ * 128 bits, the same width and the same reasoning as the stale-result projection that closed this in
229
+ * its own lane earlier: tool results are untrusted text (web content rides them), and a 48-bit
230
+ * truncation is birthday-collidable at ~2^24 work — a deliberate collision plus a recurring id would
231
+ * re-open exactly the cross-serving this closes. Deterministic, so a re-derived ref for identical
232
+ * content is byte-identical and the preview stays prompt-cache stable.
90
233
  */
91
- export declare function buildToolResultRef(sessionId: string, toolCallId: string): string;
234
+ export declare function toolResultContentSegment(text: string): string;
92
235
  export interface ToolResultSlice {
93
236
  content: string;
94
237
  /** Byte/char offset this slice starts at. */
@@ -108,12 +251,17 @@ export declare class InMemoryToolResultStore implements ToolResultStore {
108
251
  private readonly opts?;
109
252
  /** put/get only — no scheduled deletion contract: honestly `"none"` (retention.ts). */
110
253
  readonly retention: "none";
254
+ /** #119: the entry is content AND owner as ONE value — the write-once election that decides the
255
+ * content decides the owner in the same step (no window where an entry exists ownerless). */
111
256
  private readonly map;
112
257
  private totalChars;
113
258
  constructor(opts?: {
114
259
  maxTotalChars?: number;
115
260
  } | undefined);
116
- put(ref: string, content: string): void;
261
+ put(ref: string, content: string, provenance?: ToolResultProvenance): void;
262
+ /** #119 — the owner recorded at the winning write; `undefined` for unknown AND for unowned entries,
263
+ * which a read face treats identically (fail-closed). */
264
+ ownerOf(ref: string): ToolResultProvenance | undefined;
117
265
  /** design/80 D-2: true when NOTHING has been offloaded — a durable suspend can then proceed safely even on
118
266
  * this in-memory store, because a cross-replica resume has no offloaded result to deref to null. */
119
267
  isEmpty(): boolean;
@@ -147,15 +295,18 @@ export declare class ScopedToolResultStore implements ToolResultStore {
147
295
  readonly volatileBacking: boolean;
148
296
  constructor(inner: ToolResultStore, scope: string);
149
297
  /** Length-prefixed namespace — unambiguous for any scope string (no delimiter-injection ambiguity).
150
- * RB-266: the scope segment is percent-ENCODED (injective, and its output charset carries no path
151
- * separator or control character), so a deployment whose trust scope contains one cannot compose a
152
- * key the inner store must reject — the ref-safety rule then applies to the caller's ref alone. */
298
+ * RB-266: the scope segment is ENCODED (injective, and its output charset carries no path separator
299
+ * or control character), so a deployment whose trust scope contains one cannot compose a key the
300
+ * inner store must reject — the ref-safety rule then applies to the caller's ref alone. */
153
301
  private key;
154
- put(ref: string, content: string): Promise<void> | void;
302
+ put(ref: string, content: string, provenance?: ToolResultProvenance): Promise<void> | void;
155
303
  get(ref: string, opts?: {
156
304
  offset?: number;
157
305
  limit?: number;
158
306
  }): Promise<ToolResultSlice | undefined> | ToolResultSlice | undefined;
307
+ /** #119 — same namespacing as {@link get}. `undefined` when the inner store predates the operation
308
+ * (an unowned answer, which the read face reads fail-closed — never a fabricated owner). */
309
+ ownerOf(ref: string): Promise<ToolResultProvenance | undefined> | ToolResultProvenance | undefined;
159
310
  /** THIS task's offload count only — the D-2 gate's per-task semantics (see class doc). */
160
311
  isEmpty(): boolean;
161
312
  }
@@ -171,6 +322,10 @@ export declare const OFFLOAD_TOOL_NAME = "ReadToolResult";
171
322
  * "expired" are different answers). If the marker write fails too, the generic miss remains.
172
323
  */
173
324
  export declare function createOffloadPersist(store: ToolResultStore, sessionId: string): (toolCallId: string, fullText: string) => string;
325
+ /** Backlog #119 — build the provenance a write site records, from the two coordinates every write site
326
+ * already has in hand. `taskId` is omitted (not `undefined`-valued) when the run declares none, so the
327
+ * stored shape compares equal across a durable round-trip. */
328
+ export declare function toolResultProvenanceOf(sessionId: string, taskId?: string): ToolResultProvenance;
174
329
  /**
175
330
  * RB-374① — the ONE source of the "page the offloaded text back" suggestion sentence, shared by
176
331
  * {@link buildPreview} (offload preview tail) and context-edit's cleared-marker refNote. The two
@@ -228,7 +383,7 @@ export declare function buildPreview(full: string, ref: string, sizes?: {
228
383
  }, reachableTools?: ReadonlySet<string>): string;
229
384
  /**
230
385
  * Wrap an {@link AgentTool} so that an oversized text result is offloaded to `store` and replaced with a
231
- * preview + `ref` before it ever reaches the session/model. `ref = tr_<toolCallId>` is stable and the
386
+ * preview + `ref` before it ever reaches the session/model. `ref = tr_<sessionId>~<toolCallId>` is stable and the
232
387
  * store is write-once: a **same-process** replay keeps an identical (cacheable) preview; a **cross-process**
233
388
  * wake needs a durable store (the default in-memory store loses the full text — the preview still stands).
234
389
  * Image blocks are left untouched; only text is offloaded. Forwards the `onUpdate` progress callback so a
@@ -2,6 +2,34 @@ import { createHash } from "node:crypto";
2
2
  import { Type } from "typebox";
3
3
  import { defineTool, errorResult } from "./tools.js";
4
4
  import { TOOL_SEARCH_NAME } from "./runner/tool-disclosure.js";
5
+ export const TOOL_RESULT_REF_CONFLICT_CODE = "tool_result.ref_conflict";
6
+ export class ToolResultRefConflictError extends Error {
7
+ ref;
8
+ stored;
9
+ incoming;
10
+ code = TOOL_RESULT_REF_CONFLICT_CODE;
11
+ constructor(ref, stored, incoming) {
12
+ super(`tool-result store: ref ${JSON.stringify(ref)} already belongs to a different origin ` +
13
+ `(stored ${describeProvenance(stored)}, offered ${describeProvenance(incoming)}) — refusing to store under it`);
14
+ this.ref = ref;
15
+ this.stored = stored;
16
+ this.incoming = incoming;
17
+ this.name = "ToolResultRefConflictError";
18
+ }
19
+ }
20
+ function describeProvenance(p) {
21
+ return p.taskId === undefined ? `session=${p.sessionId}` : `session=${p.sessionId} task=${p.taskId}`;
22
+ }
23
+ export function assertToolResultProvenanceMatch(ref, stored, incoming) {
24
+ if (stored === undefined || incoming === undefined)
25
+ return;
26
+ if (stored.sessionId === incoming.sessionId && stored.taskId === incoming.taskId)
27
+ return;
28
+ throw new ToolResultRefConflictError(ref, stored, incoming);
29
+ }
30
+ export function normalizeToolResultProvenance(p) {
31
+ return p.taskId === undefined ? { sessionId: p.sessionId } : { sessionId: p.sessionId, taskId: p.taskId };
32
+ }
5
33
  export function assertSafeToolResultRef(ref) {
6
34
  const bad = ref === "" ||
7
35
  ref === "." ||
@@ -14,14 +42,23 @@ export function assertSafeToolResultRef(ref) {
14
42
  }
15
43
  const NATIVE_REF_CHARSET = /^[A-Za-z0-9_.-]+$/;
16
44
  const MAX_REF_SEGMENT_CHARS = 128;
17
- export function buildToolResultRef(sessionId, toolCallId) {
18
- return `tr_${refSegment(sessionId)}_${refSegment(toolCallId)}`;
45
+ const REF_SEGMENT_SEPARATOR = "~";
46
+ const FOLDED_SEGMENT_PREFIX = "fold.";
47
+ export function buildToolResultRef(sessionId, toolCallId, ...extraSegments) {
48
+ return [`tr_${refSegment(sessionId)}`, refSegment(toolCallId), ...extraSegments.map(refSegment)].join(REF_SEGMENT_SEPARATOR);
19
49
  }
20
50
  function refSegment(raw) {
21
- if (raw.length <= MAX_REF_SEGMENT_CHARS && NATIVE_REF_CHARSET.test(raw))
51
+ if (raw.length <= MAX_REF_SEGMENT_CHARS && NATIVE_REF_CHARSET.test(raw) && !raw.startsWith(FOLDED_SEGMENT_PREFIX))
22
52
  return raw;
23
53
  const base = raw.replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 32);
24
- return `${base || "x"}-${createHash("sha256").update(raw, "utf8").digest("hex")}`;
54
+ return `${FOLDED_SEGMENT_PREFIX}${base || "x"}-${digestOfString(raw)}`;
55
+ }
56
+ export const MAX_MINTED_TOOL_RESULT_REF_CHARS = 3 + 4 * MAX_REF_SEGMENT_CHARS + 3;
57
+ export function toolResultContentSegment(text) {
58
+ return `s${digestOfString(text).slice(0, 32)}`;
59
+ }
60
+ function digestOfString(s) {
61
+ return createHash("sha256").update(Buffer.from(s, "utf16le")).digest("hex");
25
62
  }
26
63
  export class InMemoryToolResultStore {
27
64
  opts;
@@ -35,27 +72,33 @@ export class InMemoryToolResultStore {
35
72
  throw new Error("InMemoryToolResultStore: maxTotalChars must be a number (got NaN) — omit it for an unbounded store");
36
73
  }
37
74
  }
38
- put(ref, content) {
75
+ put(ref, content, provenance) {
39
76
  assertSafeToolResultRef(ref);
40
- if (this.map.has(ref))
77
+ const existing = this.map.get(ref);
78
+ if (existing !== undefined) {
79
+ assertToolResultProvenanceMatch(ref, existing.provenance, provenance);
41
80
  return;
42
- this.map.set(ref, content);
81
+ }
82
+ this.map.set(ref, provenance === undefined ? { content } : { content, provenance: normalizeToolResultProvenance(provenance) });
43
83
  this.totalChars += content.length;
44
84
  const cap = this.opts?.maxTotalChars;
45
85
  if (cap !== undefined && Number.isFinite(cap)) {
46
- for (const [oldRef, oldContent] of this.map) {
86
+ for (const [oldRef, oldEntry] of this.map) {
47
87
  if (this.totalChars <= cap || oldRef === ref)
48
88
  break;
49
89
  this.map.delete(oldRef);
50
- this.totalChars -= oldContent.length;
90
+ this.totalChars -= oldEntry.content.length;
51
91
  }
52
92
  }
53
93
  }
94
+ ownerOf(ref) {
95
+ return this.map.get(ref)?.provenance;
96
+ }
54
97
  isEmpty() {
55
98
  return this.map.size === 0;
56
99
  }
57
100
  get(ref, opts) {
58
- const full = this.map.get(ref);
101
+ const full = this.map.get(ref)?.content;
59
102
  if (full === undefined)
60
103
  return undefined;
61
104
  const offset = Math.min(full.length, Math.max(0, intOr(opts?.offset, 0)));
@@ -77,21 +120,32 @@ export class ScopedToolResultStore {
77
120
  this.volatileBacking = inner instanceof InMemoryToolResultStore;
78
121
  }
79
122
  key(ref) {
80
- const scope = encodeURIComponent(this.scope);
123
+ const scope = encodeScopeSegment(this.scope);
81
124
  return `${scope.length}:${scope}:${ref}`;
82
125
  }
83
- put(ref, content) {
126
+ put(ref, content, provenance) {
84
127
  assertSafeToolResultRef(ref);
85
128
  this.localPuts++;
86
- return this.inner.put(this.key(ref), content);
129
+ return this.inner.put(this.key(ref), content, provenance);
87
130
  }
88
131
  get(ref, opts) {
89
132
  return this.inner.get(this.key(ref), opts);
90
133
  }
134
+ ownerOf(ref) {
135
+ return this.inner.ownerOf?.(this.key(ref));
136
+ }
91
137
  isEmpty() {
92
138
  return this.localPuts === 0;
93
139
  }
94
140
  }
141
+ function encodeScopeSegment(scope) {
142
+ let out = "";
143
+ for (let i = 0; i < scope.length; i++) {
144
+ const ch = scope[i];
145
+ out += /[A-Za-z0-9]/.test(ch) ? ch : `%${ch.charCodeAt(0).toString(16).padStart(4, "0")}`;
146
+ }
147
+ return out;
148
+ }
95
149
  export function isVolatileOffloadStore(store) {
96
150
  if (store instanceof ScopedToolResultStore)
97
151
  return store.volatileBacking;
@@ -99,15 +153,19 @@ export function isVolatileOffloadStore(store) {
99
153
  }
100
154
  export const OFFLOAD_TOOL_NAME = "ReadToolResult";
101
155
  export function createOffloadPersist(store, sessionId) {
156
+ const provenance = toolResultProvenanceOf(sessionId);
102
157
  return (toolCallId, fullText) => {
103
- const ref = buildToolResultRef(sessionId, toolCallId);
104
- void Promise.resolve(store.put(ref, fullText)).catch((err) => {
158
+ const ref = buildToolResultRef(sessionId, toolCallId, toolResultContentSegment(fullText));
159
+ void Promise.resolve(store.put(ref, fullText, provenance)).catch((err) => {
105
160
  const cause = err instanceof Error ? err.message : String(err);
106
- void Promise.resolve(store.put(ref, `[offload LOST at write time: ${cause} — the inline preview is all that survived]`)).catch(() => undefined);
161
+ void Promise.resolve(store.put(ref, `[offload LOST at write time: ${cause} — the inline preview is all that survived]`, provenance)).catch(() => undefined);
107
162
  });
108
163
  return ref;
109
164
  };
110
165
  }
166
+ export function toolResultProvenanceOf(sessionId, taskId) {
167
+ return taskId === undefined ? { sessionId } : { sessionId, taskId };
168
+ }
111
169
  export function offloadPagebackHint(ref, form, reachableTools) {
112
170
  const activate = reachableTools !== undefined && !reachableTools.has(OFFLOAD_TOOL_NAME)
113
171
  ? `${OFFLOAD_TOOL_NAME} is not active yet — call ${TOOL_SEARCH_NAME} with {"query":"select:${OFFLOAD_TOOL_NAME}"} to activate it, then `
@@ -164,9 +222,10 @@ export function buildPreview(full, ref, sizes, reachableTools) {
164
222
  export function withToolResultOffload(tool, store, thresholdChars, sessionId, reachableTools) {
165
223
  if (!(Number.isFinite(thresholdChars) && thresholdChars > 0))
166
224
  return tool;
225
+ const provenance = toolResultProvenanceOf(sessionId);
167
226
  const wrappedExecute = async (toolCallId, params, signal, onUpdate) => {
168
227
  const res = await tool.execute(toolCallId, params, signal, onUpdate);
169
- const offloadedDetails = res.details === undefined ? undefined : await offloadOversizedDetailStrings(res.details, store, thresholdChars, sessionId, toolCallId);
228
+ const offloadedDetails = res.details === undefined ? undefined : await offloadOversizedDetailStrings(res.details, store, thresholdChars, sessionId, toolCallId, provenance);
170
229
  const withDetails = (r) => offloadedDetails === undefined || offloadedDetails.value === res.details ? r : { ...r, details: offloadedDetails.value };
171
230
  if (totalTextChars(res.content) <= thresholdChars)
172
231
  return withDetails(res);
@@ -176,20 +235,20 @@ export function withToolResultOffload(tool, store, thresholdChars, sessionId, re
176
235
  .join("\n");
177
236
  if (full.length <= PREVIEW_HEAD_CHARS + PREVIEW_TAIL_CHARS)
178
237
  return withDetails(res);
179
- const ref = buildToolResultRef(sessionId, toolCallId);
180
- await store.put(ref, full);
238
+ const ref = buildToolResultRef(sessionId, toolCallId, toolResultContentSegment(full));
239
+ await store.put(ref, full, provenance);
181
240
  const images = res.content.filter((b) => b.type !== "text");
182
241
  return withDetails({ ...res, content: [{ type: "text", text: buildPreview(full, ref, undefined, reachableTools?.()) }, ...images] });
183
242
  };
184
243
  return { ...tool, execute: wrappedExecute };
185
244
  }
186
245
  const OFFLOADED_DETAIL_HEAD_CHARS = 2_000;
187
- const OFFLOADED_DETAIL_NOTICE_ALLOWANCE_CHARS = 400;
246
+ const OFFLOADED_DETAIL_NOTICE_ALLOWANCE_CHARS = MAX_MINTED_TOOL_RESULT_REF_CHARS + 200;
188
247
  export const OFFLOADED_DETAIL_NOTICE_PREFIX = "…[offloaded — ";
189
248
  export function isOffloadedDetailReplacement(s) {
190
249
  return s.includes(`\n${OFFLOADED_DETAIL_NOTICE_PREFIX}`);
191
250
  }
192
- async function offloadOversizedDetailStrings(details, store, thresholdChars, sessionId, toolCallId) {
251
+ async function offloadOversizedDetailStrings(details, store, thresholdChars, sessionId, toolCallId, provenance) {
193
252
  const puts = [];
194
253
  const onStack = new Set();
195
254
  const isPlainObject = (v) => {
@@ -199,8 +258,8 @@ async function offloadOversizedDetailStrings(details, store, thresholdChars, ses
199
258
  return p === Object.prototype || p === null;
200
259
  };
201
260
  const replace = (full, path) => {
202
- const detailRef = buildToolResultRef(sessionId, toolCallId + " " + path);
203
- puts.push(Promise.resolve(store.put(detailRef, full)));
261
+ const detailRef = buildToolResultRef(sessionId, toolCallId, path, toolResultContentSegment(full));
262
+ puts.push(Promise.resolve(store.put(detailRef, full, provenance)));
204
263
  return (`${full.slice(0, OFFLOADED_DETAIL_HEAD_CHARS)}\n` +
205
264
  `${OFFLOADED_DETAIL_NOTICE_PREFIX}${full.length} chars total; ref "${detailRef}"; the remainder is retained in this run's tool-result store and reads back through the deployment's tool-results face]`);
206
265
  };
@@ -320,6 +320,12 @@ export interface ToolSpec<TParams extends TSchema = TSchema> {
320
320
  * text under `"static"`, where the placeholder is never swapped. Use for large/MCP tool sets where
321
321
  * inlining hundreds of schemas blows up turn-1 tokens and risks prefix-cache breakage. Default: not
322
322
  * deferred (full schema inlined). See {@link RunnerDeps.deferMode}.
323
+ *
324
+ * SCOPE: this flag reaches the classifier through CALLER specs (`TaskSpec.tools`) only — a
325
+ * built-in/injected tool's spec never enters that list, so setting `defer` on a spec that shadows a
326
+ * built-in name does nothing. To defer an already-mounted tool (built-ins included), list its wire
327
+ * name in {@link TaskSpec.deferTools}. Also inert when the tool ends up excluded (exclusion wins)
328
+ * or pinned ({@link alwaysLoad} / {@link TaskSpec.alwaysLoadTools}).
323
329
  */
324
330
  defer?: boolean;
325
331
  /**
@@ -705,6 +711,17 @@ export interface ToolExecuteContext {
705
711
  * is a DEPLOYMENT property, not a per-task one — a child in the same sandbox needs the same facts
706
712
  * (its env block renders the scratchpad section; its root fence admits the scratchpad dir). */
707
713
  envFacts?: TaskSpec["envFacts"];
714
+ /**
715
+ * The parent task's DECLARED {@link TaskSpec.memoryPersistenceCapable} — present ONLY when the spec
716
+ * set it (an inferred run's ctx gains no key), Runner-filled on the same trusted seat as
717
+ * {@link principal}: never a model/tool argument. A delegation tool (`createSubagentTool`) forwards
718
+ * it into every child spec it builds so the deployment's persistence statement survives delegation:
719
+ * `false` is a floor a chosen agent definition cannot loosen (the read-only-memory disclosure must
720
+ * hold tree-wide when the deployment says nothing durable is reachable), `true` is a default a
721
+ * definition may narrow back to `false`. Absent ⇒ nothing is forwarded and each child's own roster
722
+ * inference decides, exactly as before the seat existed.
723
+ */
724
+ memoryPersistenceCapable?: boolean;
708
725
  /**
709
726
  * [893]④a — the parent task's per-model auth hook ({@link TaskSpec.getApiKeyAndHeaders}), inherited
710
727
  * verbatim down the delegation tree like `principal`/`clientContext` (Runner-filled, read-only, NEVER
@@ -1058,6 +1075,19 @@ export interface AgentDefinition {
1058
1075
  maxTurns?: number;
1059
1076
  /** Long-term memory scope for this agent (same shape as {@link TaskSpec.memory}). */
1060
1077
  memory?: TaskSpec["memory"];
1078
+ /**
1079
+ * This agent's own {@link TaskSpec.memoryPersistenceCapable} declaration — the same three-state
1080
+ * statement, made per definition: an agent whose closure tools persist memory declares `true`
1081
+ * (suppressing the read-only-memory disclosure no roster inference can clear), one whose roster
1082
+ * looks write-capable but reaches no durable store declares `false` (forcing the disclosure).
1083
+ * Arbitration with the spawning run's declaration: a parent's explicit `false` is a FLOOR and wins
1084
+ * over a definition `true` (the disclosure is about the deployment's storage, which choosing this
1085
+ * agent does not change); otherwise a declared definition value wins over the parent's; absent
1086
+ * both, the child spec carries no value and the child's own roster inference decides.
1087
+ * On the workflow lane (`agent(…, {agentType})`) the definition value fills an ABSENT governed-spec
1088
+ * value only — a base/spec declaration of either polarity wins (the lane's spec-pinned-fields rule).
1089
+ */
1090
+ memoryPersistenceCapable?: boolean;
1061
1091
  /**
1062
1092
  * Observer agents (CC 2.1.206 parity — 逐字锚 docs/CC206-OBSERVER-ANCHORS-2026-07-11.md, schema
1063
1093
  * 面 C @28385708): "Agent type auto-spawned as a background observer whenever this agent runs.
@@ -2016,6 +2046,39 @@ export interface TaskSpec {
2016
2046
  * modify the project. No effect unless an `executionEnv` is injected. Default false.
2017
2047
  */
2018
2048
  handsReadOnly?: boolean;
2049
+ /**
2050
+ * Whether this run can persist a user's "remember X" somewhere durable — the deployment's own
2051
+ * statement, overriding the runner's inference. The runner mounts a read-only-memory disclosure
2052
+ * (the model must decline to "remember" instead of receipting a save that never happens) when the
2053
+ * session provably cannot persist; provability is inferred from KNOWN store paths only (a mounted
2054
+ * file-write tool or a write-capable shell). A caller tool that persists through its own closure
2055
+ * (a custom memory writer) is invisible to that inference and would be contradicted by the
2056
+ * disclosure — set `true` to declare the channel and suppress it. Set `false` to force the
2057
+ * disclosure even when write-capable tools mount (e.g. they cannot reach any durable store);
2058
+ * a forced `false` also replaces the `# Memory` write instruction itself and drops the
2059
+ * preference-writing discipline and index seed that serve it.
2060
+ * Default: inferred. The inference also treats a REMOTE execution env's hand band as unable to
2061
+ * reach the host-side memory root (sandbox filesystem); a deployment whose remote env shares a
2062
+ * mount with the memory root declares `true` — on such a remote, `true` also restores the
2063
+ * `# Memory` write instruction when a Write tool mounts (a declared-capable session must be told
2064
+ * the path, not left silent). If your persistence channel is NOT the file face, do not mount a
2065
+ * tool named `Write` alongside the declaration, or keep the default and teach your own channel.
2066
+ * Delegation: a DECLARED value crosses the delegation boundary (sync / background / fork spawns and
2067
+ * the retained-resume snapshot alike). An explicit `false` is a FLOOR — a chosen
2068
+ * {@link AgentDefinition.memoryPersistenceCapable} `true` cannot loosen it (the disclosure is
2069
+ * about the deployment's storage, which no agent selection changes); an explicit `true` is a
2070
+ * DEFAULT the chosen definition may narrow back to `false`; absent = absent downstream too — each
2071
+ * child runs its own inference over its own roster. A retained child woken by a DIFFERENT run
2072
+ * additionally folds the waker's declared `false` on top of the spawn snapshot (tighten-only,
2073
+ * like the other resume clamps).
2074
+ * KNOWN GAPS (registered, whole-clamp-family shapes — `handsReadOnly`/`interactiveTools` share
2075
+ * them): a TIER-3 durable revival rebuilds the child from the REVIVER's context (the durable row
2076
+ * records lookup keys, never a serialized spec), so the spawn-time declaration does not survive
2077
+ * that lane — the reviver's own declaration governs; and the workflow HOST lane does not forward
2078
+ * the host TaskSpec's declaration into `agent()` children (an {@link AgentDefinition} on the
2079
+ * workflow agent type does carry).
2080
+ */
2081
+ memoryPersistenceCapable?: boolean;
2019
2082
  /**
2020
2083
  * design/119 (CC --add-dir parity): extra directories the FILE tools may access in addition to the
2021
2084
  * containment root — each is canonicalized into the containment allowlist and listed in the
@@ -2090,6 +2153,11 @@ export interface TaskSpec {
2090
2153
  * (writes, egress, pipes, unknown commands) tightens to ask. An egress command (e.g. `curl`) suspends under
2091
2154
  * the IRREVERSIBLE axis (the shell mark is irreversibility, not egress) — the kind is non-budgetable either
2092
2155
  * way, so the budget resolver still never auto-approves it; the axis label is informational.
2156
+ * NOT unconditional: a POSITIVE per-tool mark on the shell tool keeps its seat — an explicit
2157
+ * `irreversibility:"always"` is never downgraded (the tier combine is tighten-only) and an explicit
2158
+ * `"maybe"` keeps its own probe (a probe-less explicit `"maybe"` stays fail-closed ask; the doctrine's
2159
+ * generic probe installs only on doctrine-owned seats). An explicit `"never"` is NOT a mark — the
2160
+ * doctrine governs that seat as if unmarked.
2093
2161
  * No effect under `handsReadOnly` (that mounts `bash_readonly`, already allowlisted) or with no execution env.
2094
2162
  * **Re-supply on resume:** like `toolPolicy`/`tools`/`durableApproval`, `shellGate` is part of the resume
2095
2163
  * task config — a resume that omits it leaves the resumed run's SUBSEQUENT `bash` calls ungated (the approved
@@ -170,8 +170,12 @@ export declare function defuseControlChars(text: string): string;
170
170
  * Make untrusted text safe to interpolate INLINE on a trusted prompt line (not inside a {@link delimitUntrusted}
171
171
  * fence) — e.g. design/80 D-F echoes a chosen option label into the "The user answered:" block. Folds EVERY
172
172
  * line/space separator to a single space so the text cannot forge a new line — `\s` (covers CR/LF/TAB/VT/FF and
173
- * U+2028/U+2029 + the Unicode spaces) PLUS U+0085 NEL, which `\s` does NOT match caps the length, then applies
174
- * the same tag-neutralization (`</system-reminder>`) + fence-sentinel (`<<<`/`>>>`) defusing the fenced body gets.
173
+ * U+2028/U+2029 + the Unicode spaces) PLUS the whole C0 + DEL + C1 block (U+0000–U+001F, U+007F–U+009F), which
174
+ * `\s` does NOT fully match: the C1 half carries the 8-bit CSI/OSC/ST forms (U+009B/U+009D/U+009C) a
175
+ * C1-honoring terminal treats like their ESC-prefixed spellings, so leaving them through would let a
176
+ * 'sanitized' value repaint the trusted line it is interpolated onto (adversarial round finding) — caps the
177
+ * length, then applies the same tag-neutralization (`</system-reminder>`) + fence-sentinel (`<<<`/`>>>`)
178
+ * defusing the fenced body gets.
175
179
  * Defense-in-depth, NOT a guarantee (same posture as the rest of this module).
176
180
  */
177
181
  export declare function inlineUntrusted(text: string, maxLen?: number): string;
@@ -99,7 +99,7 @@ export function defuseControlChars(text) {
99
99
  }
100
100
  const LABEL_MAX = 160;
101
101
  export function inlineUntrusted(text, maxLen = LABEL_MAX) {
102
- const oneLine = text.replace(/[\s\u0000-\u001f\u007f\u0085]+/g, " ").trim();
102
+ const oneLine = text.replace(/[\s\u0000-\u001f\u007f-\u009f]+/g, " ").trim();
103
103
  const cps = [...oneLine];
104
104
  const capped = cps.length > maxLen ? cps.slice(0, maxLen).join("") + "…" : oneLine;
105
105
  const out = defuseFenceMarkers(sanitizeUntrustedText(capped));
@@ -3,12 +3,13 @@ import { leafIdAfterEntry } from "./storage-base.js";
3
3
  import { parseSessionTimestampMs } from "./timestamps.js";
4
4
  import { flattenableUserText, normalizeEngineSegments } from "../../core/untrusted-text.js";
5
5
  import { normalizePromptEpoch } from "../../prompt-assembly/epoch.js";
6
+ import { MAX_MINTED_TOOL_RESULT_REF_CHARS } from "../../core/tool-result-store.js";
6
7
  import { PERSISTED_OUTPUT_REFS_MAX_ENTRIES, SKILL_RETENTION_PER_SKILL_MAX_CHARS, SKILL_RETENTION_TOTAL_MAX_CHARS, } from "../compaction/utils.js";
7
8
  const PATH_LIST_MAX_CHARS = 4096;
8
9
  const PATH_LIST_MAX_ENTRIES = 1000;
9
10
  const INVOKED_SKILLS_MAX_ENTRIES = 1000;
10
11
  const INVOKED_SKILL_NAME_MAX_CHARS = 1024;
11
- const PERSISTED_REF_MAX_CHARS = 256;
12
+ const PERSISTED_REF_MAX_CHARS = MAX_MINTED_TOOL_RESULT_REF_CHARS;
12
13
  const ACTIVE_TOOL_NAME_MAX_CHARS = 1024;
13
14
  const ACTIVE_TOOLS_MAX_ENTRIES = 1000;
14
15
  export class StreamingImportValidator {