@sema-agent/core 5.59.0 → 5.60.1

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 (45) hide show
  1. package/CHANGELOG.md +61 -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/stream-engine.js +9 -1
  8. package/dist/core/auto-compaction.js +2 -2
  9. package/dist/core/checkpoint-store.d.ts +172 -25
  10. package/dist/core/checkpoint-store.js +15 -8
  11. package/dist/core/context-edit.d.ts +243 -41
  12. package/dist/core/context-edit.js +247 -32
  13. package/dist/core/governance-codes.d.ts +1 -1
  14. package/dist/core/governance-codes.js +5 -0
  15. package/dist/core/locked-config.d.ts +36 -4
  16. package/dist/core/locked-config.js +34 -1
  17. package/dist/core/mcp.d.ts +4 -5
  18. package/dist/core/mcp.js +10 -6
  19. package/dist/core/memory-engine/content-origin.d.ts +24 -2
  20. package/dist/core/memory-engine/content-origin.js +6 -1
  21. package/dist/core/memory-engine/engine.d.ts +5 -6
  22. package/dist/core/memory.d.ts +10 -0
  23. package/dist/core/park-selfcheck.js +1 -0
  24. package/dist/core/permission-rule-consent.js +9 -5
  25. package/dist/core/runner/prepare-config-doors.d.ts +22 -1
  26. package/dist/core/runner/prepare-config-doors.js +36 -0
  27. package/dist/core/runner/prepare-task.d.ts +28 -1
  28. package/dist/core/runner/prepare-task.js +107 -8
  29. package/dist/core/runner/runtask.js +97 -9
  30. package/dist/core/store-contracts/checkpoint-store-contract.js +32 -0
  31. package/dist/core/tool-policy.d.ts +32 -10
  32. package/dist/core/tool-policy.js +3 -3
  33. package/dist/core/tools.js +1 -1
  34. package/dist/core/trace.d.ts +36 -0
  35. package/dist/core/types.d.ts +127 -2
  36. package/dist/core/untrusted-text.d.ts +11 -0
  37. package/dist/core/untrusted-text.js +1 -0
  38. package/dist/engine/llm/types.d.ts +21 -2
  39. package/dist/engine/loop/agent-loop.js +7 -1
  40. package/dist/engine/loop/types.d.ts +4 -1
  41. package/dist/index.d.ts +1 -1
  42. package/dist/index.js +1 -1
  43. package/dist/tools/fs/fs-bash.js +1 -2
  44. package/package.json +1 -1
  45. package/test/export-surface.snapshot.json +5 -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
+ }
@@ -93,7 +93,7 @@ export type NoticeAudience = "user" | "operator";
93
93
  * src/ for notice mint shapes and names any code that is minted but unregistered, or registered but
94
94
  * no longer minted.
95
95
  */
96
- export declare const ENGINE_NOTICE_CODES: readonly ["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.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"];
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
97
  /** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
98
98
  * `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
99
99
  * sink is a supported shape, and narrowing that field would break it. */
@@ -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",
@@ -85,6 +86,7 @@ export const RULE_SYNC_DROP_CODES = {
85
86
  server_rejected: "local-quarantined",
86
87
  };
87
88
  export const ENGINE_NOTICE_CODES = [
89
+ "config.autocompact_window_clamped",
88
90
  "config.env_timeout_discarded",
89
91
  "config.materialize_env_discarded",
90
92
  "config.models_swapped",
@@ -98,6 +100,7 @@ export const ENGINE_NOTICE_CODES = [
98
100
  "memory.session_polluted",
99
101
  "memory.harvest_quarantined",
100
102
  "memory.delegation_static_mark_waived",
103
+ "memory.content_class_declared",
101
104
  "memory.hold_opened",
102
105
  "memory.hold_released",
103
106
  "memory.hold_disposed",
@@ -122,6 +125,7 @@ const NOTICE_AUDIENCE_TABLE = {
122
125
  "memory.hold_disposed": "user",
123
126
  "task.user_steer_undrained": "user",
124
127
  "task.user_followup_undrained": "user",
128
+ "config.autocompact_window_clamped": "operator",
125
129
  "config.env_timeout_discarded": "operator",
126
130
  "config.materialize_env_discarded": "operator",
127
131
  "config.models_swapped": "operator",
@@ -132,6 +136,7 @@ const NOTICE_AUDIENCE_TABLE = {
132
136
  "delegation.transcript_integrity": "operator",
133
137
  "mcp.revocation_probe_failed": "operator",
134
138
  "workflow.governance_key_stripped": "operator",
139
+ "memory.content_class_declared": "operator",
135
140
  "memory.consolidation_recommended": "operator",
136
141
  "memory.consolidation_committed": "operator",
137
142
  "memory.consolidation_conflict": "operator",
@@ -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
  };
@@ -177,11 +177,10 @@ export interface MaterializedMcp {
177
177
  * fails the WHOLE model request (provider 400), bricking the task for every healthy tool
178
178
  * (CC 2.1.216 ships the same intake prune, `tengu_mcp_drop_invalid_tool_schemas`).
179
179
  *
180
- * HONEST REACH (pinned in mcp-dropped-tools.test.ts): with the bundled TS MCP SDK, a string-root
181
- * schema already fails the SDK client's OWN zod at `listTools` the whole server is skipped
182
- * (warnings face) and this gate never sees the listing, losing healthy sibling tools with it
183
- * (SDK-upstream failure mode, recorded). This per-tool gate is DEFENSE IN DEPTH: it binds when a
184
- * transport/SDK revision delivers schemas the client does not fully validate.
180
+ * HONEST REACH (pinned in mcp-dropped-tools.test.ts): listings arrive through the lenient reader
181
+ * (`listToolsLenient` raw request, no SDK-client zod pass), so a string-root or otherwise odd
182
+ * schema DOES reach this gate and is judged per tool; healthy siblings survive. This per-tool gate
183
+ * is therefore the PRIMARY adjudicator for schema shape, not a fallback behind SDK validation.
185
184
  *
186
185
  * The drop is NEVER silent, two faces (CC parity — its `mcp_dropped_tools_delta` system reminder):
187
186
  * - operator: prepare-task forwards each entry to `onError(phase:"mcp")` alongside `warnings`;
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
  }
@@ -22,13 +22,22 @@
22
22
  * does not, the default is EXTERNAL (fail-closed: unknown = external), overridable only through the
23
23
  * explicit `TaskSpec.memory.trustedTools` allowlist. A core-mounted built-in without a declaration
24
24
  * defaults local: core owns those definitions, and the fail-closed default exists for tools core
25
- * CANNOT vouch for, not for its own.
25
+ * CANNOT vouch for, not for its own. A declaration is honored only when it SPELLS a member of the
26
+ * closed vocabulary — an unreadable one is not a declaration.
27
+ *
28
+ * The three tier names are BOUNDARY-RELATIVE, not statements about topology: `"local"` means "does
29
+ * not bring content from outside the deployment's trust boundary", which a unix socket to another
30
+ * process the deployment itself runs satisfies and a loopback HTTP call to somebody else's service
31
+ * does not. Distance is not the axis; whose content it is, is.
26
32
  *
27
33
  * There is deliberately no "gate inactive" state: every mounted tool gets a class.
28
34
  */
29
35
  import type { ToolContentOrigin } from "../types.js";
30
36
  export interface ClassifyToolContentOriginInput {
31
- /** The tool's own `contentOrigin` declaration (ToolSpec/AgentTool carry) — wins when present. */
37
+ /** The tool's own `contentOrigin` declaration (ToolSpec/AgentTool carry) — wins when present AND
38
+ * a member of the closed vocabulary ({@link isToolContentOrigin}); a present non-member is the
39
+ * unreadable-declaration case and classifies EXTERNAL. The annotation is intent, not a guarantee:
40
+ * the property rides tool objects untyped. */
32
41
  declared?: ToolContentOrigin;
33
42
  /** True ⇔ the name belongs to a protocol namespace (mcp__/a2a__ …) — an external channel. */
34
43
  isProtocolTool: boolean;
@@ -37,6 +46,10 @@ export interface ClassifyToolContentOriginInput {
37
46
  /** True ⇔ `TaskSpec.memory.trustedTools` names this tool (explicit host exemption). */
38
47
  trusted: boolean;
39
48
  }
49
+ /** The vocabulary in wire/message order — the one spelling list a refusal quotes back. */
50
+ export declare const TOOL_CONTENT_ORIGINS: readonly ToolContentOrigin[];
51
+ /** Runtime membership in the closed vocabulary. */
52
+ export declare function isToolContentOrigin(value: unknown): value is ToolContentOrigin;
40
53
  export declare function classifyToolContentOrigin(input: ClassifyToolContentOriginInput): ToolContentOrigin;
41
54
  /**
42
55
  * Declare `origin` on a tool object built OUTSIDE `defineTool` — a raw AgentTool literal, which is
@@ -60,6 +73,15 @@ export interface AgentToolFace {
60
73
  export interface AgentPoolTool {
61
74
  name: string;
62
75
  aliases?: readonly string[];
76
+ /** The pool row's own declaration — a STATIC surface the deployment writes when it composes the
77
+ * delegation tool, and the only class information this judgment has about the child's roster.
78
+ *
79
+ * A per-entry MCP declaration (design/378, `McpServerSpec.contentOrigin`) does NOT reach here: the
80
+ * pool is a declaration, not a live roster, and this judgment never sees the server entries. A
81
+ * deployment that hands a declared server's `mcp__` tools to children MIRRORS the value onto the
82
+ * matching pool rows; not mirroring it leaves them on the protocol arm, so the delegation reads
83
+ * external and the parent over-marks — the safe direction, and the reason this is a documented
84
+ * duty rather than a refusal. */
63
85
  contentOrigin?: ToolContentOrigin;
64
86
  }
65
87
  /**
@@ -1,6 +1,11 @@
1
+ const TOOL_CONTENT_ORIGIN_SET = { external: true, execution: true, local: true };
2
+ export const TOOL_CONTENT_ORIGINS = Object.keys(TOOL_CONTENT_ORIGIN_SET);
3
+ export function isToolContentOrigin(value) {
4
+ return typeof value === "string" && Object.prototype.hasOwnProperty.call(TOOL_CONTENT_ORIGIN_SET, value);
5
+ }
1
6
  export function classifyToolContentOrigin(input) {
2
7
  if (input.declared !== undefined)
3
- return input.declared;
8
+ return isToolContentOrigin(input.declared) ? input.declared : "external";
4
9
  if (input.trusted)
5
10
  return "local";
6
11
  if (input.isProtocolTool)