@sema-agent/core 5.58.0 → 5.60.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 (59) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/brain/anthropic.js +15 -5
  3. package/dist/brain/errors.d.ts +18 -1
  4. package/dist/brain/errors.js +7 -1
  5. package/dist/brain/input-too-long.d.ts +57 -0
  6. package/dist/brain/input-too-long.js +35 -0
  7. package/dist/brain/route-adjudicator.d.ts +8 -1
  8. package/dist/brain/route-adjudicator.js +8 -1
  9. package/dist/brain/stream-engine.js +9 -1
  10. package/dist/core/auto-compaction.js +2 -2
  11. package/dist/core/checkpoint-store.d.ts +116 -19
  12. package/dist/core/checkpoint-store.js +15 -8
  13. package/dist/core/context-edit.d.ts +243 -41
  14. package/dist/core/context-edit.js +247 -32
  15. package/dist/core/governance-codes.d.ts +37 -10
  16. package/dist/core/governance-codes.js +57 -1
  17. package/dist/core/locked-config.d.ts +36 -4
  18. package/dist/core/locked-config.js +34 -1
  19. package/dist/core/mcp.js +10 -6
  20. package/dist/core/memory-engine/consolidation-driver.d.ts +6 -2
  21. package/dist/core/memory-engine/consolidation-driver.js +54 -5
  22. package/dist/core/memory-engine/consolidation.d.ts +73 -1
  23. package/dist/core/memory-engine/consolidation.js +21 -1
  24. package/dist/core/memory-engine/content-origin.d.ts +24 -2
  25. package/dist/core/memory-engine/content-origin.js +6 -1
  26. package/dist/core/memory-engine/engine.d.ts +97 -8
  27. package/dist/core/memory-engine/engine.js +112 -20
  28. package/dist/core/memory-engine/file-backend.d.ts +13 -1
  29. package/dist/core/memory-engine/file-backend.js +3 -0
  30. package/dist/core/memory-engine/index.d.ts +4 -3
  31. package/dist/core/memory-engine/layout.js +20 -6
  32. package/dist/core/memory-engine/types.d.ts +17 -0
  33. package/dist/core/memory.d.ts +10 -0
  34. package/dist/core/park-selfcheck.js +1 -0
  35. package/dist/core/permission-rule-consent.js +9 -5
  36. package/dist/core/permission-rule-model.d.ts +42 -1
  37. package/dist/core/permission-rule-model.js +12 -0
  38. package/dist/core/runner/prepare-config-doors.d.ts +22 -1
  39. package/dist/core/runner/prepare-config-doors.js +36 -0
  40. package/dist/core/runner/prepare-task.d.ts +28 -1
  41. package/dist/core/runner/prepare-task.js +109 -11
  42. package/dist/core/runner/runtask.js +45 -8
  43. package/dist/core/store-contracts/checkpoint-store-contract.js +32 -0
  44. package/dist/core/tool-policy.d.ts +74 -0
  45. package/dist/core/tool-policy.js +80 -1
  46. package/dist/core/tools.js +1 -1
  47. package/dist/core/trace.d.ts +36 -0
  48. package/dist/core/types.d.ts +172 -22
  49. package/dist/core/types.js +4 -3
  50. package/dist/core/untrusted-text.d.ts +11 -0
  51. package/dist/core/untrusted-text.js +1 -0
  52. package/dist/engine/llm/types.d.ts +21 -2
  53. package/dist/engine/loop/agent-loop.js +7 -1
  54. package/dist/engine/loop/types.d.ts +4 -1
  55. package/dist/index.d.ts +3 -3
  56. package/dist/index.js +2 -2
  57. package/dist/tools/fs/fs-bash.js +1 -2
  58. package/package.json +1 -1
  59. package/test/export-surface.snapshot.json +1771 -1
@@ -1,7 +1,11 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { DEFAULT_CHARS_PER_TOKEN, estimateContextTokens, estimateTokens } from "../internal/harness.js";
2
3
  import { isToolResult } from "./message-utils.js";
3
- import { offloadPagebackHint } from "./tool-result-store.js";
4
+ import { PERSISTED_OUTPUT_PREFIX, offloadPagebackHint } from "./tool-result-store.js";
4
5
  const CLEARED_MARKER = "[tool result cleared to save context]";
6
+ const CC_CLEARED_MARKER = "[Old tool result content cleared]";
7
+ const LEGACY_CLEARED_EXTENDED_PREFIX = "[tool result cleared to save context —";
8
+ const CC_CLEARED_EXTENDED_PREFIX = "[Old tool result content cleared —";
5
9
  const refNote = (ref) => offloadPagebackHint(ref, "cleared");
6
10
  const mediaNote = (blocks) => {
7
11
  const byType = new Map();
@@ -17,9 +21,10 @@ const mediaNote = (blocks) => {
17
21
  const breakdown = [...byType].map(([t, c]) => `${c} ${t}`).join(", ");
18
22
  return `${n} attachments (${breakdown}) no longer visible after this clear`;
19
23
  };
20
- const clearedMarker = (notes) => {
24
+ const clearedMarker = (notes, machine = "legacy") => {
21
25
  const present = notes.filter((n) => n !== undefined);
22
- return present.length > 0 ? `[tool result cleared to save context ${present.join("; ")}]` : CLEARED_MARKER;
26
+ const [plain, extendedPrefix] = machine === "cc" ? [CC_CLEARED_MARKER, CC_CLEARED_EXTENDED_PREFIX] : [CLEARED_MARKER, LEGACY_CLEARED_EXTENDED_PREFIX];
27
+ return present.length > 0 ? `${extendedPrefix} ${present.join("; ")}]` : plain;
23
28
  };
24
29
  export const EDIT_FRACTION = 0.7;
25
30
  export const CONTEXT_OUTPUT_RESERVE_TOKENS = 20000;
@@ -28,13 +33,16 @@ export function contextEditFrontier(window) {
28
33
  return Math.max(window - (CONTEXT_OUTPUT_RESERVE_TOKENS + COMPACTION_TRIGGER_BUFFER_TOKENS), Math.floor(window * EDIT_FRACTION));
29
34
  }
30
35
  export const DEFAULT_KEEP_RECENT_TOOL_RESULTS = 3;
36
+ export const CC_DEFAULT_KEEP_RECENT_TOOL_RESULTS = 5;
37
+ export const MIN_CLEAR_SAVINGS_TOKENS = 20000;
38
+ const MEDIA_BLOCK_SAVINGS_TOKENS = 2000;
31
39
  export const MIN_KEEP_RECENT_TOOL_RESULTS = 1;
32
- function resolveKeepRecentToolResults(value) {
40
+ function resolveKeepRecentToolResults(value, defaultValue) {
33
41
  if (value === undefined)
34
- return DEFAULT_KEEP_RECENT_TOOL_RESULTS;
42
+ return defaultValue;
35
43
  if (!Number.isSafeInteger(value) || value < 0) {
36
44
  throw new TypeError(`keepRecentToolResults must be a non-negative safe integer (got ${String(value)}); ` +
37
- `omit it for the default ${DEFAULT_KEEP_RECENT_TOOL_RESULTS}`);
45
+ `omit it for the default ${defaultValue}`);
38
46
  }
39
47
  return Math.max(MIN_KEEP_RECENT_TOOL_RESULTS, value);
40
48
  }
@@ -49,12 +57,29 @@ export const COMPACTABLE_TOOLS = new Set([
49
57
  "Edit",
50
58
  "Write",
51
59
  ]);
52
- function isCleared(m) {
60
+ function soleMarkerText(m) {
53
61
  const content = m.content;
54
62
  if (!Array.isArray(content) || content.length !== 1)
55
- return false;
63
+ return undefined;
56
64
  const text = content[0].text;
57
- return text === CLEARED_MARKER || (typeof text === "string" && text.startsWith("[tool result cleared to save context —"));
65
+ return typeof text === "string" ? text : undefined;
66
+ }
67
+ function isCleared(m, recognizeCcForms = false) {
68
+ const text = soleMarkerText(m);
69
+ if (text === undefined)
70
+ return false;
71
+ if (text === CLEARED_MARKER || text.startsWith(LEGACY_CLEARED_EXTENDED_PREFIX))
72
+ return true;
73
+ return recognizeCcForms && (text === CC_CLEARED_MARKER || text.startsWith(CC_CLEARED_EXTENDED_PREFIX));
74
+ }
75
+ function isCcCleared(m) {
76
+ if (isCleared(m, true))
77
+ return true;
78
+ const content = m.content;
79
+ if (!Array.isArray(content) || content.length === 0)
80
+ return false;
81
+ const first = content[0];
82
+ return first?.type === "text" && typeof first.text === "string" && first.text.startsWith(PERSISTED_OUTPUT_PREFIX);
58
83
  }
59
84
  export function dropEmptyFailureAssistants(messages) {
60
85
  const isJunk = (m) => {
@@ -67,8 +92,56 @@ export function dropEmptyFailureAssistants(messages) {
67
92
  };
68
93
  return messages.some(isJunk) ? messages.filter((m) => !isJunk(m)) : messages;
69
94
  }
95
+ function structuralClearSavings(target) {
96
+ const content = Array.isArray(target.content) ? target.content : [];
97
+ let saved = 0;
98
+ for (const c of content) {
99
+ const block = c;
100
+ if (block?.type === "text") {
101
+ saved += typeof block.text === "string" ? Math.round(block.text.length / 4) : 0;
102
+ }
103
+ else {
104
+ saved += MEDIA_BLOCK_SAVINGS_TOKENS;
105
+ }
106
+ }
107
+ return saved;
108
+ }
109
+ function clearOneResult(target, offload, machine) {
110
+ const rawContent = Array.isArray(target.content) ? target.content : [];
111
+ let ref;
112
+ if (offload) {
113
+ const fullText = rawContent
114
+ .filter((c) => c?.type === "text" && typeof c.text === "string")
115
+ .map((c) => c.text)
116
+ .join("\n");
117
+ if (target.toolCallId && fullText.trim().length > 0) {
118
+ try {
119
+ ref = refNote(offload.persist(target.toolCallId, fullText));
120
+ }
121
+ catch {
122
+ ref = undefined;
123
+ }
124
+ }
125
+ }
126
+ const mediaBlocks = rawContent.filter((c) => c?.type !== "text");
127
+ const marker = clearedMarker([ref, mediaBlocks.length > 0 ? mediaNote(mediaBlocks) : undefined], machine);
128
+ return { ...target, content: [{ type: "text", text: marker }] };
129
+ }
130
+ function reportCleared(onCleared, clears, tokensSavedEstimate) {
131
+ if (!onCleared || clears.length === 0)
132
+ return;
133
+ try {
134
+ onCleared({ clears, tokensSavedEstimate });
135
+ }
136
+ catch {
137
+ }
138
+ }
70
139
  export function clearStaleToolResults(messages, opts) {
71
140
  const cpt = opts.charsPerToken ?? DEFAULT_CHARS_PER_TOKEN;
141
+ const machine = opts.machine ?? "legacy";
142
+ if (machine !== "legacy" && machine !== "cc") {
143
+ throw new TypeError(`machine must be "legacy" | "cc" (got ${String(machine)}); omit it for the default "legacy"`);
144
+ }
72
145
  const anchored = opts.anchoredTotalTokens !== undefined;
73
146
  let total;
74
147
  let anchorIdx = -1;
@@ -85,48 +158,190 @@ export function clearStaleToolResults(messages, opts) {
85
158
  if (total <= opts.budgetTokens) {
86
159
  return messages;
87
160
  }
88
- const keep = resolveKeepRecentToolResults(opts.keepRecentToolResults);
161
+ if (machine === "cc") {
162
+ return clearStaleToolResultsCc(messages, opts);
163
+ }
164
+ const keep = resolveKeepRecentToolResults(opts.keepRecentToolResults, DEFAULT_KEEP_RECENT_TOOL_RESULTS);
89
165
  const compactable = opts.compactableTools ?? COMPACTABLE_TOOLS;
90
- const toolResultCandidates = messages.flatMap((m, i) => isToolResult(m) && !isCleared(m) && compactable.has(m.toolName) ? [{ idx: i, target: m }] : []);
166
+ const toolResultCandidates = messages.flatMap((m, i) => isToolResult(m) && !isCleared(m, opts.recognizeCcMarkers === true) && compactable.has(m.toolName) ? [{ idx: i, target: m }] : []);
91
167
  const clearable = toolResultCandidates.slice(0, Math.max(0, toolResultCandidates.length - keep));
92
168
  if (clearable.length === 0) {
93
169
  return messages;
94
170
  }
95
171
  const out = messages.slice();
96
172
  let current = total;
173
+ const clears = [];
174
+ let savedEstimate = 0;
97
175
  for (const { idx, target } of clearable) {
98
176
  if (current <= opts.budgetTokens) {
99
177
  break;
100
178
  }
101
179
  const before = estimateTokens(target, cpt);
102
- const rawContent = Array.isArray(target.content) ? target.content : [];
103
- let ref;
104
- if (opts.offload) {
105
- const fullText = rawContent
106
- .filter((c) => c?.type === "text" && typeof c.text === "string")
107
- .map((c) => c.text)
108
- .join("\n");
109
- if (target.toolCallId && fullText.trim().length > 0) {
110
- try {
111
- ref = refNote(opts.offload.persist(target.toolCallId, fullText));
112
- }
113
- catch {
114
- ref = undefined;
115
- }
116
- }
117
- }
118
- const mediaBlocks = rawContent.filter((c) => c?.type !== "text");
119
- const marker = clearedMarker([ref, mediaBlocks.length > 0 ? mediaNote(mediaBlocks) : undefined]);
120
- const cleared = { ...target, content: [{ type: "text", text: marker }] };
180
+ const cleared = clearOneResult(target, opts.offload, "legacy");
121
181
  out[idx] = cleared;
122
- current -= idx > anchorIdx ? before - estimateTokens(cleared, cpt) : 0;
182
+ clears.push({ index: idx });
183
+ const delta = before - estimateTokens(cleared, cpt);
184
+ savedEstimate += delta;
185
+ current -= idx > anchorIdx ? delta : 0;
123
186
  }
187
+ reportCleared(opts.onCleared, clears, savedEstimate);
124
188
  return out;
125
189
  }
190
+ function clearStaleToolResultsCc(messages, opts) {
191
+ const keep = resolveKeepRecentToolResults(opts.keepRecentToolResults, CC_DEFAULT_KEEP_RECENT_TOOL_RESULTS);
192
+ const compactable = opts.compactableTools ?? COMPACTABLE_TOOLS;
193
+ const candidates = messages.flatMap((m, i) => isToolResult(m) && compactable.has(m.toolName) ? [{ idx: i, target: m }] : []);
194
+ const beyondKeep = candidates.slice(0, Math.max(0, candidates.length - keep));
195
+ const clearable = beyondKeep.filter(({ target }) => !isCcCleared(target));
196
+ if (clearable.length === 0) {
197
+ return messages;
198
+ }
199
+ const tokensSaved = clearable.reduce((sum, { target }) => sum + structuralClearSavings(target), 0);
200
+ if (tokensSaved < MIN_CLEAR_SAVINGS_TOKENS) {
201
+ return messages;
202
+ }
203
+ const out = messages.slice();
204
+ const clears = [];
205
+ for (const { idx, target } of clearable) {
206
+ const src = opts.clearSource?.[idx];
207
+ const source = src !== undefined && isToolResult(src) && src.toolCallId === target.toolCallId ? src : target;
208
+ out[idx] = clearOneResult(source, opts.offload, "cc");
209
+ clears.push({ index: idx });
210
+ }
211
+ reportCleared(opts.onCleared, clears, tokensSaved);
212
+ return out;
213
+ }
214
+ export function resolveTriggerWindow(model) {
215
+ const physical = model.contextTokens ?? model.contextWindow;
216
+ const declared = model.autoCompactTokens;
217
+ if (declared !== undefined &&
218
+ Number.isFinite(declared) &&
219
+ declared > 0 &&
220
+ Number.isFinite(physical) &&
221
+ physical > 0 &&
222
+ declared > physical) {
223
+ return { window: physical, clamped: true, declaredAutoCompactTokens: declared, physicalWindow: physical };
224
+ }
225
+ return { window: declared ?? physical, clamped: false };
226
+ }
126
227
  export function editBudget(model) {
127
- const window = model.autoCompactTokens ?? model.contextTokens ?? model.contextWindow;
228
+ const window = resolveTriggerWindow(model).window;
128
229
  if (!Number.isFinite(window) || window <= 0) {
129
230
  return Number.POSITIVE_INFINITY;
130
231
  }
131
232
  return contextEditFrontier(window);
132
233
  }
234
+ export function fingerprintToolResultOccurrence(m) {
235
+ const content = Array.isArray(m.content)
236
+ ? (m.content)
237
+ : [];
238
+ const canonical = content.map((c) => {
239
+ const block = c;
240
+ if (block?.type === "text" && typeof block.text === "string") {
241
+ return ["text", block.text];
242
+ }
243
+ return [block?.type ?? "?", block?.mimeType ?? "", typeof block?.data === "string" ? block.data : ""];
244
+ });
245
+ return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
246
+ }
247
+ export function createClearedProjectionLedger() {
248
+ return { entries: new Map() };
249
+ }
250
+ const OCCURRENCE_KEY_SEP = String.fromCharCode(0);
251
+ function occurrenceGroupId(m) {
252
+ return `${m.toolCallId}${OCCURRENCE_KEY_SEP}${m.timestamp ?? 0}`;
253
+ }
254
+ export function planRejectionClears(messages, opts) {
255
+ const keep = resolveKeepRecentToolResults(opts.keepRecentToolResults, CC_DEFAULT_KEEP_RECENT_TOOL_RESULTS);
256
+ const compactable = opts.compactableTools ?? COMPACTABLE_TOOLS;
257
+ const candidates = messages.flatMap((m) => (isToolResult(m) && compactable.has(m.toolName) ? [m] : []));
258
+ const beyondKeep = candidates.slice(0, Math.max(0, candidates.length - keep));
259
+ const clearable = beyondKeep.flatMap((m) => {
260
+ if (isCcCleared(m))
261
+ return [];
262
+ const at = opts.keyOf(m);
263
+ return at === undefined ? [] : [{ m, at }];
264
+ });
265
+ if (clearable.length === 0)
266
+ return { declined: "no_candidates" };
267
+ const tokensSavedEstimate = clearable.reduce((sum, { m }) => sum + structuralClearSavings(m), 0);
268
+ if (tokensSavedEstimate < MIN_CLEAR_SAVINGS_TOKENS)
269
+ return { declined: "below_min_savings" };
270
+ const cleared = clearable.map(({ m, at }) => {
271
+ const source = at.occurrence !== undefined && isToolResult(at.occurrence) ? at.occurrence : m;
272
+ const clearedMsg = clearOneResult(source, opts.offload, "cc");
273
+ return { key: at.key, groupCount: at.groupCount, marker: clearedMsg.content[0]?.text ?? CC_CLEARED_MARKER, fp: at.fp };
274
+ });
275
+ return { cleared, tokensSavedEstimate };
276
+ }
277
+ export function replayClearedProjection(messages, ledger) {
278
+ const groups = new Map();
279
+ const byIndex = new Map();
280
+ const byObject = new WeakMap();
281
+ messages.forEach((m, i) => {
282
+ if (!isToolResult(m))
283
+ return;
284
+ const g = occurrenceGroupId(m);
285
+ const list = groups.get(g) ?? [];
286
+ byIndex.set(i, { group: g, dupIdx: list.length });
287
+ byObject.set(m, { group: g, dupIdx: list.length });
288
+ list.push(i);
289
+ groups.set(g, list);
290
+ });
291
+ const mintKey = (hit) => {
292
+ if (hit === undefined)
293
+ return undefined;
294
+ const list = groups.get(hit.group) ?? [];
295
+ const occurrenceIdx = list[hit.dupIdx];
296
+ return {
297
+ key: `${hit.group}${OCCURRENCE_KEY_SEP}${hit.dupIdx}`,
298
+ groupCount: list.length,
299
+ fp: occurrenceIdx === undefined ? "" : fingerprintToolResultOccurrence(messages[occurrenceIdx]),
300
+ ...(occurrenceIdx === undefined ? {} : { occurrence: messages[occurrenceIdx] }),
301
+ };
302
+ };
303
+ const index = {
304
+ keyAt: (i) => mintKey(byIndex.get(i)),
305
+ keyOf: (m) => {
306
+ const direct = byObject.get(m);
307
+ if (direct !== undefined)
308
+ return mintKey(direct);
309
+ if (!isToolResult(m))
310
+ return undefined;
311
+ const g = occurrenceGroupId(m);
312
+ const list = groups.get(g);
313
+ return list !== undefined && list.length === 1 ? mintKey({ group: g, dupIdx: 0 }) : undefined;
314
+ },
315
+ };
316
+ if (ledger.entries.size === 0) {
317
+ return { messages, index };
318
+ }
319
+ let out;
320
+ const rekeyed = new Map();
321
+ for (const [key, entry] of ledger.entries) {
322
+ const sep = key.lastIndexOf(OCCURRENCE_KEY_SEP);
323
+ const group = key.slice(0, sep);
324
+ const dupIdx = Number(key.slice(sep + 1));
325
+ const list = groups.get(group);
326
+ if (list === undefined)
327
+ continue;
328
+ const m = list.length;
329
+ const shrink = Math.max(0, entry.groupCount - m);
330
+ const mapped = dupIdx - shrink;
331
+ if (mapped < 0 || mapped >= m)
332
+ continue;
333
+ const targetIdx = list[mapped];
334
+ const target = messages[targetIdx];
335
+ if (soleMarkerText(target) !== entry.marker) {
336
+ if (fingerprintToolResultOccurrence(target) !== entry.fp)
337
+ continue;
338
+ out = out ?? messages.slice();
339
+ out[targetIdx] = { ...target, content: [{ type: "text", text: entry.marker }] };
340
+ }
341
+ rekeyed.set(`${group}${OCCURRENCE_KEY_SEP}${mapped}`, { marker: entry.marker, groupCount: m, fp: entry.fp });
342
+ }
343
+ ledger.entries.clear();
344
+ for (const [k, v] of rekeyed)
345
+ ledger.entries.set(k, v);
346
+ return { messages: out ?? messages, index };
347
+ }
@@ -68,15 +68,42 @@ export type RuleSyncDropReason = keyof typeof RULE_SYNC_DROP_CODES;
68
68
  * fence arm / local screening). `own_actor_forged` is inbound-only by construction. */
69
69
  export type RuleQuarantineReason = Exclude<RuleSyncDropReason, "own_actor_forged">;
70
70
  /**
71
- * The presentation-tier registry (the server's wire whitelist retires into this
72
- * table once shipped): WHO a notice code is for. `"user"` = a session-scoped disclosure the end
73
- * user of that session should see (safe to project onto the session's event stream); `"operator"`
74
- * = a deployment/config/ops fact for whoever runs the process. Presentation tier is a property of
75
- * the CODE (closed set, one code one tier) never of an individual emission, which is why this is
76
- * a registry and not an EngineNotice field. Codes absent from the table read as `"operator"`
77
- * (the conservative default: never push an unclassified code at an end user).
78
- * Orthogonal to {@link NON_GOVERNANCE_MEMORY_CODES} (a retry-semantics table, not presentation).
71
+ * WHO a notice code is for. `"user"` = a session-scoped disclosure the end user of that session is
72
+ * entitled to see (safe to project onto that session's event stream); `"operator"` = a
73
+ * deployment/config/ops fact for whoever runs the process.
74
+ *
75
+ * Two values, deliberately. "Can this notice be routed to a session" is NOT an audience value it
76
+ * is the presence of `EngineNotice.sessionId`, a per-EMISSION fact. A third value would fold a
77
+ * routing property into an entitlement vocabulary, and the two answer different questions: several
78
+ * codes below carry a session attribution while the person entitled to the fact is still the
79
+ * operator (a failed offload write, a routing fallback, a consolidation recommendation).
80
+ */
81
+ export type NoticeAudience = "user" | "operator";
82
+ /**
83
+ * The CLOSED catalog of EngineNotice codes this engine mints — the enumeration half of the
84
+ * presentation registry below, and the thing a consumer's own audience table can be diffed against
85
+ * (that is the point of exporting it: a downstream table with a row missing, or a row for a code
86
+ * this engine no longer mints, is a mechanically detectable disagreement rather than an argument).
87
+ *
88
+ * Kept in LOCKSTEP with {@link NOTICE_AUDIENCE} by the compiler (`satisfies Record<EngineNoticeCode,
89
+ * …>` below: a catalog entry with no audience row, or an audience row for a non-member, is a tsc
90
+ * error — a mirrored enumeration that is only checked at runtime is a mirror that drifts). The
91
+ * other half — "is the catalog itself still complete?" — cannot be a type: `EngineNotice.code` is
92
+ * declared `string` (a host may forward its own), so the gate test (governance-codes.test.ts) scans
93
+ * src/ for notice mint shapes and names any code that is minted but unregistered, or registered but
94
+ * no longer minted.
95
+ */
96
+ export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "delegation.transcript_integrity", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "task.injection_priority_unimplemented", "tool_result.offload_put_failed"];
97
+ /** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
98
+ * `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
99
+ * sink is a supported shape, and narrowing that field would break it. */
100
+ export type EngineNoticeCode = (typeof ENGINE_NOTICE_CODES)[number];
101
+ /**
102
+ * The presentation-tier registry — see {@link NOTICE_AUDIENCE_TABLE} for the per-row judgment.
103
+ * The exported TYPE stays the open `Record<string, …>` on purpose: a consumer indexing it with a
104
+ * runtime string (a code off a wire, a host's own) must keep compiling. The lockstep with
105
+ * {@link ENGINE_NOTICE_CODES} is enforced on the table above, where narrowing costs nobody anything.
79
106
  */
80
- export declare const NOTICE_AUDIENCE: Readonly<Record<string, "user" | "operator">>;
107
+ export declare const NOTICE_AUDIENCE: Readonly<Record<string, NoticeAudience>>;
81
108
  /** The audience for `code` — table lookup with the conservative `"operator"` default. */
82
- export declare function noticeAudienceOf(code: string): "user" | "operator";
109
+ export declare function noticeAudienceOf(code: string): NoticeAudience;
@@ -17,6 +17,7 @@ export const NON_GOVERNANCE_MEMORY_CODES = new Set([
17
17
  "memory.session_polluted",
18
18
  "memory.harvest_quarantined",
19
19
  "memory.delegation_static_mark_waived",
20
+ "memory.content_class_declared",
20
21
  "memory.consolidation_driver_superseded",
21
22
  "memory.challenge_sweep_failed",
22
23
  "memory.lineage_settle_failed",
@@ -84,14 +85,69 @@ export const RULE_SYNC_DROP_CODES = {
84
85
  below_gc_frontier: "local-quarantined",
85
86
  server_rejected: "local-quarantined",
86
87
  };
87
- export const NOTICE_AUDIENCE = {
88
+ export const ENGINE_NOTICE_CODES = [
89
+ "config.autocompact_window_clamped",
90
+ "config.env_timeout_discarded",
91
+ "config.materialize_env_discarded",
92
+ "config.models_swapped",
93
+ "config.read_face_deployment_clamped",
94
+ "config.tool_model_gate_removed",
95
+ "config.tool_model_gate_unknown_class",
96
+ "config.tool_model_gate_env_invalid",
97
+ "delegation.transcript_integrity",
98
+ "mcp.revocation_probe_failed",
99
+ "workflow.governance_key_stripped",
100
+ "memory.session_polluted",
101
+ "memory.harvest_quarantined",
102
+ "memory.delegation_static_mark_waived",
103
+ "memory.content_class_declared",
104
+ "memory.hold_opened",
105
+ "memory.hold_released",
106
+ "memory.hold_disposed",
107
+ "memory.consolidation_recommended",
108
+ "memory.consolidation_committed",
109
+ "memory.consolidation_conflict",
110
+ "memory.consolidation_incomplete",
111
+ "memory.consolidation_refused",
112
+ "route.fallback_to_primary",
113
+ "route.base_url_changed_key_unchanged",
114
+ "task.user_steer_undrained",
115
+ "task.user_followup_undrained",
116
+ "task.injection_priority_unimplemented",
117
+ "tool_result.offload_put_failed",
118
+ ];
119
+ const NOTICE_AUDIENCE_TABLE = {
88
120
  "memory.session_polluted": "user",
89
121
  "memory.harvest_quarantined": "user",
90
122
  "memory.delegation_static_mark_waived": "user",
91
123
  "memory.hold_opened": "user",
92
124
  "memory.hold_released": "user",
93
125
  "memory.hold_disposed": "user",
126
+ "task.user_steer_undrained": "user",
127
+ "task.user_followup_undrained": "user",
128
+ "config.autocompact_window_clamped": "operator",
129
+ "config.env_timeout_discarded": "operator",
130
+ "config.materialize_env_discarded": "operator",
131
+ "config.models_swapped": "operator",
132
+ "config.read_face_deployment_clamped": "operator",
133
+ "config.tool_model_gate_removed": "operator",
134
+ "config.tool_model_gate_unknown_class": "operator",
135
+ "config.tool_model_gate_env_invalid": "operator",
136
+ "delegation.transcript_integrity": "operator",
137
+ "mcp.revocation_probe_failed": "operator",
138
+ "workflow.governance_key_stripped": "operator",
139
+ "memory.content_class_declared": "operator",
140
+ "memory.consolidation_recommended": "operator",
141
+ "memory.consolidation_committed": "operator",
142
+ "memory.consolidation_conflict": "operator",
143
+ "memory.consolidation_incomplete": "operator",
144
+ "memory.consolidation_refused": "operator",
145
+ "route.fallback_to_primary": "operator",
146
+ "route.base_url_changed_key_unchanged": "operator",
147
+ "task.injection_priority_unimplemented": "operator",
148
+ "tool_result.offload_put_failed": "operator",
94
149
  };
150
+ export const NOTICE_AUDIENCE = NOTICE_AUDIENCE_TABLE;
95
151
  export function noticeAudienceOf(code) {
96
152
  return NOTICE_AUDIENCE[code] ?? "operator";
97
153
  }
@@ -1,4 +1,4 @@
1
- import type { RunnerDeps, TaskSpec } from "./types.js";
1
+ import type { McpServerSpec, RunnerDeps, TaskSpec } from "./types.js";
2
2
  import type { ToolPolicy } from "./tool-policy.js";
3
3
  /** The closed set of administratively lockable configuration keys. Adding a member is a deliberate
4
4
  * edit HERE (tsc forces the registry row), never a free-form string. */
@@ -38,9 +38,12 @@ export interface LockedConfig {
38
38
  export interface LockedPreflight {
39
39
  /** The validated lock set (empty when the deployment declares none). */
40
40
  lockedKeys: ReadonlySet<LockedKey>;
41
- /** Effective MCP server list for this task: `spec.mcp` when the key is unlocked; with `mcp`
42
- * locked a spec-supplied value has already been refused, so this is always `undefined` (core has
43
- * no deployment-level MCP seat a locked deployment mounts no task-supplied servers). */
41
+ /** Effective MCP server list for this task: a validated, frozen SNAPSHOT of `spec.mcp` when the
42
+ * key is unlocked (see {@link snapshotAndValidateMcpEntries} the content-class declaration is read once
43
+ * and judged here, so no mint site downstream can be handed a different value than the door
44
+ * approved); with `mcp` locked a spec-supplied value has already been refused, so this is always
45
+ * `undefined` (core has no deployment-level MCP seat — a locked deployment mounts no
46
+ * task-supplied servers). */
44
47
  mcp: TaskSpec["mcp"];
45
48
  /** Effective caller tool policy: the same `spec.toolPolicy ?? deps.toolPolicy` slot the gate has
46
49
  * always enforced, resolved ONCE here. With `toolPolicy` locked, the deployment's own
@@ -61,6 +64,35 @@ export interface LockedPreflight {
61
64
  * then fails the first prepare instead of silently guarding nothing).
62
65
  */
63
66
  export declare function resolveLockedKeys(config: LockedConfig | undefined): ReadonlySet<LockedKey>;
67
+ /**
68
+ * design/378 — turn a caller-owned MCP entry list into FROZEN PLAIN DATA, reading every own property
69
+ * exactly once.
70
+ *
71
+ * WHY A COPY. `McpServerSpec.contentOrigin` decides whether a server's tools mark the session's
72
+ * memory, and the entry objects travel a long way: the preparation door validates them, then
73
+ * `materializeMcpTools` connects them, then every mid-task refresh re-reads the same object. A value
74
+ * read off a LIVE object is only true for as long as the object holds still, and the fail-open
75
+ * direction is the reachable one — so both boundaries consume data instead.
76
+ *
77
+ * The read discipline is the load-bearing part, and it is why this is a spread rather than
78
+ * "read the property, then copy the object": a getter answering ALTERNATING LEGAL values would
79
+ * satisfy any number of re-reads (`"external"` to a validator, `"local"` to a mint site), and no
80
+ * vocabulary guard anywhere has purchase on a legal value. One spread evaluates every own property
81
+ * EXACTLY ONCE and materializes the result as plain data; every later reader — validator, connect,
82
+ * refresh — then sees the same byte. Frozen so nothing downstream rewrites it.
83
+ *
84
+ * Boundary, stated: the copy is one level deep — nested option objects (transport, headers, env,
85
+ * toolAxes) stay shared with the caller's entry exactly as before, because the axis this owns is the
86
+ * content class; readers of the nested shapes normalize their own values (the transport DISCRIMINATOR
87
+ * is derived, never echoed raw). Own ENUMERABLE properties only: an entry whose fields live on a
88
+ * prototype was never the plain configuration record this type describes, and its absent class reads
89
+ * fail-closed like any other absence.
90
+ *
91
+ * Exported because the SECOND boundary needs the identical guarantee: `materializeMcpTools` is public
92
+ * and a library caller reaches it without passing any door, so it snapshots its own input through
93
+ * this same function (idempotent over an already-frozen entry). One spelling, both entrances.
94
+ */
95
+ export declare function snapshotMcpEntries(entries: readonly McpServerSpec[]): McpServerSpec[];
64
96
  /**
65
97
  * The single preflight resolver: validate the lock declaration, refuse a spec that tries to occupy
66
98
  * a locked slot (`config.locked_key`, two-state — the whole prepare is rejected, nothing is
@@ -1,3 +1,5 @@
1
+ import { isToolContentOrigin, TOOL_CONTENT_ORIGINS } from "./memory-engine/content-origin.js";
2
+ import { inlineUntrusted } from "./untrusted-text.js";
1
3
  export const LOCKED_KEY_REGISTRY = {
2
4
  mcp: { specFields: ["mcp"] },
3
5
  toolPolicy: { specFields: ["toolPolicy", "basePolicyForResumeEdit"] },
@@ -23,6 +25,37 @@ export function resolveLockedKeys(config) {
23
25
  }
24
26
  return keys;
25
27
  }
28
+ export function snapshotMcpEntries(entries) {
29
+ return entries.map((entry) => Object.freeze({ ...entry }));
30
+ }
31
+ function describeDeclared(value) {
32
+ try {
33
+ return JSON.stringify(value) ?? String(value);
34
+ }
35
+ catch {
36
+ try {
37
+ return String(value);
38
+ }
39
+ catch {
40
+ return `[unrepresentable ${typeof value}]`;
41
+ }
42
+ }
43
+ }
44
+ function snapshotAndValidateMcpEntries(entries) {
45
+ const copies = snapshotMcpEntries(entries);
46
+ for (const copy of copies) {
47
+ const declared = copy.contentOrigin;
48
+ if (declared !== undefined && !isToolContentOrigin(declared)) {
49
+ throw codedError("config.mcp_content_class", `MCP server ${inlineUntrusted(typeof copy.name === "string" ? JSON.stringify(copy.name) : describeDeclared(copy.name), 80)} declares contentOrigin ` +
50
+ `${inlineUntrusted(describeDeclared(declared), 80)}, which is not one of ` +
51
+ `${TOOL_CONTENT_ORIGINS.map((v) => `"${v}"`).join(" | ")}. The declaration decides whether this server's tools ` +
52
+ `mark the session's memory externally exposed — a value this engine cannot read is refused at the door rather ` +
53
+ `than folded to a class, because folding it would let a deployment believe a declaration is in force while every ` +
54
+ `call keeps writing a one-way durable mark.`);
55
+ }
56
+ }
57
+ return copies;
58
+ }
26
59
  export function preflightLockedConfig(spec, deps) {
27
60
  const lockedKeys = resolveLockedKeys(deps.lockedConfig);
28
61
  for (const key of lockedKeys) {
@@ -36,7 +69,7 @@ export function preflightLockedConfig(spec, deps) {
36
69
  }
37
70
  return {
38
71
  lockedKeys,
39
- mcp: lockedKeys.has("mcp") ? undefined : spec.mcp,
72
+ mcp: lockedKeys.has("mcp") || spec.mcp === undefined ? undefined : snapshotAndValidateMcpEntries(spec.mcp),
40
73
  toolPolicy: lockedKeys.has("toolPolicy") ? deps.toolPolicy : (spec.toolPolicy ?? deps.toolPolicy),
41
74
  basePolicyForResumeEdit: lockedKeys.has("toolPolicy") ? deps.basePolicyForResumeEdit : (spec.basePolicyForResumeEdit ?? deps.basePolicyForResumeEdit),
42
75
  };
package/dist/core/mcp.js CHANGED
@@ -14,6 +14,7 @@ import { truncateError } from "./tool-errors.js";
14
14
  import { delimitUntrusted, inlineUntrusted, sanitizeUntrustedText } from "./untrusted-text.js";
15
15
  import { discloseReminderShaped, observeReminderMarkEcho } from "./reminder-disclosure.js";
16
16
  import { withContentOrigin } from "./memory-engine/content-origin.js";
17
+ import { snapshotMcpEntries } from "./locked-config.js";
17
18
  import { validateJsonSchemaShape } from "./runner/strict-output-schema.js";
18
19
  export const MCP_PREFIX = MCP_NAMESPACE.prefix;
19
20
  const MCP_OUTPUT_TOKENS_DEFAULT = 25_000;
@@ -753,8 +754,9 @@ export function mcpToolSchemaProblem(schema) {
753
754
  return undefined;
754
755
  }
755
756
  export async function materializeMcpTools(specs, principal, onElicit, imageResizer, reminderDisclosure, mcpRevocations) {
757
+ const entries = snapshotMcpEntries(specs);
756
758
  {
757
- const collision = findNamespacePrefixCollision(MCP_NAMESPACE, specs.map((s) => s.name));
759
+ const collision = findNamespacePrefixCollision(MCP_NAMESPACE, entries.map((s) => s.name));
758
760
  if (collision) {
759
761
  const [a, b] = collision.peers;
760
762
  const e = new Error(a === b
@@ -801,11 +803,11 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
801
803
  const droppedTools = [];
802
804
  let disposing = false;
803
805
  const serverHandles = [];
804
- const settled = await Promise.allSettled(specs.map((spec) => connectServer(spec, principal, onElicit, imageResizer, mcpDisclosure, isServerRevoked)));
806
+ const settled = await Promise.allSettled(entries.map((spec) => connectServer(spec, principal, onElicit, imageResizer, mcpDisclosure, isServerRevoked)));
805
807
  try {
806
- for (let i = 0; i < specs.length; i++) {
808
+ for (let i = 0; i < entries.length; i++) {
807
809
  const r = settled[i];
808
- const spec = specs[i];
810
+ const spec = entries[i];
809
811
  if (r.status === "fulfilled") {
810
812
  const s = r.value;
811
813
  clients.push(s.client);
@@ -1513,6 +1515,7 @@ async function connectServer(spec, principal, onElicit, imageResizer, reminderDi
1513
1515
  }
1514
1516
  }
1515
1517
  function intakeListedTools(listed, spec, client, health, imageResizer, reminderDisclosure, isServerRevoked) {
1518
+ const declaredContentOrigin = spec.contentOrigin;
1516
1519
  const serverTools = [];
1517
1520
  const serverAxes = [];
1518
1521
  const dropped = [];
@@ -1576,7 +1579,7 @@ function intakeListedTools(listed, spec, client, health, imageResizer, reminderD
1576
1579
  const mcpToolMeta = t._meta;
1577
1580
  const mcpMaxResultSizeChars = resolveMcpDeclaredResultSize(mcpToolMeta);
1578
1581
  const mcpAlwaysLoad = mcpToolMeta?.["anthropic/alwaysLoad"] === true;
1579
- serverTools.push({
1582
+ const mounted = {
1580
1583
  name: namespacedName,
1581
1584
  description: capMcpToolDescription(effectiveDescription ?? `MCP tool ${inlineUntrusted(sanitizeMcpModelFacingText(remoteName))} from ${spec.name}`),
1582
1585
  label: `${spec.name}:${remoteName}`,
@@ -1678,7 +1681,8 @@ function intakeListedTools(listed, spec, client, health, imageResizer, reminderD
1678
1681
  return { content, details: { type: "mcp", structuredContent: sc }, terminate: false };
1679
1682
  return { content, details: res, terminate: false };
1680
1683
  },
1681
- });
1684
+ };
1685
+ serverTools.push(declaredContentOrigin !== undefined ? withContentOrigin(mounted, declaredContentOrigin) : mounted);
1682
1686
  }
1683
1687
  return { serverTools, serverAxes, dropped, advisories };
1684
1688
  }