@animalabs/agent-framework 0.7.3 → 0.8.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 (105) hide show
  1. package/dist/src/agent.d.ts.map +1 -1
  2. package/dist/src/agent.js +28 -1
  3. package/dist/src/agent.js.map +1 -1
  4. package/dist/src/code-execution/index.d.ts +1 -1
  5. package/dist/src/code-execution/index.d.ts.map +1 -1
  6. package/dist/src/code-execution/index.js.map +1 -1
  7. package/dist/src/code-execution/py-runner.d.ts +23 -1
  8. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  9. package/dist/src/code-execution/py-runner.js +40 -4
  10. package/dist/src/code-execution/py-runner.js.map +1 -1
  11. package/dist/src/code-execution/runtime-py.d.ts +15 -2
  12. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  13. package/dist/src/code-execution/runtime-py.js +156 -7
  14. package/dist/src/code-execution/runtime-py.js.map +1 -1
  15. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  16. package/dist/src/code-execution/tool-definition.js +27 -2
  17. package/dist/src/code-execution/tool-definition.js.map +1 -1
  18. package/dist/src/framework.d.ts +92 -0
  19. package/dist/src/framework.d.ts.map +1 -1
  20. package/dist/src/framework.js +932 -60
  21. package/dist/src/framework.js.map +1 -1
  22. package/dist/src/mcpl/channel-registry.d.ts +28 -2
  23. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  24. package/dist/src/mcpl/channel-registry.js +53 -7
  25. package/dist/src/mcpl/channel-registry.js.map +1 -1
  26. package/dist/src/mcpl/transport.d.ts +1 -1
  27. package/dist/src/mcpl/transport.d.ts.map +1 -1
  28. package/dist/src/mcpl/transport.js +25 -7
  29. package/dist/src/mcpl/transport.js.map +1 -1
  30. package/dist/src/mcpl/types.d.ts +10 -0
  31. package/dist/src/mcpl/types.d.ts.map +1 -1
  32. package/dist/src/mcpl/types.js.map +1 -1
  33. package/dist/src/module-registry.d.ts +12 -0
  34. package/dist/src/module-registry.d.ts.map +1 -1
  35. package/dist/src/module-registry.js +26 -0
  36. package/dist/src/module-registry.js.map +1 -1
  37. package/dist/src/modules/workspace/index.d.ts +20 -0
  38. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  39. package/dist/src/modules/workspace/index.js +48 -15
  40. package/dist/src/modules/workspace/index.js.map +1 -1
  41. package/dist/src/modules/workspace/sync.d.ts +4 -0
  42. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  43. package/dist/src/modules/workspace/sync.js +1 -1
  44. package/dist/src/modules/workspace/sync.js.map +1 -1
  45. package/dist/src/types/agent.d.ts +12 -0
  46. package/dist/src/types/agent.d.ts.map +1 -1
  47. package/dist/src/types/framework.d.ts +10 -0
  48. package/dist/src/types/framework.d.ts.map +1 -1
  49. package/dist/src/types/module.d.ts +22 -0
  50. package/dist/src/types/module.d.ts.map +1 -1
  51. package/dist/test/agent-runtime-settings.test.js +37 -0
  52. package/dist/test/agent-runtime-settings.test.js.map +1 -1
  53. package/dist/test/assistant-persist-refusal.test.d.ts +2 -0
  54. package/dist/test/assistant-persist-refusal.test.d.ts.map +1 -0
  55. package/dist/test/assistant-persist-refusal.test.js +63 -0
  56. package/dist/test/assistant-persist-refusal.test.js.map +1 -0
  57. package/dist/test/busy-agent-scheduler.test.js +1 -0
  58. package/dist/test/busy-agent-scheduler.test.js.map +1 -1
  59. package/dist/test/code-execution.test.js +327 -1
  60. package/dist/test/code-execution.test.js.map +1 -1
  61. package/dist/test/deferred-wake-integrity.test.d.ts +29 -0
  62. package/dist/test/deferred-wake-integrity.test.d.ts.map +1 -0
  63. package/dist/test/deferred-wake-integrity.test.js +320 -0
  64. package/dist/test/deferred-wake-integrity.test.js.map +1 -0
  65. package/dist/test/machine-close-provenance.test.d.ts +2 -0
  66. package/dist/test/machine-close-provenance.test.d.ts.map +1 -0
  67. package/dist/test/machine-close-provenance.test.js +110 -0
  68. package/dist/test/machine-close-provenance.test.js.map +1 -0
  69. package/dist/test/mcpl-access-provider.test.d.ts +2 -0
  70. package/dist/test/mcpl-access-provider.test.d.ts.map +1 -0
  71. package/dist/test/mcpl-access-provider.test.js +20 -0
  72. package/dist/test/mcpl-access-provider.test.js.map +1 -0
  73. package/dist/test/module-context-integration.test.d.ts +18 -0
  74. package/dist/test/module-context-integration.test.d.ts.map +1 -0
  75. package/dist/test/module-context-integration.test.js +181 -0
  76. package/dist/test/module-context-integration.test.js.map +1 -0
  77. package/dist/test/present-while-acting.test.js +72 -0
  78. package/dist/test/present-while-acting.test.js.map +1 -1
  79. package/dist/test/utils-surface.test.d.ts +2 -0
  80. package/dist/test/utils-surface.test.d.ts.map +1 -0
  81. package/dist/test/utils-surface.test.js +145 -0
  82. package/dist/test/utils-surface.test.js.map +1 -0
  83. package/dist/test/workspace-read-image.test.js +71 -0
  84. package/dist/test/workspace-read-image.test.js.map +1 -1
  85. package/dist/test/workspace-restore-order.test.d.ts +16 -0
  86. package/dist/test/workspace-restore-order.test.d.ts.map +1 -0
  87. package/dist/test/workspace-restore-order.test.js +140 -0
  88. package/dist/test/workspace-restore-order.test.js.map +1 -0
  89. package/dist/tsconfig.tsbuildinfo +1 -1
  90. package/package.json +1 -1
  91. package/src/agent.ts +31 -1
  92. package/src/code-execution/index.ts +1 -0
  93. package/src/code-execution/py-runner.ts +56 -4
  94. package/src/code-execution/runtime-py.ts +156 -7
  95. package/src/code-execution/tool-definition.ts +27 -2
  96. package/src/framework.ts +1055 -63
  97. package/src/mcpl/channel-registry.ts +98 -4
  98. package/src/mcpl/transport.ts +26 -7
  99. package/src/mcpl/types.ts +11 -0
  100. package/src/module-registry.ts +34 -0
  101. package/src/modules/workspace/index.ts +49 -15
  102. package/src/modules/workspace/sync.ts +1 -1
  103. package/src/types/agent.ts +12 -0
  104. package/src/types/framework.ts +10 -0
  105. package/src/types/module.ts +24 -0
@@ -157,6 +157,61 @@ const PROSE_HELP_TOOL = {
157
157
  };
158
158
  /** Strip the `server--` MCPL prefix from a tool name. */
159
159
  const bareToolName = (n) => n.split('--').pop();
160
+ /** The single slot behind which every rarely-used capability lives. Only
161
+ * advertised when at least one module contributes utilities — with none
162
+ * registered the surface is byte-identical to before this existed. */
163
+ const UTILS_TOOL = {
164
+ name: 'utils',
165
+ description: 'Rarely-used utilities, behind one slot instead of each costing a tool. ' +
166
+ '"list" names them all with one-liners; "describe" returns one utility\'s full ' +
167
+ 'input schema; "run" invokes it (args are validated against that schema — a ' +
168
+ 'miss bounces back with the schema and the specific error).',
169
+ inputSchema: {
170
+ type: 'object',
171
+ properties: {
172
+ action: { type: 'string', enum: ['list', 'describe', 'run'], description: 'What to do.' },
173
+ name: { type: 'string', description: 'Utility name from list (required for describe/run).' },
174
+ args: { type: 'object', description: "Arguments for run, per the utility's schema." },
175
+ },
176
+ required: ['action'],
177
+ },
178
+ };
179
+ /** First sentence (or line) of a description — what `utils list` shows. */
180
+ const utilityOneLiner = (description) => {
181
+ const line = description.split('\n')[0];
182
+ const dot = line.indexOf('. ');
183
+ return (dot >= 0 ? line.slice(0, dot + 1) : line).slice(0, 160);
184
+ };
185
+ /** Shallow validation of run args against a utility's declared inputSchema:
186
+ * required keys, primitive types, enums. Deliberately not a full JSON-Schema
187
+ * engine — handlers still hand-validate (they always did); this layer exists
188
+ * so an arg-shape miss teaches by bounce instead of failing deep in a handler
189
+ * with a less instructive message. Returns an error string or null. */
190
+ function validateUtilityArgs(input, schema) {
191
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
192
+ return 'args must be an object';
193
+ }
194
+ const obj = input;
195
+ for (const req of schema.required ?? []) {
196
+ if (!(req in obj) || obj[req] === undefined)
197
+ return `missing required "${req}"`;
198
+ }
199
+ for (const [key, value] of Object.entries(obj)) {
200
+ const param = schema.properties?.[key];
201
+ if (!param || value === undefined)
202
+ continue; // unknown keys: handler's business
203
+ if (param.enum && !param.enum.includes(value)) {
204
+ return `"${key}" must be one of ${JSON.stringify(param.enum)}`;
205
+ }
206
+ if (param.type) {
207
+ const actual = Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value;
208
+ const expected = param.type === 'integer' ? 'number' : param.type;
209
+ if (actual !== expected)
210
+ return `"${key}" must be ${param.type}, got ${actual}`;
211
+ }
212
+ }
213
+ return null;
214
+ }
160
215
  import { CheckpointManager } from './mcpl/checkpoint-manager.js';
161
216
  import { isToolAllowed } from './mcpl/tool-policy.js';
162
217
  import { EventGate } from './gate/event-gate.js';
@@ -398,6 +453,24 @@ export class AgentFramework {
398
453
  // Messages deferred while an agent is waiting_for_tools (to preserve
399
454
  // tool_use → tool_result adjacency required by the Anthropic API).
400
455
  deferredMessages = [];
456
+ // Turn-alive markers (2026-07-31 Mythos phantom-skip incident): agentName →
457
+ // token of the turn currently in progress. Set at startAgentStream ENTRY —
458
+ // before hooks, compile, and stream setup, all of which await — and cleared
459
+ // token-matched when that turn's teardown finishes (driveStream finally /
460
+ // terminal failure path), so a successor turn's marker survives a
461
+ // predecessor's late finally. A turn is alive from dequeue to settled
462
+ // teardown: strictly longer than `activeStreams` membership, which begins
463
+ // only after the stream exists. Guarding addMessage on stream-liveness
464
+ // alone left the compile window open — a deferred message flushed by the
465
+ // previous turn's finally landed in the window MID-COMPILE of the turn its
466
+ // own wake had started: positioned before the new turn's blocks yet absent
467
+ // from its wire request. The agent then "skipped" a message it never saw
468
+ // (falsified history — it later confabulated an apology for the skip), and
469
+ // every subsequent compile diverged from the live prefix at that message
470
+ // (KV bust). Invariant: nothing enters the window between a turn's dequeue
471
+ // and its settle except that turn's own blocks.
472
+ activeTurnTokens = new Map();
473
+ nextTurnToken = 1;
401
474
  // Undo/redo state
402
475
  turnCounters = new Map(); // agentName → next turnIndex
403
476
  redoStacks = new Map(); // agentName → redo entries
@@ -438,6 +511,15 @@ export class AgentFramework {
438
511
  * deferred and applied to the final code_execution result instead of
439
512
  * cancelling the stream mid-script (which would wedge the turn). */
440
513
  scriptDeferredEndTurn = new Set();
514
+ /** Background (daemon) scripts: model-authored watchers that outlive their
515
+ * spawning turn. Each gets a DEDICATED PyRunner; wake_agent() injects a
516
+ * provenance envelope + payload and requests inference. Keyed by script id. */
517
+ backgroundScripts = new Map();
518
+ backgroundScriptCounter = 0;
519
+ /** Ephemeral per-agent override of the tool-result inline cap (chars).
520
+ * Set via agent_settings `tool_result_inline_max_chars`; NOT persisted —
521
+ * the lift is meant as a temporary gate, reverts on restart/reset. */
522
+ toolResultInlineMaxCharsOverride = new Map();
441
523
  mcplTools = [];
442
524
  mcplToolRefreshInFlight = false;
443
525
  mcplToolRefreshPending = false;
@@ -495,7 +577,12 @@ export class AgentFramework {
495
577
  queryMessages: (filter) => this.queryMessages(filter),
496
578
  pushEvent: (event) => this.pushEvent(event),
497
579
  onTrace: (listener) => this.onTrace(listener),
498
- callTool: (call) => this.executeToolCall(call),
580
+ // The ONLY entry that confers module origin — a private closure the
581
+ // framework hands to its own module registry. The public
582
+ // executeToolCall() is shared with model/ephemeral callers and always
583
+ // stamps agent origin (see there).
584
+ callTool: (call) => this.executeToolCallFrom(call, { kind: 'module' }),
585
+ notifyOps: (kind, agentName, message, data) => this.notifyOps(kind, agentName, message, data),
499
586
  });
500
587
  }
501
588
  /**
@@ -745,6 +832,17 @@ export class AgentFramework {
745
832
  runner.dispose();
746
833
  }
747
834
  this.codeExecutionRunners.clear();
835
+ // Background daemons die with the process (documented limitation) —
836
+ // mark cancelled FIRST so their settle path stays silent (no crash-wake
837
+ // into a framework that is shutting down).
838
+ for (const record of this.backgroundScripts.values()) {
839
+ if (record.status === 'running') {
840
+ record.cancelled = true;
841
+ record.status = 'cancelled';
842
+ }
843
+ record.runner.dispose();
844
+ }
845
+ this.backgroundScripts.clear();
748
846
  // Cancel all active streams
749
847
  for (const agent of this.agents.values()) {
750
848
  if (agent.state.status === 'streaming' ||
@@ -1003,6 +1101,8 @@ export class AgentFramework {
1003
1101
  : [];
1004
1102
  return [
1005
1103
  ...moduleTools,
1104
+ // Utilities collapse into one slot; absent entirely when none exist.
1105
+ ...(this.moduleRegistry.getAllUtilities().length > 0 ? [UTILS_TOOL] : []),
1006
1106
  ...this.mcplTools,
1007
1107
  ...channelTools,
1008
1108
  ...gateTools,
@@ -1038,6 +1138,15 @@ export class AgentFramework {
1038
1138
  collectAgentSettingsExtensions() {
1039
1139
  const result = new Map();
1040
1140
  const taken = new Set(AgentFramework.AGENT_SETTINGS_CORE_KEYS);
1141
+ // Framework-owned extension: the tool-result inline cap lift. Registered
1142
+ // through the same extension surface modules use so get/update/reset all
1143
+ // work with zero extra plumbing. EPHEMERAL by design (a temporary gate —
1144
+ // reverts on restart), unlike module extensions which persist their own.
1145
+ {
1146
+ const ext = this.spillSettingsExtension();
1147
+ ext.keys.forEach((k) => taken.add(k));
1148
+ result.set('_framework', ext);
1149
+ }
1041
1150
  for (const module of this.moduleRegistry.getAllModules()) {
1042
1151
  const ext = module.getAgentSettingsExtension?.();
1043
1152
  if (!ext)
@@ -1052,6 +1161,39 @@ export class AgentFramework {
1052
1161
  }
1053
1162
  return result;
1054
1163
  }
1164
+ /** agent_settings extension for the spill gate (see spillOrTruncate). */
1165
+ spillSettingsExtension() {
1166
+ return {
1167
+ properties: {
1168
+ tool_result_inline_max_chars: {
1169
+ type: 'number',
1170
+ description: 'Inline size cap (chars) for tool results and background-script wake payloads. ' +
1171
+ 'Content over the cap is written to a workspace file under tool-results/ and ' +
1172
+ 'replaced by a truncated preview + file reference. Raise it temporarily when you ' +
1173
+ 'genuinely want a large result inline; reset restores the default (derived from ' +
1174
+ 'your strategy). Ephemeral — reverts on host restart.',
1175
+ },
1176
+ },
1177
+ keys: ['tool_result_inline_max_chars'],
1178
+ get: (agentName) => ({
1179
+ tool_result_inline_max_chars: this.toolResultInlineMaxCharsOverride.get(agentName) ?? null,
1180
+ }),
1181
+ update: (agentName, patch) => {
1182
+ const n = Number(patch.tool_result_inline_max_chars);
1183
+ if (!Number.isFinite(n) || n < 1000) {
1184
+ throw new Error('tool_result_inline_max_chars must be a number >= 1000');
1185
+ }
1186
+ this.toolResultInlineMaxCharsOverride.set(agentName, Math.floor(n));
1187
+ return { tool_result_inline_max_chars: Math.floor(n) };
1188
+ },
1189
+ reset: (agentName, keys) => {
1190
+ if (!keys || keys.includes('tool_result_inline_max_chars')) {
1191
+ this.toolResultInlineMaxCharsOverride.delete(agentName);
1192
+ }
1193
+ return { tool_result_inline_max_chars: null };
1194
+ },
1195
+ };
1196
+ }
1055
1197
  captureInferenceToolSnapshot(agent) {
1056
1198
  return {
1057
1199
  sameRoundThinkTextPolicy: agent.getEffectiveSameRoundThinkTextPolicy(),
@@ -1564,6 +1706,12 @@ export class AgentFramework {
1564
1706
  // ephemeral agents leave checkpoint-tree keys and diagnostics map
1565
1707
  // entries behind for the life of the store/session.
1566
1708
  this.evictTurnCheckpoints(agent.name);
1709
+ // Turn-alive marker, same spawn-and-dispose logic: a stalled ephemeral
1710
+ // (watchdog fired, driveStream's terminal event lost) would strand its
1711
+ // token forever — no wedge (ephemeral names are unique per run) but
1712
+ // unbounded map growth. Blind delete is safe: the agent is already out
1713
+ // of the map, and a late driveStream finally token-match no-ops.
1714
+ this.activeTurnTokens.delete(agent.name);
1567
1715
  }
1568
1716
  }
1569
1717
  /**
@@ -1787,6 +1935,11 @@ export class AgentFramework {
1787
1935
  context_budget_tokens: { type: 'number', description: 'Total input context budget, including the reserved response allowance.' },
1788
1936
  tail_tokens: { type: 'number', description: 'Recent raw context retained verbatim.' },
1789
1937
  transition_pace_tokens: { type: 'number', description: 'Maximum ordinary KV re-read/perturbation per compile while converging.' },
1938
+ immediate: {
1939
+ type: 'boolean',
1940
+ description: 'With a context_budget_tokens DECREASE: apply it immediately instead of a paced descent — ' +
1941
+ 'the whole fold-down and its KV re-read land on the next compile, and any in-flight descent is cancelled.',
1942
+ },
1790
1943
  same_round_think_text_policy: {
1791
1944
  type: 'string',
1792
1945
  enum: ['public', 'private'],
@@ -2842,6 +2995,18 @@ export class AgentFramework {
2842
2995
  }
2843
2996
  // Compute truncation limit from agent's strategy (maxMessageTokens * 4 chars)
2844
2997
  const maxChars = this.getMaxToolResultChars(agent);
2998
+ // Oversized results spill to a workspace file (truncated preview +
2999
+ // file reference) instead of being blind-truncated. Computed ONCE
3000
+ // per call and reused for both the history copy and the live wire
3001
+ // copy below — the two must stay byte-matched (the window stores
3002
+ // what the membrane sends; divergence breaks the compile prefix).
3003
+ const spilled = new Map();
3004
+ const dateLabel = new Date().toISOString().slice(0, 10);
3005
+ for (const tc of currentState.toolResults) {
3006
+ if (tc.result.isError)
3007
+ continue;
3008
+ spilled.set(tc.id, await this.spillOrTruncate(toolResultDataToHistoryString(tc.result.data, undefined), maxChars, `${dateLabel}-${tc.id}`, agent.name));
3009
+ }
2845
3010
  // Store tool results as a user message (tool_result blocks).
2846
3011
  // Use the history serializer so MCP image blocks become a short
2847
3012
  // `[image: type, size]` placeholder instead of megabytes of base64
@@ -2851,7 +3016,7 @@ export class AgentFramework {
2851
3016
  toolUseId: tc.id,
2852
3017
  content: tc.result.isError
2853
3018
  ? tc.result.error ?? 'Unknown error'
2854
- : toolResultDataToHistoryString(tc.result.data, maxChars),
3019
+ : spilled.get(tc.id) ?? toolResultDataToHistoryString(tc.result.data, maxChars),
2855
3020
  isError: tc.result.isError,
2856
3021
  }));
2857
3022
  agent.getContextManager().addMessage('user', toolResultContent);
@@ -2918,6 +3083,66 @@ export class AgentFramework {
2918
3083
  const overBudget = currentState.stream
2919
3084
  && agent.lastStreamInputTokens > 0
2920
3085
  && agent.lastStreamInputTokens > agent.maxStreamTokens;
3086
+ // Addressed re-pin (2026-07-31 Mythos misroutes, antra-ratified):
3087
+ // when someone ADDRESSES the agent mid-turn from another channel
3088
+ // (mention / reply / DM — the same chat:addressed signal the
3089
+ // turn-START batch policy prefers), the agent's subsequent plain
3090
+ // prose almost always answers THEM. Six recorded misroutes
3091
+ // delivered those answers to the stale frozen locus instead (the
3092
+ // LabClaude answer into #hospital_commons mid-examination; the
3093
+ // hospital scene into antra's DM). Re-pin the turn locus to the
3094
+ // LAST addressed conversational injection, at segment granularity:
3095
+ // prose routed in earlier rounds already went to the old pin,
3096
+ // which was correct when it was delivered. Ambient chatter,
3097
+ // reactions, and system markers still cannot move the pin — the
3098
+ // 2026-07-21 Cairn hijack protection is unchanged; this extends
3099
+ // the ratified addressed-outranks-ambient principle from turn
3100
+ // start to boundaries. Locus mode only (explicit mode has no pin).
3101
+ // Skipped on endTurn/overBudget: no further prose this stream, and
3102
+ // a stale "for this turn" notice would be pure noise.
3103
+ //
3104
+ // The notice rides the SAME injection batch — stored right after
3105
+ // the flushed messages and appended to midTurnInjections — so the
3106
+ // window byte-matches the wire (short event-style notice: the
3107
+ // classifier-safe D-class from the 2026-07-24 ablation). It also
3108
+ // updates lastAnnouncedLocus so the next turn's announce-on-change
3109
+ // diffs against what the agent was actually last told.
3110
+ if (agent.proseRouting !== 'explicit' &&
3111
+ !shouldEndTurn && !overBudget && currentState.stream) {
3112
+ const lastAddressed = [...midTurnInjections].reverse().find((inj) => {
3113
+ const m = inj.metadata;
3114
+ return (isConversationalInjection(inj.metadata) &&
3115
+ typeof m?.channelId === 'string' &&
3116
+ isAddressedMessage(m.tags, m));
3117
+ });
3118
+ const newLocus = lastAddressed?.metadata
3119
+ ?.channelId;
3120
+ if (newLocus && this.turnLocusPins.get(agent.name) !== newLocus) {
3121
+ const prevPin = this.turnLocusPins.get(agent.name) ?? null;
3122
+ this.turnLocusPins.set(agent.name, newLocus);
3123
+ this.lastAnnouncedLocus.set(agent.name, newLocus);
3124
+ const label = this.channelRegistry?.getDescriptor(newLocus)?.label;
3125
+ const shown = label && label !== newLocus
3126
+ ? `${label.startsWith('#') ? label : `#${label}`} (${newLocus})`
3127
+ : newLocus;
3128
+ const noticeContent = [{
3129
+ type: 'text',
3130
+ text: `[routing] You were just addressed from ${shown} — your plain ` +
3131
+ 'speech now lands there for the rest of this turn. Other ' +
3132
+ 'channels need an explicit send tool.',
3133
+ }];
3134
+ const noticeMeta = { system: true, kind: 'routing-notice' };
3135
+ try {
3136
+ const id = agent.getContextManager().addMessage('user', noticeContent, noticeMeta);
3137
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
3138
+ midTurnInjections.push({ participant: 'user', content: noticeContent, metadata: noticeMeta });
3139
+ console.error(`[routing] ${agent.name}: mid-turn addressed re-pin ${prevPin ?? '(none)'} -> ${newLocus}`);
3140
+ }
3141
+ catch (err) {
3142
+ console.error('mid-turn locus re-pin: failed to record routing notice:', err);
3143
+ }
3144
+ }
3145
+ }
2921
3146
  if (shouldEndTurn) {
2922
3147
  // endTurn: messages already stored above, cancel stream, reset to idle.
2923
3148
  if (currentState.stream) {
@@ -2943,6 +3168,16 @@ export class AgentFramework {
2943
3168
  // endTurn above).
2944
3169
  if (currentState.stream) {
2945
3170
  this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart');
3171
+ // Cancel the LIVE stream object directly (like the endTurn
3172
+ // branch above): at a tool boundary the state machine reads
3173
+ // 'ready', and Agent.cancelStream only signals the stream from
3174
+ // 'streaming'/'waiting_for_tools' — so the old call left the
3175
+ // provider stream parked forever awaiting tool results that
3176
+ // would never come (zombie: driveStream's finally never ran,
3177
+ // its activeStreams entry survived until the restart's stream
3178
+ // overwrote it). Harmless by accident before turn-alive
3179
+ // tracking; a genuine teardown leak after it.
3180
+ currentState.stream.cancel();
2946
3181
  }
2947
3182
  agent.cancelStream();
2948
3183
  this.emitTrace({
@@ -2964,7 +3199,7 @@ export class AgentFramework {
2964
3199
  // Mid-turn messages collected above ride along as injected user
2965
3200
  // messages (membrane ≥0.5.72) — appended after the tool_result
2966
3201
  // envelope so the next round of THIS turn hears them.
2967
- const membraneResults = currentState.toolResults.map(tc => this.toMembraneToolResult(tc.id, tc.result, maxChars));
3202
+ const membraneResults = currentState.toolResults.map(tc => this.toMembraneToolResult(tc.id, tc.result, maxChars, spilled.get(tc.id)));
2968
3203
  currentState.stream.provideToolResults(membraneResults, midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections } : undefined);
2969
3204
  agent.setStreaming(currentState.stream);
2970
3205
  this.emitTrace({
@@ -3105,7 +3340,26 @@ export class AgentFramework {
3105
3340
  await this.routeConversationIncoming(event, metadata);
3106
3341
  return;
3107
3342
  }
3108
- const id = this.addMessage('user', event.content, metadata);
3343
+ // Addressed-while-closed invitation — parity with the push-event path
3344
+ // (channels.publish surfaces like portal-mcpl deliver mentions here).
3345
+ const incomingContent = [...event.content];
3346
+ {
3347
+ const invitation = this.buildClosedChannelInvitation({
3348
+ serverId: event.serverId,
3349
+ channelId: event.channelId,
3350
+ messageId: event.messageId,
3351
+ authorName: event.author?.name || 'Someone',
3352
+ guildName: typeof event.metadata?.guildName === 'string' && event.metadata.guildName
3353
+ ? event.metadata.guildName
3354
+ : undefined,
3355
+ tags: event.tags,
3356
+ });
3357
+ if (invitation) {
3358
+ incomingContent.push(invitation.block);
3359
+ Object.assign(metadata, invitation.metadataPatch);
3360
+ }
3361
+ }
3362
+ const id = this.addMessage('user', incomingContent, metadata);
3109
3363
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
3110
3364
  if (event.triggerInference) {
3111
3365
  const addressed = isAddressedMessage(event.tags, event.metadata);
@@ -3352,6 +3606,60 @@ export class AgentFramework {
3352
3606
  }
3353
3607
  }
3354
3608
  }
3609
+ /**
3610
+ * Build the addressed-while-CLOSED channel invitation block, shared by the
3611
+ * push-event and channels/incoming ingest paths. Until 2026-07-27 only push
3612
+ * events (discord-mcpl mentions) carried it; channels.publish surfaces
3613
+ * (portal-mcpl) delivered bare mentions with no guidance and no route — an
3614
+ * explicit-prose agent's natural plain reply bounced with nowhere to go
3615
+ * (Rhys's first portal mention: a 2,320-char reply stranded).
3616
+ *
3617
+ * Wording: option 1 teaches the `>>` destination prefix, which delivers for
3618
+ * BOTH legacy (host-inferred) and explicit prose-routing agents. The
3619
+ * previous "simply write your reply as normal text" was a false promise
3620
+ * under explicit routing (the metadata flags it set had no consumers).
3621
+ *
3622
+ * Human-readable names wherever the surface provided them — a mind reading
3623
+ * this should see WHO addressed it WHERE, not a wall of snowflake ids. Raw
3624
+ * ids remain only inside the tool-argument instructions, where they are the
3625
+ * literal values to pass.
3626
+ */
3627
+ buildClosedChannelInvitation(opts) {
3628
+ if (!this.channelRegistry)
3629
+ return null;
3630
+ if (!opts.tags?.includes('chat:addressed'))
3631
+ return null;
3632
+ if (this.channelRegistry.isChannelOpen(opts.channelId))
3633
+ return null;
3634
+ const descriptor = this.channelRegistry.getDescriptor(opts.channelId);
3635
+ const maxBackscroll = descriptor?.capabilities?.history?.maxMessages ?? 0;
3636
+ const label = opts.channelLabel ?? descriptor?.label;
3637
+ const channelLabel = label ? `#${label}` : `"${opts.channelId}"`;
3638
+ const place = opts.guildName ? `${channelLabel} in "${opts.guildName}"` : channelLabel;
3639
+ const block = {
3640
+ type: 'text',
3641
+ text: `\n[Channel invitation] ${opts.authorName} addressed you in ${place} — ` +
3642
+ `a channel you haven't joined. You're seeing this one message; you won't receive more from it unless you join. ` +
3643
+ `Your options:\n` +
3644
+ `1. Reply without joining — write your reply this turn prefixed with ">>${channelLabel}"; it will be delivered there.\n` +
3645
+ `2. Join the channel — call channel_open with channelId "${opts.channelId}" and serverId "${opts.serverId}"` +
3646
+ (maxBackscroll > 0
3647
+ ? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${opts.messageId}".\n`
3648
+ : `.\n`) +
3649
+ `3. Stay out — call channel_decline with channelId "${opts.channelId}", serverId "${opts.serverId}", ` +
3650
+ `and messageId "${opts.messageId}" (optionally set acknowledge to an emoji like 👀 so ${opts.authorName} isn't left hanging).\n` +
3651
+ `Doing nothing is also fine.`,
3652
+ };
3653
+ return {
3654
+ block,
3655
+ metadataPatch: {
3656
+ channelInvitation: true,
3657
+ channelOpen: false,
3658
+ channelId: opts.channelId,
3659
+ invitationMessageId: opts.messageId,
3660
+ },
3661
+ };
3662
+ }
3355
3663
  /**
3356
3664
  * Convert an MCPL push event to a context message.
3357
3665
  */
@@ -3378,44 +3686,21 @@ export class AgentFramework {
3378
3686
  metadata.channelId = triggerChannel.channelId;
3379
3687
  }
3380
3688
  const content = [...event.content];
3381
- const addressedWhileClosed = triggerChannel &&
3382
- event.tags?.includes('chat:addressed') &&
3383
- this.channelRegistry &&
3384
- !this.channelRegistry.isChannelOpen(triggerChannel.channelId);
3385
- if (addressedWhileClosed) {
3386
- const descriptor = this.channelRegistry.getDescriptor(triggerChannel.channelId);
3387
- const messageId = typeof event.origin?.messageId === 'string'
3388
- ? event.origin.messageId
3389
- : event.eventId;
3390
- const maxBackscroll = descriptor?.capabilities?.history?.maxMessages ?? 0;
3391
- // Resolve human-readable names from the event origin wherever the
3392
- // surface provided them — a mind reading this should see WHO addressed
3393
- // it WHERE, not a wall of snowflake ids. Raw ids remain only inside the
3394
- // tool-argument instructions, where they are the literal values to pass.
3689
+ if (triggerChannel) {
3395
3690
  const origin = (event.origin ?? {});
3396
- const authorName = typeof origin.authorName === 'string' && origin.authorName ? origin.authorName : 'Someone';
3397
- const channelLabel = triggerChannel.label ? `#${triggerChannel.label}` : `"${triggerChannel.channelId}"`;
3398
- const place = typeof origin.guildName === 'string' && origin.guildName
3399
- ? `${channelLabel} in "${origin.guildName}"`
3400
- : channelLabel;
3401
- content.push({
3402
- type: 'text',
3403
- text: `\n[Channel invitation] ${authorName} addressed you in ${place} — ` +
3404
- `a channel you haven't joined. You're seeing this one message; you won't receive more from it unless you join. ` +
3405
- `Your options:\n` +
3406
- `1. Reply without joining — simply write your reply as normal text this turn; it will be delivered to ${channelLabel}.\n` +
3407
- `2. Join the channel — call channel_open with channelId "${triggerChannel.channelId}" and serverId "${event.serverId}"` +
3408
- (maxBackscroll > 0
3409
- ? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${messageId}".\n`
3410
- : `.\n`) +
3411
- `3. Stay out — call channel_decline with channelId "${triggerChannel.channelId}", serverId "${event.serverId}", ` +
3412
- `and messageId "${messageId}" (optionally set acknowledge to an emoji like 👀 so ${authorName} isn't left hanging).\n` +
3413
- `Doing nothing is also fine.`,
3691
+ const invitation = this.buildClosedChannelInvitation({
3692
+ serverId: event.serverId,
3693
+ channelId: triggerChannel.channelId,
3694
+ channelLabel: triggerChannel.label,
3695
+ messageId: typeof origin.messageId === 'string' ? origin.messageId : event.eventId,
3696
+ authorName: typeof origin.authorName === 'string' && origin.authorName ? origin.authorName : 'Someone',
3697
+ guildName: typeof origin.guildName === 'string' && origin.guildName ? origin.guildName : undefined,
3698
+ tags: event.tags,
3414
3699
  });
3415
- metadata.channelInvitation = true;
3416
- metadata.channelOpen = false;
3417
- metadata.channelId = triggerChannel.channelId;
3418
- metadata.invitationMessageId = messageId;
3700
+ if (invitation) {
3701
+ content.push(invitation.block);
3702
+ Object.assign(metadata, invitation.metadataPatch);
3703
+ }
3419
3704
  }
3420
3705
  const id = this.addMessage('user', content, metadata);
3421
3706
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
@@ -3521,8 +3806,24 @@ export class AgentFramework {
3521
3806
  console.error(`[inference-dropped] agent=${agentName} reason=agent_not_found requests=${requests.length}`);
3522
3807
  continue;
3523
3808
  }
3524
- // Skip if agent is busy (inferring, streaming, or waiting for tools)
3525
- if (agent.state.status === 'inferring' || agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') {
3809
+ // Skip if agent is busy (inferring, streaming, or waiting for tools) —
3810
+ // or if a turn is alive at all (activeTurnTokens): the state machine
3811
+ // goes idle at endTurn's reset while driveStream teardown is still
3812
+ // pending, and a successor turn dequeued in that gap compiles while the
3813
+ // predecessor's deferred flush can still write (2026-07-31 Mythos).
3814
+ // Turn-alive spans dequeue → settled teardown; wakes wait it out.
3815
+ //
3816
+ // EXCEPT a context-budget restart: it CONTINUES the turn whose token is
3817
+ // held — queueing it behind its own turn's teardown is a deadlock when
3818
+ // that teardown lags (the cancelled stream's `aborted` event is
3819
+ // asynchronous and, historically, not even guaranteed — see the
3820
+ // overBudget branch). The restart's startAgentStream overwrites the
3821
+ // token; the predecessor frame's late finally no-ops via token-match.
3822
+ // This mirrors how the restart has always overwritten activeStreams
3823
+ // rather than waiting for the old stream's teardown.
3824
+ const isBudgetRestart = requests.some((r) => r.reason === 'context_budget_restart');
3825
+ const turnAlive = !isBudgetRestart && this.activeTurnTokens.has(agentName);
3826
+ if (turnAlive || agent.state.status === 'inferring' || agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') {
3526
3827
  // Re-queue requests, but warn if they've been pending too long
3527
3828
  const oldest = Math.min(...requests.map(r => r.timestamp));
3528
3829
  if (now - oldest > STALE_REQUEST_MS &&
@@ -3532,14 +3833,21 @@ export class AgentFramework {
3532
3833
  // (up to 100/sec after the scheduler backoff), adding avoidable work
3533
3834
  // precisely while the agent was already under pressure.
3534
3835
  this.staleWarnAt.set(agentName, now);
3836
+ // Surface turn-alive separately from the state machine: status can
3837
+ // read 'idle' while a turn's teardown is pending, and a LEAKED turn
3838
+ // token would look exactly like this — permanently requeued wakes.
3839
+ // This line is the wedge's tell (idle+turn-alive, forever).
3840
+ const shownStatus = turnAlive && agent.state.status === 'idle'
3841
+ ? 'idle+turn-alive'
3842
+ : agent.state.status;
3535
3843
  this.emitTrace({
3536
3844
  type: 'inference:request_stale',
3537
3845
  agentName,
3538
- agentStatus: agent.state.status,
3846
+ agentStatus: shownStatus,
3539
3847
  requestCount: requests.length,
3540
3848
  oldestRequestAge: now - oldest,
3541
3849
  });
3542
- console.error(`[inference-stale] agent=${agentName} busy (${agent.state.status}) — ` +
3850
+ console.error(`[inference-stale] agent=${agentName} busy (${shownStatus}) — ` +
3543
3851
  `${requests.length} request(s) waiting ${Math.round((now - oldest) / 1000)}s`);
3544
3852
  }
3545
3853
  this.pendingRequests.push(...requests);
@@ -3842,6 +4150,75 @@ export class AgentFramework {
3842
4150
  }
3843
4151
  }
3844
4152
  async startAgentStream(agent, trigger, attempt = 0) {
4153
+ // Mark the turn alive before ANYTHING awaits (hooks, compile, stream
4154
+ // setup): from here until this turn's teardown, cross-turn writers defer
4155
+ // (addMessage guard) instead of appending — nothing may enter the window
4156
+ // between a turn's dequeue and its settle except the turn's own blocks.
4157
+ // A retry re-enters with a fresh token; teardown clears token-matched.
4158
+ //
4159
+ // The try/finally is the leak-proofing: a token whose owner never clears
4160
+ // it permanently requeues every wake for the agent (the 'idle+turn-alive'
4161
+ // wedge — worse than any single lost turn). Ownership is handed off
4162
+ // exactly once, to driveStream, the moment the stream handle is
4163
+ // registered; on every other exit — including a throw from the turn-start
4164
+ // flush, recordTurnCheckpoint, or locus resolution, none of which the
4165
+ // inner catch covers (ENOSPC-class store writes live there) — the finally
4166
+ // clears our own token. Token-matched, so when a retry frame has replaced
4167
+ // the token, or the terminal-failure branch already deleted it, this
4168
+ // no-ops instead of clobbering a successor's marker.
4169
+ const turnToken = this.nextTurnToken++;
4170
+ this.activeTurnTokens.set(agent.name, turnToken);
4171
+ let tokenHandedOff = false;
4172
+ try {
4173
+ tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken);
4174
+ }
4175
+ finally {
4176
+ if (!tokenHandedOff && this.activeTurnTokens.get(agent.name) === turnToken) {
4177
+ this.activeTurnTokens.delete(agent.name);
4178
+ }
4179
+ }
4180
+ }
4181
+ /**
4182
+ * Body of startAgentStream, split out so the caller's try/finally owns the
4183
+ * turn token unconditionally. Returns true iff the token was handed off to
4184
+ * driveStream (whose finally then owns clearing it).
4185
+ */
4186
+ async beginAgentTurn(agent, trigger, attempt, turnToken) {
4187
+ // Flush messages deferred during the PREVIOUS turn — before the
4188
+ // checkpoint, the locus announcement, and the compile — so a turn started
4189
+ // by a queued wake actually CONTAINS the message that woke it. (2026-07-31
4190
+ // Mythos: a DM's wake fired a turn whose compile ran before the deferred
4191
+ // DM flushed; the model saw only the routing-shift notice, reasonably
4192
+ // skipped, and the DM then landed before the skip in the window — false
4193
+ // "saw it and ignored it" history plus KV divergence. Flushed here, the
4194
+ // payload rides its own wake, and the notice/payload can never split.)
4195
+ // Primary agent only — deferred messages are framework-level, meant for
4196
+ // the main conversation (same target rule as the tool-boundary flush).
4197
+ // Fresh turns only: a context-budget restart continues the same logical
4198
+ // turn, and flushing there would insert messages between its rounds.
4199
+ // Before recordTurnCheckpoint: these are the turn's inputs, not its
4200
+ // products — undoing the turn must not destroy them.
4201
+ if (attempt === 0 &&
4202
+ trigger?.reason !== 'context_budget_restart' &&
4203
+ this.deferredMessages.length > 0) {
4204
+ const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent;
4205
+ if (target === agent) {
4206
+ const deferred = this.deferredMessages.splice(0);
4207
+ for (const msg of deferred) {
4208
+ // Per-message try/catch (mirrors announceLocusIfChanged): one
4209
+ // poison message — or a transient store-write failure — must not
4210
+ // abort the turn or drop the messages behind it in the queue.
4211
+ try {
4212
+ const id = agent.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
4213
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'deferred-flush:turn-start' });
4214
+ }
4215
+ catch (err) {
4216
+ console.error(`[deferred-flush] turn-start flush failed to store a message for ${agent.name} ` +
4217
+ `(participant=${msg.participant}):`, err);
4218
+ }
4219
+ }
4220
+ }
4221
+ }
3845
4222
  // Record turn checkpoint before inference (only on first attempt, not retries)
3846
4223
  if (attempt === 0) {
3847
4224
  this.recordTurnCheckpoint(agent.name);
@@ -3913,6 +4290,20 @@ export class AgentFramework {
3913
4290
  });
3914
4291
  this.eventGate?.onInferenceStarted(agent.name);
3915
4292
  this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
4293
+ // Typing indicator, started at TURN START. It says "attending", which is
4294
+ // true from the moment the turn is accepted — but it used to start inside
4295
+ // driveStream, i.e. only after module gatherContext + MCPL beforeInference
4296
+ // RPCs + the full context compile + stream initiation. All of that read as
4297
+ // dead air to whoever just messaged the agent (30+ s during the 2026-07
4298
+ // mythos compile regression; still seconds of hooks+compile after).
4299
+ // Same channel choice as driveStream's own startTyping (which stays, is
4300
+ // idempotent per channel, and owns the 7s refresh); the catch below stops
4301
+ // it on the no-driveStream failure paths (e.g. a compile refusal).
4302
+ const earlyTypingChannel = agent.proseRouting === 'explicit'
4303
+ ? trigger?.channelId ?? null
4304
+ : this.turnLocusPins.get(agent.name) ?? null;
4305
+ if (earlyTypingChannel)
4306
+ this.channelRegistry?.startTyping(earlyTypingChannel);
3916
4307
  try {
3917
4308
  const requestSnapshot = this.captureInferenceToolSnapshot(agent);
3918
4309
  const allTools = this.getToolsForAgent(agent.name, requestSnapshot);
@@ -3954,8 +4345,15 @@ export class AgentFramework {
3954
4345
  const { stream, request: compiledRequest } = await agent.startStreamWithInjections(tools, injections);
3955
4346
  const handle = this.driveStream(agent, stream, requestSnapshot, trigger, attempt, compiledRequest);
3956
4347
  this.activeStreams.set(agent.name, handle);
4348
+ // Handoff: driveStream captured the token in its synchronous prefix;
4349
+ // its finally now owns clearing it. The caller's finally must not.
4350
+ return true;
3957
4351
  }
3958
4352
  catch (error) {
4353
+ // The early typing indicator has no driveStream `finally` on this path —
4354
+ // a hook/compile/stream-setup failure must not leave "typing…" stuck.
4355
+ // (Retries below restart it; stopTyping is idempotent.)
4356
+ this.channelRegistry?.stopTyping();
3959
4357
  const err = error instanceof Error ? error : new Error(String(error));
3960
4358
  this.emitTrace({
3961
4359
  type: 'inference:failed',
@@ -3967,9 +4365,19 @@ export class AgentFramework {
3967
4365
  const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
3968
4366
  if (action.retry) {
3969
4367
  await new Promise((resolve) => setTimeout(resolve, action.delayMs));
4368
+ // The retry re-enters startAgentStream, which replaces this frame's
4369
+ // turn token with its own; cleanup belongs to the innermost frame.
3970
4370
  await this.startAgentStream(agent, trigger, attempt + 1);
3971
4371
  }
3972
4372
  else {
4373
+ // Terminal failure with no driveStream: this frame owns teardown.
4374
+ // Token-matched (like the finally in driveStream) so a stale frame
4375
+ // can never clear a successor turn's marker. A leaked token would
4376
+ // permanently requeue every wake for this agent (same wedge shape as
4377
+ // the eventGate `inferring` leak) — every exit path must clear it.
4378
+ if (this.activeTurnTokens.get(agent.name) === turnToken) {
4379
+ this.activeTurnTokens.delete(agent.name);
4380
+ }
3973
4381
  this.settleAgent(agent.name, {
3974
4382
  stopReason: 'exhausted',
3975
4383
  speech: '',
@@ -3993,12 +4401,22 @@ export class AgentFramework {
3993
4401
  this.pushEvent(action.emit);
3994
4402
  }
3995
4403
  }
4404
+ // No handoff on either failure branch: the terminal branch deleted the
4405
+ // token above; the retry branch's recursive startAgentStream replaced
4406
+ // it with the inner frame's token. Returning false lets the caller's
4407
+ // token-matched finally no-op in both cases (and actually clear it if
4408
+ // something above threw before either branch ran).
4409
+ return false;
3996
4410
  }
3997
4411
  }
3998
4412
  async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest) {
3999
4413
  const startTime = Date.now();
4000
4414
  const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
4001
4415
  const myStreamId = agent.streamId;
4416
+ // This turn's alive-marker, set at startAgentStream entry. Safe to read
4417
+ // from the map here: the turn-alive busy check in processInferenceRequests
4418
+ // means no successor turn can have replaced it while we compiled.
4419
+ const myTurnToken = this.activeTurnTokens.get(agent.name);
4002
4420
  let hadToolCalls = false;
4003
4421
  // ---- Present-while-acting turn state ---------------------------------
4004
4422
  // Output locus for the WHOLE logical turn: frozen in startAgentStream
@@ -4799,6 +5217,16 @@ export class AgentFramework {
4799
5217
  if (this.closingConversationAgents.has(agent.name)) {
4800
5218
  this.disposeConversationAgent(agent.name);
4801
5219
  }
5220
+ // The turn is torn down — release the turn-alive marker BEFORE the
5221
+ // deferred flush below, so the flush appends at the settled tail.
5222
+ // Token-matched: if a successor turn already owns the agent (endTurn
5223
+ // reset the state machine before this finally ran — the 2026-07-31
5224
+ // interleave), leave its marker alone; the guarded addMessage below
5225
+ // then re-defers, and the successor's own turn-start flush / boundary
5226
+ // injection / teardown delivers the messages at a correct position.
5227
+ if (myTurnToken !== undefined && this.activeTurnTokens.get(agent.name) === myTurnToken) {
5228
+ this.activeTurnTokens.delete(agent.name);
5229
+ }
4802
5230
  // Flush any deferred messages (e.g. if stream failed while tools were pending)
4803
5231
  if (this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
4804
5232
  const deferred = this.deferredMessages.splice(0);
@@ -4812,8 +5240,22 @@ export class AgentFramework {
4812
5240
  * Execute a tool call and return the result.
4813
5241
  * Routes to the appropriate handler (module registry or MCPL).
4814
5242
  * Used by SubagentModule to dispatch tool calls for ephemeral agents.
5243
+ *
5244
+ * This public entry always carries AGENT origin. Ephemeral/promise-based
5245
+ * callers are models, and provenance must identify which internal path a
5246
+ * call entered through — a public method shared with model callers can
5247
+ * never confer module trust, whatever the input claims. Module origin
5248
+ * exists only through the private closure handed to ModuleRegistry
5249
+ * (ctx.callTool → executeToolCallFrom with {kind:'module'}). Callers that
5250
+ * don't identify themselves fail safe to agent semantics too.
4815
5251
  */
4816
5252
  async executeToolCall(call) {
5253
+ return this.executeToolCallFrom(call, {
5254
+ kind: 'agent',
5255
+ agentName: call.callerAgentName ?? '__ephemeral__',
5256
+ });
5257
+ }
5258
+ async executeToolCallFrom(call, origin) {
4817
5259
  // Client-side programmatic tool calling for promise-based callers
4818
5260
  // (SubagentModule ephemerals). Keyed by callerAgentName so each ephemeral
4819
5261
  // gets its own interpreter state.
@@ -4832,6 +5274,25 @@ export class AgentFramework {
4832
5274
  }
4833
5275
  }
4834
5276
  }
5277
+ // Synthesized channel tools. This route was missing: when
5278
+ // subscription-GC moved off the retired MCPL unsubscribe tool onto the
5279
+ // generic channel_close (discord-mcpl b095a9f), module-originated
5280
+ // closes started failing downstream as "Invalid tool name format" —
5281
+ // the janitor had been silently broken since. `origin` is trusted
5282
+ // dispatch context established at the call boundary above, not
5283
+ // inferred from which method was used: only the ModuleRegistry closure
5284
+ // carries module provenance (see handleToolClose). The agent-only
5285
+ // synthesized verbs (think, skip_reply) are deliberately not routed
5286
+ // here.
5287
+ if (call.name.startsWith('channel_') && this.channelRegistry) {
5288
+ return this.channelRegistry.handleChannelToolCall(call.name, call.input, origin);
5289
+ }
5290
+ // The utils meta-tool — programmatic callers (code_execution scripts,
5291
+ // ephemerals, ModuleContext.callTool) reach utilities the same way the
5292
+ // model does.
5293
+ if (call.name === 'utils') {
5294
+ return this.handleUtilsToolCall(call.callerAgentName ?? '__ephemeral__', call);
5295
+ }
4835
5296
  // Module tools
4836
5297
  return this.moduleRegistry.handleToolCall(call);
4837
5298
  }
@@ -4927,6 +5388,43 @@ export class AgentFramework {
4927
5388
  */
4928
5389
  async runCodeExecution(agentName, call) {
4929
5390
  const input = (call.input ?? {});
5391
+ // Management surface: the agent's own daemon fleet is inspectable and
5392
+ // killable through the same tool that spawns it.
5393
+ if (input.action === 'list') {
5394
+ const scripts = [...this.backgroundScripts.values()]
5395
+ .filter((s) => s.agentName === agentName)
5396
+ .map((s) => ({
5397
+ script_id: s.id,
5398
+ status: s.status,
5399
+ started_at: new Date(s.startedAt).toISOString(),
5400
+ wakes: s.wakes,
5401
+ last_wake_at: s.lastWakeAt ? new Date(s.lastWakeAt).toISOString() : null,
5402
+ log: s.logPath,
5403
+ }));
5404
+ return { success: true, data: { background_scripts: scripts } };
5405
+ }
5406
+ if (input.action === 'cancel') {
5407
+ if (typeof input.script_id !== 'string') {
5408
+ return { success: false, error: 'cancel requires `script_id`', isError: true };
5409
+ }
5410
+ const record = this.backgroundScripts.get(input.script_id);
5411
+ if (!record || record.agentName !== agentName) {
5412
+ return { success: false, error: `no background script '${input.script_id}'`, isError: true };
5413
+ }
5414
+ if (record.status === 'running') {
5415
+ record.cancelled = true;
5416
+ record.status = 'cancelled';
5417
+ record.runner.abort('cancelled by agent');
5418
+ record.runner.dispose();
5419
+ }
5420
+ return {
5421
+ success: true,
5422
+ data: { script_id: record.id, status: record.status, log: record.logPath },
5423
+ };
5424
+ }
5425
+ if (input.action !== undefined && input.action !== 'run') {
5426
+ return { success: false, error: `unknown action ${JSON.stringify(input.action)}`, isError: true };
5427
+ }
4930
5428
  if (typeof input.code !== 'string' || input.code.trim() === '') {
4931
5429
  return {
4932
5430
  success: false,
@@ -4939,6 +5437,9 @@ export class AgentFramework {
4939
5437
  ? this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name))
4940
5438
  : this.getAllTools(); // ephemeral agents: full surface, matching executeToolCall
4941
5439
  const injected = buildInjectedTools(surface.map((t) => t.name).filter((name) => name !== CODE_EXECUTION_TOOL_NAME));
5440
+ if (input.background === true) {
5441
+ return this.startBackgroundScript(agentName, input.code, injected);
5442
+ }
4942
5443
  const runner = this.getOrCreateScriptRunner(agentName);
4943
5444
  this.scriptDeferredEndTurn.delete(agentName);
4944
5445
  const exec = await runner.exec(input.code, injected);
@@ -4950,6 +5451,255 @@ export class AgentFramework {
4950
5451
  ...(endTurn ? { endTurn: true } : {}),
4951
5452
  };
4952
5453
  }
5454
+ // ---------------------------------------------------------------------------
5455
+ // Background (daemon) scripts — model-authored watchers that outlive the turn
5456
+ // ---------------------------------------------------------------------------
5457
+ /**
5458
+ * Start a background script: dedicated interpreter, output journaled to a
5459
+ * workspace file, wake_agent() available in-script. The tool result
5460
+ * returns immediately; the script runs until it ends, hits the lifetime
5461
+ * ceiling, or is cancelled. Scripts DIE WITH THE HOST PROCESS — that
5462
+ * limitation is stated in the tool description so agents can calibrate
5463
+ * how much to rely on an armed wake.
5464
+ */
5465
+ startBackgroundScript(agentName, code, injected) {
5466
+ // v1: primary-agent-only. A conversation fork's or ephemeral's daemon
5467
+ // would outlive its owner and its wake would land in the primary
5468
+ // conversation anyway — refuse loudly instead of surprising anyone.
5469
+ if (this.primaryAgentName && agentName !== this.primaryAgentName) {
5470
+ return {
5471
+ success: false,
5472
+ error: 'background scripts are available to the primary agent only',
5473
+ isError: true,
5474
+ };
5475
+ }
5476
+ const cfg = this.codeExecutionConfig;
5477
+ const maxScripts = cfg?.maxBackgroundScripts ?? 3;
5478
+ const runningCount = [...this.backgroundScripts.values()]
5479
+ .filter((s) => s.agentName === agentName && s.status === 'running').length;
5480
+ if (runningCount >= maxScripts) {
5481
+ return {
5482
+ success: false,
5483
+ error: `background script limit reached (${maxScripts} running). ` +
5484
+ 'Use code_execution {"action": "list"} to inspect and {"action": "cancel", "script_id": ...} to free a slot.',
5485
+ isError: true,
5486
+ };
5487
+ }
5488
+ const scriptId = `bg-${++this.backgroundScriptCounter}`;
5489
+ const lifetimeMs = cfg?.backgroundMaxLifetimeMs ?? 86_400_000;
5490
+ // Journal: a file under the agent's first read-write workspace mount so
5491
+ // their existing read/grep/shell tools work on it. Python appends
5492
+ // directly to the materialized path; watch-mode mounts sync it back to
5493
+ // the chronicle tree on their usual cadence.
5494
+ let logPath = null;
5495
+ let logAbsPath = null;
5496
+ const workspace = this.getWorkspaceModule();
5497
+ if (workspace) {
5498
+ const mountName = this.firstWritableMountName(workspace);
5499
+ if (mountName) {
5500
+ logPath = `${mountName}/background-scripts/${scriptId}.log`;
5501
+ logAbsPath = workspace.resolveAbsolutePath(logPath);
5502
+ }
5503
+ }
5504
+ const runner = new PyRunner({
5505
+ pythonPath: cfg?.pythonPath,
5506
+ toolCallTimeoutMs: cfg?.toolCallTimeoutMs,
5507
+ scriptTimeoutMs: cfg?.scriptTimeoutMs,
5508
+ idleReclaimMs: 0, // dedicated runner; lifetime is the exec deadline
5509
+ label: `${agentName}:${scriptId}`,
5510
+ onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args),
5511
+ });
5512
+ const record = {
5513
+ id: scriptId,
5514
+ agentName,
5515
+ runner,
5516
+ code,
5517
+ startedAt: Date.now(),
5518
+ wakes: 0,
5519
+ lastWakeAt: null,
5520
+ logPath,
5521
+ status: 'running',
5522
+ cancelled: false,
5523
+ };
5524
+ this.backgroundScripts.set(scriptId, record);
5525
+ this.emitTrace({
5526
+ type: 'tool:started', module: 'code_execution', tool: `background:${scriptId}`,
5527
+ callId: scriptId, input: { lines: code.split('\n').length },
5528
+ });
5529
+ void runner
5530
+ .exec(code, injected, {
5531
+ logPath: logAbsPath,
5532
+ lifetimeMs,
5533
+ onWake: (line, payload) => this.handleScriptWake(record, line, payload),
5534
+ })
5535
+ .then((exec) => this.settleBackgroundScript(record, exec))
5536
+ .catch((err) => {
5537
+ // exec never rejects by contract; this is the belt-and-suspenders.
5538
+ console.error(`[pytc:${agentName}:${scriptId}] background exec rejected: ${String(err)}`);
5539
+ this.settleBackgroundScript(record, {
5540
+ stdout: '', stderr: String(err), returnCode: 1, aborted: true,
5541
+ });
5542
+ });
5543
+ return {
5544
+ success: true,
5545
+ data: {
5546
+ status: 'background script started',
5547
+ script_id: scriptId,
5548
+ log: logPath ?? 'no writable workspace mount — output is not retrievable; only wake_agent() reaches you',
5549
+ lifetime_hours: Math.round(lifetimeMs / 3_600_000 * 10) / 10,
5550
+ note: 'The script dies if the host process restarts. Manage with code_execution {"action": "list"|"cancel"}.',
5551
+ },
5552
+ };
5553
+ }
5554
+ /** First read-write mount name, or null. */
5555
+ firstWritableMountName(workspace) {
5556
+ const mounts = workspace.getMounts?.();
5557
+ if (mounts) {
5558
+ const rw = mounts.find((m) => m.mode !== 'read-only');
5559
+ return rw?.name ?? null;
5560
+ }
5561
+ return null;
5562
+ }
5563
+ /**
5564
+ * wake_agent() from a background script: enforce the per-script wake cap
5565
+ * and rate floor (delay, not drop — the script awaits the ack), then
5566
+ * inject the provenance envelope + payload and request inference.
5567
+ * Resolves null on delivery; an error string refuses the wake (raises
5568
+ * RuntimeError inside the script).
5569
+ */
5570
+ async handleScriptWake(record, line, payload) {
5571
+ if (record.cancelled)
5572
+ return 'script was cancelled';
5573
+ const cfg = this.codeExecutionConfig;
5574
+ const maxWakes = cfg?.maxWakesPerScript ?? 100;
5575
+ if (record.wakes >= maxWakes) {
5576
+ return `wake limit reached (${maxWakes} per script)`;
5577
+ }
5578
+ const floorMs = cfg?.wakeMinIntervalMs ?? 60_000;
5579
+ if (record.lastWakeAt !== null) {
5580
+ const wait = record.lastWakeAt + floorMs - Date.now();
5581
+ if (wait > 0) {
5582
+ await new Promise((r) => {
5583
+ const t = setTimeout(r, wait);
5584
+ t.unref?.();
5585
+ });
5586
+ if (record.cancelled)
5587
+ return 'script was cancelled';
5588
+ }
5589
+ }
5590
+ record.wakes += 1;
5591
+ record.lastWakeAt = Date.now();
5592
+ const elapsedMin = Math.round((Date.now() - record.startedAt) / 60_000);
5593
+ const payloadJson = payload === undefined || payload === null
5594
+ ? 'null'
5595
+ : JSON.stringify(payload, null, 1) ?? String(payload);
5596
+ const agent = this.agents.get(record.agentName);
5597
+ const maxChars = agent ? this.getMaxToolResultChars(agent) : undefined;
5598
+ const payloadText = await this.spillOrTruncate(payloadJson, maxChars, `${record.id}-wake${record.wakes}`);
5599
+ const envelope = `[background script ${record.id}] Woke you: wake_agent() called at line ${line} of your script, ` +
5600
+ `${elapsedMin}m after you started it (wake ${record.wakes} of ${maxWakes}).\n` +
5601
+ `Arguments:\n${payloadText}\n` +
5602
+ (record.logPath ? `Script output so far: workspace file ${record.logPath}\n` : '') +
5603
+ `Script status: still running`;
5604
+ this.injectScriptWake(record, envelope);
5605
+ return null;
5606
+ }
5607
+ /** Background script ended (clean, crash, deadline, cancel/dispose). */
5608
+ settleBackgroundScript(record, exec) {
5609
+ record.runner.dispose();
5610
+ if (record.status === 'running') {
5611
+ record.status = exec.returnCode === 0 ? 'finished' : 'died';
5612
+ }
5613
+ this.emitTrace({
5614
+ type: 'tool:completed', module: 'code_execution', tool: `background:${record.id}`,
5615
+ callId: record.id, durationMs: Date.now() - record.startedAt,
5616
+ });
5617
+ // Crash honesty: a resident sleeping on a promise must never have that
5618
+ // promise die silently. Deliberate cancels/disposes stay silent (the
5619
+ // agent or host chose them); clean exits stay silent (the null path).
5620
+ if (record.status === 'died' && !record.cancelled) {
5621
+ const tail = (exec.tail ?? exec.stderr ?? '').slice(-2000);
5622
+ const elapsedMin = Math.round((Date.now() - record.startedAt) / 60_000);
5623
+ const envelope = `[background script ${record.id}] Your background script DIED ${elapsedMin}m after start ` +
5624
+ `(it did not call wake_agent and is no longer watching).\n` +
5625
+ `Last output:\n${tail || '(none)'}\n` +
5626
+ (record.logPath ? `Full journal: workspace file ${record.logPath}` : '');
5627
+ this.injectScriptWake(record, envelope);
5628
+ }
5629
+ // Keep a short memory of settled scripts for {"action":"list"}, then drop.
5630
+ const settled = [...this.backgroundScripts.values()]
5631
+ .filter((s) => s.agentName === record.agentName && s.status !== 'running');
5632
+ for (const old of settled.slice(0, Math.max(0, settled.length - 5))) {
5633
+ this.backgroundScripts.delete(old.id);
5634
+ }
5635
+ }
5636
+ /**
5637
+ * Deliver a background-script event: provenance envelope into the agent's
5638
+ * context + an inference request. Deliberately does NOT consult the event
5639
+ * gate — the agent armed this wake themselves (wake-through-sleep
5640
+ * authority); it enters tagged so gate policies could be taught about it
5641
+ * later if that ever needs revisiting.
5642
+ */
5643
+ injectScriptWake(record, envelope) {
5644
+ try {
5645
+ const messageId = this.addMessage('user', [{ type: 'text', text: envelope }], {
5646
+ source: 'background-script',
5647
+ scriptId: record.id,
5648
+ tags: ['script:wake'],
5649
+ });
5650
+ this.pendingRequests.push({
5651
+ agentName: record.agentName,
5652
+ reason: 'script:wake',
5653
+ source: 'code_execution',
5654
+ timestamp: Date.now(),
5655
+ });
5656
+ this.emitTrace({
5657
+ type: 'message:added',
5658
+ messageId,
5659
+ source: `background-script:${record.id}`,
5660
+ });
5661
+ }
5662
+ catch (err) {
5663
+ console.error(`[pytc:${record.agentName}:${record.id}] wake injection failed: ${err instanceof Error ? err.message : String(err)}`);
5664
+ }
5665
+ }
5666
+ /**
5667
+ * Oversized content policy (antra, 2026-07-31): never hit the agent in
5668
+ * the face with a huge blob, never silently destroy it either. Content
5669
+ * over the inline cap is materialized to a workspace file (readable with
5670
+ * the agent's own tools) and replaced by a truncated head + a trailing
5671
+ * file reference. Falls back to plain truncation when there is no
5672
+ * writable workspace. The cap can be lifted temporarily via
5673
+ * agent_settings `tool_result_inline_max_chars`.
5674
+ */
5675
+ async spillOrTruncate(content, maxChars, label, agentName) {
5676
+ const override = agentName !== undefined
5677
+ ? this.toolResultInlineMaxCharsOverride.get(agentName)
5678
+ : undefined;
5679
+ const cap = override ?? maxChars;
5680
+ if (!cap || content.length <= cap)
5681
+ return content;
5682
+ const workspace = this.getWorkspaceModule();
5683
+ const mountName = workspace ? this.firstWritableMountName(workspace) : null;
5684
+ if (workspace && mountName) {
5685
+ const safeLabel = label.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
5686
+ const path = `${mountName}/tool-results/${safeLabel}.txt`;
5687
+ try {
5688
+ const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain');
5689
+ if (result.success) {
5690
+ return safeSlice(content, 0, cap)
5691
+ + `\n\n[truncated — showing ${cap} of ${content.length} chars; full content: workspace file ${path}. `
5692
+ + 'Read/grep it with your file tools, or raise the inline cap temporarily via '
5693
+ + 'agent_settings update tool_result_inline_max_chars.]';
5694
+ }
5695
+ }
5696
+ catch {
5697
+ // fall through to plain truncation
5698
+ }
5699
+ }
5700
+ return safeSlice(content, 0, cap)
5701
+ + '\n\n[truncated — original was ' + content.length + ' chars]';
5702
+ }
4953
5703
  getOrCreateScriptRunner(agentName) {
4954
5704
  let runner = this.codeExecutionRunners.get(agentName);
4955
5705
  if (!runner) {
@@ -4991,9 +5741,10 @@ export class AgentFramework {
4991
5741
  }
4992
5742
  if (result.data === undefined)
4993
5743
  return '';
4994
- const agent = this.agents.get(agentName);
4995
- const maxChars = agent ? this.getMaxToolResultChars(agent) : undefined;
4996
- return toolResultDataToHistoryString(result.data, maxChars);
5744
+ // Scripts get (nearly) full data — the script environment IS the spill:
5745
+ // filtering big results in code is the whole point. 5MB protocol safety
5746
+ // cap only; no context-cap truncation, no file spill.
5747
+ return toolResultDataToHistoryString(result.data, 5_000_000);
4997
5748
  }
4998
5749
  /**
4999
5750
  * Dispatch a script-inner tool call through the NORMAL dispatch path and
@@ -5041,7 +5792,10 @@ export class AgentFramework {
5041
5792
  runner.abort(reason);
5042
5793
  }
5043
5794
  }
5044
- toMembraneToolResult(callId, afResult, maxChars) {
5795
+ toMembraneToolResult(callId, afResult, maxChars,
5796
+ /** Pre-spilled string from the history path — used for the non-image
5797
+ * path so the live wire copy byte-matches what the window stored. */
5798
+ precomputed) {
5045
5799
  if (afResult.isError) {
5046
5800
  return { toolUseId: callId, content: afResult.error ?? 'Unknown error', isError: true };
5047
5801
  }
@@ -5053,6 +5807,9 @@ export class AgentFramework {
5053
5807
  if (blocks) {
5054
5808
  return { toolUseId: callId, content: blocks, isError: false };
5055
5809
  }
5810
+ if (precomputed !== undefined) {
5811
+ return { toolUseId: callId, content: precomputed, isError: false };
5812
+ }
5056
5813
  // JSON.stringify returns the VALUE undefined (not a string) for undefined
5057
5814
  // input — a module tool returning `{ success: true }` with no data would
5058
5815
  // otherwise make `content.length` below throw a TypeError mid-turn.
@@ -5219,6 +5976,10 @@ export class AgentFramework {
5219
5976
  return;
5220
5977
  }
5221
5978
  // Route the agent's typed, allowlisted hot-settings surface.
5979
+ if (enrichedCall.name === 'utils') {
5980
+ this.dispatchUtilsToolCall(agentName, enrichedCall);
5981
+ return;
5982
+ }
5222
5983
  if (enrichedCall.name === 'agent_settings') {
5223
5984
  this.dispatchAgentSettingsToolCall(agentName, enrichedCall);
5224
5985
  return;
@@ -5354,15 +6115,25 @@ export class AgentFramework {
5354
6115
  }
5355
6116
  // Defer non-tool_result messages while a tool cycle is mid-flight
5356
6117
  // (pendingAssistantBlocks: preserves tool_use → tool_result adjacency)
5357
- // OR while the target agent has a live stream at all. A message stored
5358
- // mid-stream lands BEFORE the turn's assistant blocks in the window even
5359
- // though the live conversation never saw it — so the next compile
5360
- // diverges from the live prefix (prompt-cache bust) and the message
5361
- // misses mid-turn injection. Deferred messages flush at the agent's next
5362
- // tool boundary (where they are ALSO injected into the live stream —
5363
- // hear-while-acting) or in driveStream's finally when the turn ends.
6118
+ // OR while the target agent has a turn in progress AT ALL. A message
6119
+ // stored mid-turn lands BEFORE the turn's assistant blocks in the window
6120
+ // even though the live conversation never saw it — so the next compile
6121
+ // diverges from the live prefix (prompt-cache bust), the message misses
6122
+ // mid-turn injection, and the window falsely testifies the agent saw it
6123
+ // and moved on. Turn-alive (activeTurnTokens) is the guard, not stream-
6124
+ // alive: a turn exists from dequeue through hooks + compile before its
6125
+ // stream registers in activeStreams, and the 2026-07-31 Mythos incident
6126
+ // was precisely a deferred flush landing inside that compile window.
6127
+ // (activeStreams is kept as a belt-and-suspenders backstop.) Deferred
6128
+ // messages flush at the next turn's START (before its compile — riding
6129
+ // their own wake), at the agent's next tool boundary (where they are
6130
+ // ALSO injected into the live stream — hear-while-acting), or in
6131
+ // driveStream's finally when the turn ends.
5364
6132
  const hasToolResult = content.some(b => b.type === 'tool_result');
5365
- if (!hasToolResult && (this.pendingAssistantBlocks.size > 0 || this.activeStreams.has(agent.name))) {
6133
+ if (!hasToolResult &&
6134
+ (this.pendingAssistantBlocks.size > 0 ||
6135
+ this.activeTurnTokens.has(agent.name) ||
6136
+ this.activeStreams.has(agent.name))) {
5366
6137
  this.deferredMessages.push({ participant, content, metadata });
5367
6138
  return ''; // Deferred — flushed at the next boundary
5368
6139
  }
@@ -6816,7 +7587,7 @@ export class AgentFramework {
6816
7587
  }
6817
7588
  this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input });
6818
7589
  const startTime = Date.now();
6819
- this.channelRegistry.handleChannelToolCall(call.name, call.input)
7590
+ this.channelRegistry.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName })
6820
7591
  .then((result) => {
6821
7592
  const durationMs = Date.now() - startTime;
6822
7593
  this.emitTrace({ type: 'tool:completed', module: 'channels', tool: call.name, callId: call.id, durationMs });
@@ -7029,6 +7800,31 @@ export class AgentFramework {
7029
7800
  if (typeof input.path !== 'string' || input.path.length === 0) {
7030
7801
  throw new Error('read_image: `path` (mount-prefixed) is required');
7031
7802
  }
7803
+ // Anthropic rejects images >5MB (and >8000px); guard the hard byte
7804
+ // limit here — resizing is out of scope without an image library.
7805
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
7806
+ // Prefer the workspace module's own read_image handler when it exists:
7807
+ // it reads tree-first with a FILESYSTEM FALLBACK, so binaries that live
7808
+ // only on disk (shell-created files, pre-fix mangled tree blobs) still
7809
+ // render. The legacy tree-only path below stays for older workspace
7810
+ // modules that don't advertise the tool.
7811
+ if (typeof workspace.handleToolCall === 'function' &&
7812
+ typeof workspace.getTools === 'function' &&
7813
+ workspace.getTools().some((tool) => tool.name === 'read_image')) {
7814
+ const result = await workspace.handleToolCall({ ...call, name: 'read_image' });
7815
+ if (result.success && Array.isArray(result.data)) {
7816
+ const image = result.data.find((block) => typeof block === 'object' && block !== null &&
7817
+ block.type === 'image' &&
7818
+ typeof block.data === 'string');
7819
+ const byteLength = image ? Math.floor((image.data.length * 3) / 4) : 0;
7820
+ if (byteLength > MAX_IMAGE_BYTES) {
7821
+ throw new Error(`read_image: "${input.path}" is ~${byteLength} bytes — ` +
7822
+ `over the ${MAX_IMAGE_BYTES}-byte model limit`);
7823
+ }
7824
+ }
7825
+ finish(result);
7826
+ return;
7827
+ }
7032
7828
  const read = await workspace.readBinary(input.path);
7033
7829
  if ('error' in read)
7034
7830
  throw new Error(read.error);
@@ -7037,9 +7833,6 @@ export class AgentFramework {
7037
7833
  throw new Error(`read_image: "${input.path}" does not look like a supported image ` +
7038
7834
  '(png/jpeg/gif/webp — checked by magic bytes, not extension)');
7039
7835
  }
7040
- // Anthropic rejects images >5MB (and >8000px); guard the hard byte
7041
- // limit here — resizing is out of scope without an image library.
7042
- const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
7043
7836
  if (read.data.byteLength > MAX_IMAGE_BYTES) {
7044
7837
  throw new Error(`read_image: "${input.path}" is ${read.data.byteLength} bytes — ` +
7045
7838
  `over the ${MAX_IMAGE_BYTES}-byte model limit`);
@@ -7201,6 +7994,81 @@ export class AgentFramework {
7201
7994
  }
7202
7995
  })();
7203
7996
  }
7997
+ /** The `utils` meta-tool: list/describe/run over module utilities
7998
+ * (Module.getUtilities). Shared by the model-facing dispatcher and the
7999
+ * programmatic path (executeToolCall), so code_execution scripts and
8000
+ * ephemerals reach utilities too. */
8001
+ async handleUtilsToolCall(agentName, call) {
8002
+ const input = (call.input ?? {});
8003
+ const utilities = this.moduleRegistry.getAllUtilities();
8004
+ const available = () => utilities.length ? utilities.map((u) => u.name).join(', ') : '(none registered)';
8005
+ switch (input.action) {
8006
+ case 'list':
8007
+ return {
8008
+ success: true,
8009
+ data: utilities.map((u) => ({ name: u.name, description: utilityOneLiner(u.description) })),
8010
+ };
8011
+ case 'describe': {
8012
+ const def = utilities.find((u) => u.name === input.name);
8013
+ if (!def) {
8014
+ return { success: false, isError: true, error: `No utility "${String(input.name)}". Available: ${available()}` };
8015
+ }
8016
+ return { success: true, data: def };
8017
+ }
8018
+ case 'run': {
8019
+ const def = utilities.find((u) => u.name === input.name);
8020
+ if (!def) {
8021
+ return { success: false, isError: true, error: `No utility "${String(input.name)}". Available: ${available()}` };
8022
+ }
8023
+ const args = input.args ?? {};
8024
+ const problem = validateUtilityArgs(args, def.inputSchema);
8025
+ if (problem) {
8026
+ // Teach by bounce: the error carries the schema, so the retry needs
8027
+ // no describe round-trip.
8028
+ return {
8029
+ success: false,
8030
+ isError: true,
8031
+ error: `${problem}. Schema for ${def.name}: ${JSON.stringify(def.inputSchema)}`,
8032
+ };
8033
+ }
8034
+ // Same dispatch as a first-class tool call — the module cannot tell
8035
+ // which surface the call came through.
8036
+ return this.moduleRegistry.handleToolCall({
8037
+ id: call.id,
8038
+ name: def.name,
8039
+ input: args,
8040
+ callerAgentName: agentName,
8041
+ });
8042
+ }
8043
+ default:
8044
+ return { success: false, isError: true, error: 'action must be "list", "describe", or "run"' };
8045
+ }
8046
+ }
8047
+ dispatchUtilsToolCall(agentName, call) {
8048
+ this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'utils', callId: call.id, input: call.input });
8049
+ const startedAt = Date.now();
8050
+ void this.handleUtilsToolCall(agentName, call)
8051
+ .catch((err) => ({
8052
+ success: false,
8053
+ error: err instanceof Error ? err.message : String(err),
8054
+ isError: true,
8055
+ }))
8056
+ .then((result) => {
8057
+ if (result.success) {
8058
+ this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'utils', callId: call.id, durationMs: Date.now() - startedAt });
8059
+ }
8060
+ else {
8061
+ this.emitTrace({ type: 'tool:failed', module: 'framework', tool: 'utils', callId: call.id, error: result.error ?? 'unknown error' });
8062
+ }
8063
+ this.pushEvent({
8064
+ type: 'tool-result',
8065
+ callId: call.id,
8066
+ agentName,
8067
+ moduleName: 'framework',
8068
+ result,
8069
+ });
8070
+ });
8071
+ }
7204
8072
  dispatchAgentSettingsToolCall(agentName, call) {
7205
8073
  this.emitTrace({
7206
8074
  type: 'tool:started',
@@ -7243,6 +8111,10 @@ export class AgentFramework {
7243
8111
  if (input.same_round_think_text_policy !== undefined) {
7244
8112
  patch.sameRoundThinkTextPolicy = input.same_round_think_text_policy;
7245
8113
  }
8114
+ // Immediate budget decrease: skip the paced descent; the next
8115
+ // compile plans straight at the new budget (one-shot fold-down).
8116
+ if (input.immediate !== undefined)
8117
+ patch.immediate = Boolean(input.immediate);
7246
8118
  // Route extension-owned keys to their modules; apply the core patch
7247
8119
  // only when it touches core keys (an extension-only update must not
7248
8120
  // disturb a converging budget transition).