@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
package/src/framework.ts CHANGED
@@ -62,7 +62,7 @@ import { PushHandler, type McplPushEvent } from './mcpl/push-handler.js';
62
62
  import { parseProsePrefix } from './mcpl/prose-grammar.js';
63
63
  import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
64
64
  import { InferenceRouter } from './mcpl/inference-router.js';
65
- import { ChannelRegistry } from './mcpl/channel-registry.js';
65
+ import { ChannelRegistry, type ChannelToolOrigin } from './mcpl/channel-registry.js';
66
66
  import { ConversationRouter } from './mcpl/conversation-router.js';
67
67
  import { safeSlice } from './safe-slice.js';
68
68
  import type { WorkspaceModule } from './modules/workspace/index.js';
@@ -224,6 +224,65 @@ const PROSE_HELP_TOOL: import('./types/index.js').ToolDefinition = {
224
224
  };
225
225
  /** Strip the `server--` MCPL prefix from a tool name. */
226
226
  const bareToolName = (n: string): string => n.split('--').pop()!;
227
+
228
+ /** The single slot behind which every rarely-used capability lives. Only
229
+ * advertised when at least one module contributes utilities — with none
230
+ * registered the surface is byte-identical to before this existed. */
231
+ const UTILS_TOOL: import('./types/index.js').ToolDefinition = {
232
+ name: 'utils',
233
+ description:
234
+ 'Rarely-used utilities, behind one slot instead of each costing a tool. ' +
235
+ '"list" names them all with one-liners; "describe" returns one utility\'s full ' +
236
+ 'input schema; "run" invokes it (args are validated against that schema — a ' +
237
+ 'miss bounces back with the schema and the specific error).',
238
+ inputSchema: {
239
+ type: 'object' as const,
240
+ properties: {
241
+ action: { type: 'string', enum: ['list', 'describe', 'run'], description: 'What to do.' },
242
+ name: { type: 'string', description: 'Utility name from list (required for describe/run).' },
243
+ args: { type: 'object', description: "Arguments for run, per the utility's schema." },
244
+ },
245
+ required: ['action'],
246
+ },
247
+ };
248
+
249
+ /** First sentence (or line) of a description — what `utils list` shows. */
250
+ const utilityOneLiner = (description: string): string => {
251
+ const line = description.split('\n')[0]!;
252
+ const dot = line.indexOf('. ');
253
+ return (dot >= 0 ? line.slice(0, dot + 1) : line).slice(0, 160);
254
+ };
255
+
256
+ /** Shallow validation of run args against a utility's declared inputSchema:
257
+ * required keys, primitive types, enums. Deliberately not a full JSON-Schema
258
+ * engine — handlers still hand-validate (they always did); this layer exists
259
+ * so an arg-shape miss teaches by bounce instead of failing deep in a handler
260
+ * with a less instructive message. Returns an error string or null. */
261
+ function validateUtilityArgs(
262
+ input: unknown,
263
+ schema: import('./types/index.js').ToolDefinition['inputSchema'],
264
+ ): string | null {
265
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
266
+ return 'args must be an object';
267
+ }
268
+ const obj = input as Record<string, unknown>;
269
+ for (const req of schema.required ?? []) {
270
+ if (!(req in obj) || obj[req] === undefined) return `missing required "${req}"`;
271
+ }
272
+ for (const [key, value] of Object.entries(obj)) {
273
+ const param = schema.properties?.[key];
274
+ if (!param || value === undefined) continue; // unknown keys: handler's business
275
+ if (param.enum && !(param.enum as unknown[]).includes(value)) {
276
+ return `"${key}" must be one of ${JSON.stringify(param.enum)}`;
277
+ }
278
+ if (param.type) {
279
+ const actual = Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value;
280
+ const expected = param.type === 'integer' ? 'number' : param.type;
281
+ if (actual !== expected) return `"${key}" must be ${param.type}, got ${actual}`;
282
+ }
283
+ }
284
+ return null;
285
+ }
227
286
  import { CheckpointManager } from './mcpl/checkpoint-manager.js';
228
287
  import { isToolAllowed } from './mcpl/tool-policy.js';
229
288
  import { EventGate } from './gate/event-gate.js';
@@ -451,6 +510,23 @@ interface Deferred<T> {
451
510
  * the caller awaits plus the liveness/progress bookkeeping the watchdogs and
452
511
  * the stream driver share. One map entry per run — created and torn down in
453
512
  * runEphemeralToCompletion — so the pieces cannot desync. */
513
+ /** One model-authored background (daemon) script — see runCodeExecution. */
514
+ interface BackgroundScriptRecord {
515
+ id: string;
516
+ agentName: string;
517
+ runner: PyRunner;
518
+ /** The agent-authored source (line numbers in wake envelopes index into it). */
519
+ code: string;
520
+ startedAt: number;
521
+ wakes: number;
522
+ lastWakeAt: number | null;
523
+ /** Mount-prefixed workspace path of the journal, null when no workspace. */
524
+ logPath: string | null;
525
+ status: 'running' | 'finished' | 'died' | 'cancelled';
526
+ /** Set when cancel/dispose already settled this script (suppresses wakes). */
527
+ cancelled: boolean;
528
+ }
529
+
454
530
  interface EphemeralRun {
455
531
  settle: Deferred<AgentSettleResult>;
456
532
  inferenceStarted: boolean;
@@ -603,6 +679,25 @@ export class AgentFramework {
603
679
  // tool_use → tool_result adjacency required by the Anthropic API).
604
680
  private deferredMessages: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata }> = [];
605
681
 
682
+ // Turn-alive markers (2026-07-31 Mythos phantom-skip incident): agentName →
683
+ // token of the turn currently in progress. Set at startAgentStream ENTRY —
684
+ // before hooks, compile, and stream setup, all of which await — and cleared
685
+ // token-matched when that turn's teardown finishes (driveStream finally /
686
+ // terminal failure path), so a successor turn's marker survives a
687
+ // predecessor's late finally. A turn is alive from dequeue to settled
688
+ // teardown: strictly longer than `activeStreams` membership, which begins
689
+ // only after the stream exists. Guarding addMessage on stream-liveness
690
+ // alone left the compile window open — a deferred message flushed by the
691
+ // previous turn's finally landed in the window MID-COMPILE of the turn its
692
+ // own wake had started: positioned before the new turn's blocks yet absent
693
+ // from its wire request. The agent then "skipped" a message it never saw
694
+ // (falsified history — it later confabulated an apology for the skip), and
695
+ // every subsequent compile diverged from the live prefix at that message
696
+ // (KV bust). Invariant: nothing enters the window between a turn's dequeue
697
+ // and its settle except that turn's own blocks.
698
+ private activeTurnTokens: Map<string, number> = new Map();
699
+ private nextTurnToken = 1;
700
+
606
701
  // Undo/redo state
607
702
  private turnCounters: Map<string, number> = new Map(); // agentName → next turnIndex
608
703
  private redoStacks: Map<string, RedoEntry[]> = new Map(); // agentName → redo entries
@@ -646,6 +741,15 @@ export class AgentFramework {
646
741
  * deferred and applied to the final code_execution result instead of
647
742
  * cancelling the stream mid-script (which would wedge the turn). */
648
743
  private scriptDeferredEndTurn: Set<string> = new Set();
744
+ /** Background (daemon) scripts: model-authored watchers that outlive their
745
+ * spawning turn. Each gets a DEDICATED PyRunner; wake_agent() injects a
746
+ * provenance envelope + payload and requests inference. Keyed by script id. */
747
+ private backgroundScripts: Map<string, BackgroundScriptRecord> = new Map();
748
+ private backgroundScriptCounter = 0;
749
+ /** Ephemeral per-agent override of the tool-result inline cap (chars).
750
+ * Set via agent_settings `tool_result_inline_max_chars`; NOT persisted —
751
+ * the lift is meant as a temporary gate, reverts on restart/reset. */
752
+ private toolResultInlineMaxCharsOverride: Map<string, number> = new Map();
649
753
 
650
754
  private mcplTools: import('./types/index.js').ToolDefinition[] = [];
651
755
  private mcplToolRefreshInFlight = false;
@@ -722,7 +826,12 @@ export class AgentFramework {
722
826
  queryMessages: (filter) => this.queryMessages(filter),
723
827
  pushEvent: (event) => this.pushEvent(event),
724
828
  onTrace: (listener) => this.onTrace(listener),
725
- callTool: (call) => this.executeToolCall(call),
829
+ // The ONLY entry that confers module origin — a private closure the
830
+ // framework hands to its own module registry. The public
831
+ // executeToolCall() is shared with model/ephemeral callers and always
832
+ // stamps agent origin (see there).
833
+ callTool: (call) => this.executeToolCallFrom(call, { kind: 'module' }),
834
+ notifyOps: (kind, agentName, message, data) => this.notifyOps(kind, agentName, message, data),
726
835
  });
727
836
  }
728
837
 
@@ -1017,6 +1126,17 @@ export class AgentFramework {
1017
1126
  runner.dispose();
1018
1127
  }
1019
1128
  this.codeExecutionRunners.clear();
1129
+ // Background daemons die with the process (documented limitation) —
1130
+ // mark cancelled FIRST so their settle path stays silent (no crash-wake
1131
+ // into a framework that is shutting down).
1132
+ for (const record of this.backgroundScripts.values()) {
1133
+ if (record.status === 'running') {
1134
+ record.cancelled = true;
1135
+ record.status = 'cancelled';
1136
+ }
1137
+ record.runner.dispose();
1138
+ }
1139
+ this.backgroundScripts.clear();
1020
1140
 
1021
1141
  // Cancel all active streams
1022
1142
  for (const agent of this.agents.values()) {
@@ -1308,6 +1428,8 @@ export class AgentFramework {
1308
1428
  : [];
1309
1429
  return [
1310
1430
  ...moduleTools,
1431
+ // Utilities collapse into one slot; absent entirely when none exist.
1432
+ ...(this.moduleRegistry.getAllUtilities().length > 0 ? [UTILS_TOOL] : []),
1311
1433
  ...this.mcplTools,
1312
1434
  ...channelTools,
1313
1435
  ...gateTools,
@@ -1346,6 +1468,15 @@ export class AgentFramework {
1346
1468
  private collectAgentSettingsExtensions(): Map<string, AgentSettingsExtension> {
1347
1469
  const result = new Map<string, AgentSettingsExtension>();
1348
1470
  const taken = new Set<string>(AgentFramework.AGENT_SETTINGS_CORE_KEYS);
1471
+ // Framework-owned extension: the tool-result inline cap lift. Registered
1472
+ // through the same extension surface modules use so get/update/reset all
1473
+ // work with zero extra plumbing. EPHEMERAL by design (a temporary gate —
1474
+ // reverts on restart), unlike module extensions which persist their own.
1475
+ {
1476
+ const ext = this.spillSettingsExtension();
1477
+ ext.keys.forEach((k) => taken.add(k));
1478
+ result.set('_framework', ext);
1479
+ }
1349
1480
  for (const module of this.moduleRegistry.getAllModules()) {
1350
1481
  const ext = module.getAgentSettingsExtension?.();
1351
1482
  if (!ext) continue;
@@ -1362,6 +1493,42 @@ export class AgentFramework {
1362
1493
  return result;
1363
1494
  }
1364
1495
 
1496
+ /** agent_settings extension for the spill gate (see spillOrTruncate). */
1497
+ private spillSettingsExtension(): AgentSettingsExtension {
1498
+ return {
1499
+ properties: {
1500
+ tool_result_inline_max_chars: {
1501
+ type: 'number',
1502
+ description:
1503
+ 'Inline size cap (chars) for tool results and background-script wake payloads. ' +
1504
+ 'Content over the cap is written to a workspace file under tool-results/ and ' +
1505
+ 'replaced by a truncated preview + file reference. Raise it temporarily when you ' +
1506
+ 'genuinely want a large result inline; reset restores the default (derived from ' +
1507
+ 'your strategy). Ephemeral — reverts on host restart.',
1508
+ },
1509
+ },
1510
+ keys: ['tool_result_inline_max_chars'],
1511
+ get: (agentName: string) => ({
1512
+ tool_result_inline_max_chars:
1513
+ this.toolResultInlineMaxCharsOverride.get(agentName) ?? null,
1514
+ }),
1515
+ update: (agentName: string, patch: Record<string, unknown>) => {
1516
+ const n = Number(patch.tool_result_inline_max_chars);
1517
+ if (!Number.isFinite(n) || n < 1000) {
1518
+ throw new Error('tool_result_inline_max_chars must be a number >= 1000');
1519
+ }
1520
+ this.toolResultInlineMaxCharsOverride.set(agentName, Math.floor(n));
1521
+ return { tool_result_inline_max_chars: Math.floor(n) };
1522
+ },
1523
+ reset: (agentName: string, keys?: string[]) => {
1524
+ if (!keys || keys.includes('tool_result_inline_max_chars')) {
1525
+ this.toolResultInlineMaxCharsOverride.delete(agentName);
1526
+ }
1527
+ return { tool_result_inline_max_chars: null };
1528
+ },
1529
+ };
1530
+ }
1531
+
1365
1532
  private captureInferenceToolSnapshot(agent: Agent): InferenceToolSnapshot {
1366
1533
  return {
1367
1534
  sameRoundThinkTextPolicy: agent.getEffectiveSameRoundThinkTextPolicy(),
@@ -1972,6 +2139,12 @@ export class AgentFramework {
1972
2139
  // ephemeral agents leave checkpoint-tree keys and diagnostics map
1973
2140
  // entries behind for the life of the store/session.
1974
2141
  this.evictTurnCheckpoints(agent.name);
2142
+ // Turn-alive marker, same spawn-and-dispose logic: a stalled ephemeral
2143
+ // (watchdog fired, driveStream's terminal event lost) would strand its
2144
+ // token forever — no wedge (ephemeral names are unique per run) but
2145
+ // unbounded map growth. Blind delete is safe: the agent is already out
2146
+ // of the map, and a late driveStream finally token-match no-ops.
2147
+ this.activeTurnTokens.delete(agent.name);
1975
2148
  }
1976
2149
  }
1977
2150
 
@@ -2210,6 +2383,12 @@ export class AgentFramework {
2210
2383
  context_budget_tokens: { type: 'number', description: 'Total input context budget, including the reserved response allowance.' },
2211
2384
  tail_tokens: { type: 'number', description: 'Recent raw context retained verbatim.' },
2212
2385
  transition_pace_tokens: { type: 'number', description: 'Maximum ordinary KV re-read/perturbation per compile while converging.' },
2386
+ immediate: {
2387
+ type: 'boolean',
2388
+ description:
2389
+ 'With a context_budget_tokens DECREASE: apply it immediately instead of a paced descent — ' +
2390
+ 'the whole fold-down and its KV re-read land on the next compile, and any in-flight descent is cancelled.',
2391
+ },
2213
2392
  same_round_think_text_policy: {
2214
2393
  type: 'string',
2215
2394
  enum: ['public', 'private'],
@@ -3440,6 +3619,23 @@ export class AgentFramework {
3440
3619
  // Compute truncation limit from agent's strategy (maxMessageTokens * 4 chars)
3441
3620
  const maxChars = this.getMaxToolResultChars(agent);
3442
3621
 
3622
+ // Oversized results spill to a workspace file (truncated preview +
3623
+ // file reference) instead of being blind-truncated. Computed ONCE
3624
+ // per call and reused for both the history copy and the live wire
3625
+ // copy below — the two must stay byte-matched (the window stores
3626
+ // what the membrane sends; divergence breaks the compile prefix).
3627
+ const spilled = new Map<string, string>();
3628
+ const dateLabel = new Date().toISOString().slice(0, 10);
3629
+ for (const tc of currentState.toolResults) {
3630
+ if (tc.result.isError) continue;
3631
+ spilled.set(tc.id, await this.spillOrTruncate(
3632
+ toolResultDataToHistoryString(tc.result.data, undefined),
3633
+ maxChars,
3634
+ `${dateLabel}-${tc.id}`,
3635
+ agent.name,
3636
+ ));
3637
+ }
3638
+
3443
3639
  // Store tool results as a user message (tool_result blocks).
3444
3640
  // Use the history serializer so MCP image blocks become a short
3445
3641
  // `[image: type, size]` placeholder instead of megabytes of base64
@@ -3449,7 +3645,7 @@ export class AgentFramework {
3449
3645
  toolUseId: tc.id,
3450
3646
  content: tc.result.isError
3451
3647
  ? tc.result.error ?? 'Unknown error'
3452
- : toolResultDataToHistoryString(tc.result.data, maxChars),
3648
+ : spilled.get(tc.id) ?? toolResultDataToHistoryString(tc.result.data, maxChars),
3453
3649
  isError: tc.result.isError,
3454
3650
  }));
3455
3651
  agent.getContextManager().addMessage('user', toolResultContent);
@@ -3523,6 +3719,73 @@ export class AgentFramework {
3523
3719
  && agent.lastStreamInputTokens > 0
3524
3720
  && agent.lastStreamInputTokens > agent.maxStreamTokens;
3525
3721
 
3722
+ // Addressed re-pin (2026-07-31 Mythos misroutes, antra-ratified):
3723
+ // when someone ADDRESSES the agent mid-turn from another channel
3724
+ // (mention / reply / DM — the same chat:addressed signal the
3725
+ // turn-START batch policy prefers), the agent's subsequent plain
3726
+ // prose almost always answers THEM. Six recorded misroutes
3727
+ // delivered those answers to the stale frozen locus instead (the
3728
+ // LabClaude answer into #hospital_commons mid-examination; the
3729
+ // hospital scene into antra's DM). Re-pin the turn locus to the
3730
+ // LAST addressed conversational injection, at segment granularity:
3731
+ // prose routed in earlier rounds already went to the old pin,
3732
+ // which was correct when it was delivered. Ambient chatter,
3733
+ // reactions, and system markers still cannot move the pin — the
3734
+ // 2026-07-21 Cairn hijack protection is unchanged; this extends
3735
+ // the ratified addressed-outranks-ambient principle from turn
3736
+ // start to boundaries. Locus mode only (explicit mode has no pin).
3737
+ // Skipped on endTurn/overBudget: no further prose this stream, and
3738
+ // a stale "for this turn" notice would be pure noise.
3739
+ //
3740
+ // The notice rides the SAME injection batch — stored right after
3741
+ // the flushed messages and appended to midTurnInjections — so the
3742
+ // window byte-matches the wire (short event-style notice: the
3743
+ // classifier-safe D-class from the 2026-07-24 ablation). It also
3744
+ // updates lastAnnouncedLocus so the next turn's announce-on-change
3745
+ // diffs against what the agent was actually last told.
3746
+ if (
3747
+ agent.proseRouting !== 'explicit' &&
3748
+ !shouldEndTurn && !overBudget && currentState.stream
3749
+ ) {
3750
+ const lastAddressed = [...midTurnInjections].reverse().find((inj) => {
3751
+ const m = inj.metadata as Record<string, unknown> | undefined;
3752
+ return (
3753
+ isConversationalInjection(inj.metadata) &&
3754
+ typeof m?.channelId === 'string' &&
3755
+ isAddressedMessage(m.tags as string[] | undefined, m)
3756
+ );
3757
+ });
3758
+ const newLocus = (lastAddressed?.metadata as Record<string, unknown> | undefined)
3759
+ ?.channelId as string | undefined;
3760
+ if (newLocus && this.turnLocusPins.get(agent.name) !== newLocus) {
3761
+ const prevPin = this.turnLocusPins.get(agent.name) ?? null;
3762
+ this.turnLocusPins.set(agent.name, newLocus);
3763
+ this.lastAnnouncedLocus.set(agent.name, newLocus);
3764
+ const label = this.channelRegistry?.getDescriptor(newLocus)?.label;
3765
+ const shown = label && label !== newLocus
3766
+ ? `${label.startsWith('#') ? label : `#${label}`} (${newLocus})`
3767
+ : newLocus;
3768
+ const noticeContent: ContentBlock[] = [{
3769
+ type: 'text',
3770
+ text:
3771
+ `[routing] You were just addressed from ${shown} — your plain ` +
3772
+ 'speech now lands there for the rest of this turn. Other ' +
3773
+ 'channels need an explicit send tool.',
3774
+ }];
3775
+ const noticeMeta = { system: true, kind: 'routing-notice' } as MessageMetadata;
3776
+ try {
3777
+ const id = agent.getContextManager().addMessage('user', noticeContent, noticeMeta);
3778
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
3779
+ midTurnInjections.push({ participant: 'user', content: noticeContent, metadata: noticeMeta });
3780
+ console.error(
3781
+ `[routing] ${agent.name}: mid-turn addressed re-pin ${prevPin ?? '(none)'} -> ${newLocus}`,
3782
+ );
3783
+ } catch (err) {
3784
+ console.error('mid-turn locus re-pin: failed to record routing notice:', err);
3785
+ }
3786
+ }
3787
+ }
3788
+
3526
3789
  if (shouldEndTurn) {
3527
3790
  // endTurn: messages already stored above, cancel stream, reset to idle.
3528
3791
  if (currentState.stream) {
@@ -3547,6 +3810,16 @@ export class AgentFramework {
3547
3810
  // endTurn above).
3548
3811
  if (currentState.stream) {
3549
3812
  this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart');
3813
+ // Cancel the LIVE stream object directly (like the endTurn
3814
+ // branch above): at a tool boundary the state machine reads
3815
+ // 'ready', and Agent.cancelStream only signals the stream from
3816
+ // 'streaming'/'waiting_for_tools' — so the old call left the
3817
+ // provider stream parked forever awaiting tool results that
3818
+ // would never come (zombie: driveStream's finally never ran,
3819
+ // its activeStreams entry survived until the restart's stream
3820
+ // overwrote it). Harmless by accident before turn-alive
3821
+ // tracking; a genuine teardown leak after it.
3822
+ currentState.stream.cancel();
3550
3823
  }
3551
3824
  agent.cancelStream();
3552
3825
  this.emitTrace({
@@ -3568,7 +3841,7 @@ export class AgentFramework {
3568
3841
  // messages (membrane ≥0.5.72) — appended after the tool_result
3569
3842
  // envelope so the next round of THIS turn hears them.
3570
3843
  const membraneResults = currentState.toolResults.map(tc =>
3571
- this.toMembraneToolResult(tc.id, tc.result, maxChars)
3844
+ this.toMembraneToolResult(tc.id, tc.result, maxChars, spilled.get(tc.id))
3572
3845
  );
3573
3846
  currentState.stream.provideToolResults(
3574
3847
  membraneResults,
@@ -3754,7 +4027,27 @@ export class AgentFramework {
3754
4027
  return;
3755
4028
  }
3756
4029
 
3757
- const id = this.addMessage('user', event.content, metadata);
4030
+ // Addressed-while-closed invitation — parity with the push-event path
4031
+ // (channels.publish surfaces like portal-mcpl deliver mentions here).
4032
+ const incomingContent = [...event.content];
4033
+ {
4034
+ const invitation = this.buildClosedChannelInvitation({
4035
+ serverId: event.serverId,
4036
+ channelId: event.channelId,
4037
+ messageId: event.messageId,
4038
+ authorName: event.author?.name || 'Someone',
4039
+ guildName: typeof event.metadata?.guildName === 'string' && event.metadata.guildName
4040
+ ? (event.metadata.guildName as string)
4041
+ : undefined,
4042
+ tags: event.tags,
4043
+ });
4044
+ if (invitation) {
4045
+ incomingContent.push(invitation.block);
4046
+ Object.assign(metadata, invitation.metadataPatch);
4047
+ }
4048
+ }
4049
+
4050
+ const id = this.addMessage('user', incomingContent, metadata);
3758
4051
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
3759
4052
 
3760
4053
  if (event.triggerInference) {
@@ -4037,6 +4330,67 @@ export class AgentFramework {
4037
4330
  }
4038
4331
  }
4039
4332
 
4333
+ /**
4334
+ * Build the addressed-while-CLOSED channel invitation block, shared by the
4335
+ * push-event and channels/incoming ingest paths. Until 2026-07-27 only push
4336
+ * events (discord-mcpl mentions) carried it; channels.publish surfaces
4337
+ * (portal-mcpl) delivered bare mentions with no guidance and no route — an
4338
+ * explicit-prose agent's natural plain reply bounced with nowhere to go
4339
+ * (Rhys's first portal mention: a 2,320-char reply stranded).
4340
+ *
4341
+ * Wording: option 1 teaches the `>>` destination prefix, which delivers for
4342
+ * BOTH legacy (host-inferred) and explicit prose-routing agents. The
4343
+ * previous "simply write your reply as normal text" was a false promise
4344
+ * under explicit routing (the metadata flags it set had no consumers).
4345
+ *
4346
+ * Human-readable names wherever the surface provided them — a mind reading
4347
+ * this should see WHO addressed it WHERE, not a wall of snowflake ids. Raw
4348
+ * ids remain only inside the tool-argument instructions, where they are the
4349
+ * literal values to pass.
4350
+ */
4351
+ private buildClosedChannelInvitation(opts: {
4352
+ serverId: string;
4353
+ channelId: string;
4354
+ channelLabel?: string;
4355
+ messageId: string;
4356
+ authorName: string;
4357
+ guildName?: string;
4358
+ tags?: string[];
4359
+ }): { block: ContentBlock; metadataPatch: Record<string, unknown> } | null {
4360
+ if (!this.channelRegistry) return null;
4361
+ if (!opts.tags?.includes('chat:addressed')) return null;
4362
+ if (this.channelRegistry.isChannelOpen(opts.channelId)) return null;
4363
+ const descriptor = this.channelRegistry.getDescriptor(opts.channelId);
4364
+ const maxBackscroll = descriptor?.capabilities?.history?.maxMessages ?? 0;
4365
+ const label = opts.channelLabel ?? descriptor?.label;
4366
+ const channelLabel = label ? `#${label}` : `"${opts.channelId}"`;
4367
+ const place = opts.guildName ? `${channelLabel} in "${opts.guildName}"` : channelLabel;
4368
+ const block: ContentBlock = {
4369
+ type: 'text',
4370
+ text:
4371
+ `\n[Channel invitation] ${opts.authorName} addressed you in ${place} — ` +
4372
+ `a channel you haven't joined. You're seeing this one message; you won't receive more from it unless you join. ` +
4373
+ `Your options:\n` +
4374
+ `1. Reply without joining — write your reply this turn prefixed with ">>${channelLabel}"; it will be delivered there.\n` +
4375
+ `2. Join the channel — call channel_open with channelId "${opts.channelId}" and serverId "${opts.serverId}"` +
4376
+ (maxBackscroll > 0
4377
+ ? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${opts.messageId}".\n`
4378
+ : `.\n`) +
4379
+ `3. Stay out — call channel_decline with channelId "${opts.channelId}", serverId "${opts.serverId}", ` +
4380
+ `and messageId "${opts.messageId}" (optionally set acknowledge to an emoji like 👀 so ${opts.authorName} isn't left hanging).\n` +
4381
+ `Doing nothing is also fine.`,
4382
+ };
4383
+ return {
4384
+ block,
4385
+ metadataPatch: {
4386
+ channelInvitation: true,
4387
+ channelOpen: false,
4388
+ channelId: opts.channelId,
4389
+ invitationMessageId: opts.messageId,
4390
+ },
4391
+ };
4392
+ }
4393
+
4040
4394
  /**
4041
4395
  * Convert an MCPL push event to a context message.
4042
4396
  */
@@ -4070,45 +4424,21 @@ export class AgentFramework {
4070
4424
  }
4071
4425
 
4072
4426
  const content = [...event.content];
4073
- const addressedWhileClosed = triggerChannel &&
4074
- event.tags?.includes('chat:addressed') &&
4075
- this.channelRegistry &&
4076
- !this.channelRegistry.isChannelOpen(triggerChannel.channelId);
4077
- if (addressedWhileClosed) {
4078
- const descriptor = this.channelRegistry!.getDescriptor(triggerChannel.channelId);
4079
- const messageId = typeof event.origin?.messageId === 'string'
4080
- ? event.origin.messageId
4081
- : event.eventId;
4082
- const maxBackscroll = descriptor?.capabilities?.history?.maxMessages ?? 0;
4083
- // Resolve human-readable names from the event origin wherever the
4084
- // surface provided them — a mind reading this should see WHO addressed
4085
- // it WHERE, not a wall of snowflake ids. Raw ids remain only inside the
4086
- // tool-argument instructions, where they are the literal values to pass.
4427
+ if (triggerChannel) {
4087
4428
  const origin = (event.origin ?? {}) as Record<string, unknown>;
4088
- const authorName = typeof origin.authorName === 'string' && origin.authorName ? origin.authorName : 'Someone';
4089
- const channelLabel = triggerChannel.label ? `#${triggerChannel.label}` : `"${triggerChannel.channelId}"`;
4090
- const place = typeof origin.guildName === 'string' && origin.guildName
4091
- ? `${channelLabel} in "${origin.guildName}"`
4092
- : channelLabel;
4093
- content.push({
4094
- type: 'text',
4095
- text:
4096
- `\n[Channel invitation] ${authorName} addressed you in ${place} — ` +
4097
- `a channel you haven't joined. You're seeing this one message; you won't receive more from it unless you join. ` +
4098
- `Your options:\n` +
4099
- `1. Reply without joining — simply write your reply as normal text this turn; it will be delivered to ${channelLabel}.\n` +
4100
- `2. Join the channel — call channel_open with channelId "${triggerChannel.channelId}" and serverId "${event.serverId}"` +
4101
- (maxBackscroll > 0
4102
- ? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${messageId}".\n`
4103
- : `.\n`) +
4104
- `3. Stay out — call channel_decline with channelId "${triggerChannel.channelId}", serverId "${event.serverId}", ` +
4105
- `and messageId "${messageId}" (optionally set acknowledge to an emoji like 👀 so ${authorName} isn't left hanging).\n` +
4106
- `Doing nothing is also fine.`,
4429
+ const invitation = this.buildClosedChannelInvitation({
4430
+ serverId: event.serverId,
4431
+ channelId: triggerChannel.channelId,
4432
+ channelLabel: triggerChannel.label,
4433
+ messageId: typeof origin.messageId === 'string' ? origin.messageId : event.eventId,
4434
+ authorName: typeof origin.authorName === 'string' && origin.authorName ? origin.authorName : 'Someone',
4435
+ guildName: typeof origin.guildName === 'string' && origin.guildName ? origin.guildName : undefined,
4436
+ tags: event.tags,
4107
4437
  });
4108
- metadata.channelInvitation = true;
4109
- metadata.channelOpen = false;
4110
- metadata.channelId = triggerChannel.channelId;
4111
- metadata.invitationMessageId = messageId;
4438
+ if (invitation) {
4439
+ content.push(invitation.block);
4440
+ Object.assign(metadata, invitation.metadataPatch);
4441
+ }
4112
4442
  }
4113
4443
 
4114
4444
  const id = this.addMessage('user', content, metadata);
@@ -4229,8 +4559,24 @@ export class AgentFramework {
4229
4559
  continue;
4230
4560
  }
4231
4561
 
4232
- // Skip if agent is busy (inferring, streaming, or waiting for tools)
4233
- if (agent.state.status === 'inferring' || agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') {
4562
+ // Skip if agent is busy (inferring, streaming, or waiting for tools) —
4563
+ // or if a turn is alive at all (activeTurnTokens): the state machine
4564
+ // goes idle at endTurn's reset while driveStream teardown is still
4565
+ // pending, and a successor turn dequeued in that gap compiles while the
4566
+ // predecessor's deferred flush can still write (2026-07-31 Mythos).
4567
+ // Turn-alive spans dequeue → settled teardown; wakes wait it out.
4568
+ //
4569
+ // EXCEPT a context-budget restart: it CONTINUES the turn whose token is
4570
+ // held — queueing it behind its own turn's teardown is a deadlock when
4571
+ // that teardown lags (the cancelled stream's `aborted` event is
4572
+ // asynchronous and, historically, not even guaranteed — see the
4573
+ // overBudget branch). The restart's startAgentStream overwrites the
4574
+ // token; the predecessor frame's late finally no-ops via token-match.
4575
+ // This mirrors how the restart has always overwritten activeStreams
4576
+ // rather than waiting for the old stream's teardown.
4577
+ const isBudgetRestart = requests.some((r) => r.reason === 'context_budget_restart');
4578
+ const turnAlive = !isBudgetRestart && this.activeTurnTokens.has(agentName);
4579
+ if (turnAlive || agent.state.status === 'inferring' || agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') {
4234
4580
  // Re-queue requests, but warn if they've been pending too long
4235
4581
  const oldest = Math.min(...requests.map(r => r.timestamp));
4236
4582
  if (
@@ -4242,15 +4588,22 @@ export class AgentFramework {
4242
4588
  // (up to 100/sec after the scheduler backoff), adding avoidable work
4243
4589
  // precisely while the agent was already under pressure.
4244
4590
  this.staleWarnAt.set(agentName, now);
4591
+ // Surface turn-alive separately from the state machine: status can
4592
+ // read 'idle' while a turn's teardown is pending, and a LEAKED turn
4593
+ // token would look exactly like this — permanently requeued wakes.
4594
+ // This line is the wedge's tell (idle+turn-alive, forever).
4595
+ const shownStatus = turnAlive && agent.state.status === 'idle'
4596
+ ? 'idle+turn-alive'
4597
+ : agent.state.status;
4245
4598
  this.emitTrace({
4246
4599
  type: 'inference:request_stale',
4247
4600
  agentName,
4248
- agentStatus: agent.state.status,
4601
+ agentStatus: shownStatus,
4249
4602
  requestCount: requests.length,
4250
4603
  oldestRequestAge: now - oldest,
4251
4604
  });
4252
4605
  console.error(
4253
- `[inference-stale] agent=${agentName} busy (${agent.state.status}) — ` +
4606
+ `[inference-stale] agent=${agentName} busy (${shownStatus}) — ` +
4254
4607
  `${requests.length} request(s) waiting ${Math.round((now - oldest) / 1000)}s`,
4255
4608
  );
4256
4609
  }
@@ -4574,6 +4927,85 @@ export class AgentFramework {
4574
4927
  }
4575
4928
 
4576
4929
  private async startAgentStream(agent: Agent, trigger?: InferenceRequest, attempt = 0): Promise<void> {
4930
+ // Mark the turn alive before ANYTHING awaits (hooks, compile, stream
4931
+ // setup): from here until this turn's teardown, cross-turn writers defer
4932
+ // (addMessage guard) instead of appending — nothing may enter the window
4933
+ // between a turn's dequeue and its settle except the turn's own blocks.
4934
+ // A retry re-enters with a fresh token; teardown clears token-matched.
4935
+ //
4936
+ // The try/finally is the leak-proofing: a token whose owner never clears
4937
+ // it permanently requeues every wake for the agent (the 'idle+turn-alive'
4938
+ // wedge — worse than any single lost turn). Ownership is handed off
4939
+ // exactly once, to driveStream, the moment the stream handle is
4940
+ // registered; on every other exit — including a throw from the turn-start
4941
+ // flush, recordTurnCheckpoint, or locus resolution, none of which the
4942
+ // inner catch covers (ENOSPC-class store writes live there) — the finally
4943
+ // clears our own token. Token-matched, so when a retry frame has replaced
4944
+ // the token, or the terminal-failure branch already deleted it, this
4945
+ // no-ops instead of clobbering a successor's marker.
4946
+ const turnToken = this.nextTurnToken++;
4947
+ this.activeTurnTokens.set(agent.name, turnToken);
4948
+ let tokenHandedOff = false;
4949
+ try {
4950
+ tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken);
4951
+ } finally {
4952
+ if (!tokenHandedOff && this.activeTurnTokens.get(agent.name) === turnToken) {
4953
+ this.activeTurnTokens.delete(agent.name);
4954
+ }
4955
+ }
4956
+ }
4957
+
4958
+ /**
4959
+ * Body of startAgentStream, split out so the caller's try/finally owns the
4960
+ * turn token unconditionally. Returns true iff the token was handed off to
4961
+ * driveStream (whose finally then owns clearing it).
4962
+ */
4963
+ private async beginAgentTurn(
4964
+ agent: Agent,
4965
+ trigger: InferenceRequest | undefined,
4966
+ attempt: number,
4967
+ turnToken: number,
4968
+ ): Promise<boolean> {
4969
+ // Flush messages deferred during the PREVIOUS turn — before the
4970
+ // checkpoint, the locus announcement, and the compile — so a turn started
4971
+ // by a queued wake actually CONTAINS the message that woke it. (2026-07-31
4972
+ // Mythos: a DM's wake fired a turn whose compile ran before the deferred
4973
+ // DM flushed; the model saw only the routing-shift notice, reasonably
4974
+ // skipped, and the DM then landed before the skip in the window — false
4975
+ // "saw it and ignored it" history plus KV divergence. Flushed here, the
4976
+ // payload rides its own wake, and the notice/payload can never split.)
4977
+ // Primary agent only — deferred messages are framework-level, meant for
4978
+ // the main conversation (same target rule as the tool-boundary flush).
4979
+ // Fresh turns only: a context-budget restart continues the same logical
4980
+ // turn, and flushing there would insert messages between its rounds.
4981
+ // Before recordTurnCheckpoint: these are the turn's inputs, not its
4982
+ // products — undoing the turn must not destroy them.
4983
+ if (
4984
+ attempt === 0 &&
4985
+ trigger?.reason !== 'context_budget_restart' &&
4986
+ this.deferredMessages.length > 0
4987
+ ) {
4988
+ const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent;
4989
+ if (target === agent) {
4990
+ const deferred = this.deferredMessages.splice(0);
4991
+ for (const msg of deferred) {
4992
+ // Per-message try/catch (mirrors announceLocusIfChanged): one
4993
+ // poison message — or a transient store-write failure — must not
4994
+ // abort the turn or drop the messages behind it in the queue.
4995
+ try {
4996
+ const id = agent.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
4997
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'deferred-flush:turn-start' });
4998
+ } catch (err) {
4999
+ console.error(
5000
+ `[deferred-flush] turn-start flush failed to store a message for ${agent.name} ` +
5001
+ `(participant=${msg.participant}):`,
5002
+ err,
5003
+ );
5004
+ }
5005
+ }
5006
+ }
5007
+ }
5008
+
4577
5009
  // Record turn checkpoint before inference (only on first attempt, not retries)
4578
5010
  if (attempt === 0) {
4579
5011
  this.recordTurnCheckpoint(agent.name);
@@ -4643,6 +5075,21 @@ export class AgentFramework {
4643
5075
  this.eventGate?.onInferenceStarted(agent.name);
4644
5076
  this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
4645
5077
 
5078
+ // Typing indicator, started at TURN START. It says "attending", which is
5079
+ // true from the moment the turn is accepted — but it used to start inside
5080
+ // driveStream, i.e. only after module gatherContext + MCPL beforeInference
5081
+ // RPCs + the full context compile + stream initiation. All of that read as
5082
+ // dead air to whoever just messaged the agent (30+ s during the 2026-07
5083
+ // mythos compile regression; still seconds of hooks+compile after).
5084
+ // Same channel choice as driveStream's own startTyping (which stays, is
5085
+ // idempotent per channel, and owns the 7s refresh); the catch below stops
5086
+ // it on the no-driveStream failure paths (e.g. a compile refusal).
5087
+ const earlyTypingChannel =
5088
+ agent.proseRouting === 'explicit'
5089
+ ? trigger?.channelId ?? null
5090
+ : this.turnLocusPins.get(agent.name) ?? null;
5091
+ if (earlyTypingChannel) this.channelRegistry?.startTyping(earlyTypingChannel);
5092
+
4646
5093
  try {
4647
5094
  const requestSnapshot = this.captureInferenceToolSnapshot(agent);
4648
5095
  const allTools = this.getToolsForAgent(agent.name, requestSnapshot);
@@ -4699,7 +5146,14 @@ export class AgentFramework {
4699
5146
  compiledRequest,
4700
5147
  );
4701
5148
  this.activeStreams.set(agent.name, handle);
5149
+ // Handoff: driveStream captured the token in its synchronous prefix;
5150
+ // its finally now owns clearing it. The caller's finally must not.
5151
+ return true;
4702
5152
  } catch (error) {
5153
+ // The early typing indicator has no driveStream `finally` on this path —
5154
+ // a hook/compile/stream-setup failure must not leave "typing…" stuck.
5155
+ // (Retries below restart it; stopTyping is idempotent.)
5156
+ this.channelRegistry?.stopTyping();
4703
5157
  const err = error instanceof Error ? error : new Error(String(error));
4704
5158
  this.emitTrace({
4705
5159
  type: 'inference:failed',
@@ -4712,8 +5166,18 @@ export class AgentFramework {
4712
5166
  const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
4713
5167
  if (action.retry) {
4714
5168
  await new Promise((resolve) => setTimeout(resolve, action.delayMs));
5169
+ // The retry re-enters startAgentStream, which replaces this frame's
5170
+ // turn token with its own; cleanup belongs to the innermost frame.
4715
5171
  await this.startAgentStream(agent, trigger, attempt + 1);
4716
5172
  } else {
5173
+ // Terminal failure with no driveStream: this frame owns teardown.
5174
+ // Token-matched (like the finally in driveStream) so a stale frame
5175
+ // can never clear a successor turn's marker. A leaked token would
5176
+ // permanently requeue every wake for this agent (same wedge shape as
5177
+ // the eventGate `inferring` leak) — every exit path must clear it.
5178
+ if (this.activeTurnTokens.get(agent.name) === turnToken) {
5179
+ this.activeTurnTokens.delete(agent.name);
5180
+ }
4717
5181
  this.settleAgent(agent.name, {
4718
5182
  stopReason: 'exhausted',
4719
5183
  speech: '',
@@ -4737,6 +5201,12 @@ export class AgentFramework {
4737
5201
  this.pushEvent(action.emit);
4738
5202
  }
4739
5203
  }
5204
+ // No handoff on either failure branch: the terminal branch deleted the
5205
+ // token above; the retry branch's recursive startAgentStream replaced
5206
+ // it with the inner frame's token. Returning false lets the caller's
5207
+ // token-matched finally no-op in both cases (and actually clear it if
5208
+ // something above threw before either branch ran).
5209
+ return false;
4740
5210
  }
4741
5211
  }
4742
5212
 
@@ -4751,6 +5221,10 @@ export class AgentFramework {
4751
5221
  const startTime = Date.now();
4752
5222
  const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
4753
5223
  const myStreamId = agent.streamId;
5224
+ // This turn's alive-marker, set at startAgentStream entry. Safe to read
5225
+ // from the map here: the turn-alive busy check in processInferenceRequests
5226
+ // means no successor turn can have replaced it while we compiled.
5227
+ const myTurnToken = this.activeTurnTokens.get(agent.name);
4754
5228
  let hadToolCalls = false;
4755
5229
 
4756
5230
  // ---- Present-while-acting turn state ---------------------------------
@@ -5635,6 +6109,17 @@ export class AgentFramework {
5635
6109
  this.disposeConversationAgent(agent.name);
5636
6110
  }
5637
6111
 
6112
+ // The turn is torn down — release the turn-alive marker BEFORE the
6113
+ // deferred flush below, so the flush appends at the settled tail.
6114
+ // Token-matched: if a successor turn already owns the agent (endTurn
6115
+ // reset the state machine before this finally ran — the 2026-07-31
6116
+ // interleave), leave its marker alone; the guarded addMessage below
6117
+ // then re-defers, and the successor's own turn-start flush / boundary
6118
+ // injection / teardown delivers the messages at a correct position.
6119
+ if (myTurnToken !== undefined && this.activeTurnTokens.get(agent.name) === myTurnToken) {
6120
+ this.activeTurnTokens.delete(agent.name);
6121
+ }
6122
+
5638
6123
  // Flush any deferred messages (e.g. if stream failed while tools were pending)
5639
6124
  if (this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
5640
6125
  const deferred = this.deferredMessages.splice(0);
@@ -5649,8 +6134,23 @@ export class AgentFramework {
5649
6134
  * Execute a tool call and return the result.
5650
6135
  * Routes to the appropriate handler (module registry or MCPL).
5651
6136
  * Used by SubagentModule to dispatch tool calls for ephemeral agents.
6137
+ *
6138
+ * This public entry always carries AGENT origin. Ephemeral/promise-based
6139
+ * callers are models, and provenance must identify which internal path a
6140
+ * call entered through — a public method shared with model callers can
6141
+ * never confer module trust, whatever the input claims. Module origin
6142
+ * exists only through the private closure handed to ModuleRegistry
6143
+ * (ctx.callTool → executeToolCallFrom with {kind:'module'}). Callers that
6144
+ * don't identify themselves fail safe to agent semantics too.
5652
6145
  */
5653
6146
  async executeToolCall(call: ToolCall): Promise<ToolResult> {
6147
+ return this.executeToolCallFrom(call, {
6148
+ kind: 'agent',
6149
+ agentName: call.callerAgentName ?? '__ephemeral__',
6150
+ });
6151
+ }
6152
+
6153
+ private async executeToolCallFrom(call: ToolCall, origin: ChannelToolOrigin): Promise<ToolResult> {
5654
6154
  // Client-side programmatic tool calling for promise-based callers
5655
6155
  // (SubagentModule ephemerals). Keyed by callerAgentName so each ephemeral
5656
6156
  // gets its own interpreter state.
@@ -5671,6 +6171,27 @@ export class AgentFramework {
5671
6171
  }
5672
6172
  }
5673
6173
 
6174
+ // Synthesized channel tools. This route was missing: when
6175
+ // subscription-GC moved off the retired MCPL unsubscribe tool onto the
6176
+ // generic channel_close (discord-mcpl b095a9f), module-originated
6177
+ // closes started failing downstream as "Invalid tool name format" —
6178
+ // the janitor had been silently broken since. `origin` is trusted
6179
+ // dispatch context established at the call boundary above, not
6180
+ // inferred from which method was used: only the ModuleRegistry closure
6181
+ // carries module provenance (see handleToolClose). The agent-only
6182
+ // synthesized verbs (think, skip_reply) are deliberately not routed
6183
+ // here.
6184
+ if (call.name.startsWith('channel_') && this.channelRegistry) {
6185
+ return this.channelRegistry.handleChannelToolCall(call.name, call.input, origin);
6186
+ }
6187
+
6188
+ // The utils meta-tool — programmatic callers (code_execution scripts,
6189
+ // ephemerals, ModuleContext.callTool) reach utilities the same way the
6190
+ // model does.
6191
+ if (call.name === 'utils') {
6192
+ return this.handleUtilsToolCall(call.callerAgentName ?? '__ephemeral__', call);
6193
+ }
6194
+
5674
6195
  // Module tools
5675
6196
  return this.moduleRegistry.handleToolCall(call);
5676
6197
  }
@@ -5767,7 +6288,51 @@ export class AgentFramework {
5767
6288
  * results reach the running script and never the model context.
5768
6289
  */
5769
6290
  private async runCodeExecution(agentName: string, call: ToolCall): Promise<ToolResult> {
5770
- const input = (call.input ?? {}) as { code?: unknown };
6291
+ const input = (call.input ?? {}) as {
6292
+ code?: unknown;
6293
+ background?: unknown;
6294
+ action?: unknown;
6295
+ script_id?: unknown;
6296
+ };
6297
+
6298
+ // Management surface: the agent's own daemon fleet is inspectable and
6299
+ // killable through the same tool that spawns it.
6300
+ if (input.action === 'list') {
6301
+ const scripts = [...this.backgroundScripts.values()]
6302
+ .filter((s) => s.agentName === agentName)
6303
+ .map((s) => ({
6304
+ script_id: s.id,
6305
+ status: s.status,
6306
+ started_at: new Date(s.startedAt).toISOString(),
6307
+ wakes: s.wakes,
6308
+ last_wake_at: s.lastWakeAt ? new Date(s.lastWakeAt).toISOString() : null,
6309
+ log: s.logPath,
6310
+ }));
6311
+ return { success: true, data: { background_scripts: scripts } };
6312
+ }
6313
+ if (input.action === 'cancel') {
6314
+ if (typeof input.script_id !== 'string') {
6315
+ return { success: false, error: 'cancel requires `script_id`', isError: true };
6316
+ }
6317
+ const record = this.backgroundScripts.get(input.script_id);
6318
+ if (!record || record.agentName !== agentName) {
6319
+ return { success: false, error: `no background script '${input.script_id}'`, isError: true };
6320
+ }
6321
+ if (record.status === 'running') {
6322
+ record.cancelled = true;
6323
+ record.status = 'cancelled';
6324
+ record.runner.abort('cancelled by agent');
6325
+ record.runner.dispose();
6326
+ }
6327
+ return {
6328
+ success: true,
6329
+ data: { script_id: record.id, status: record.status, log: record.logPath },
6330
+ };
6331
+ }
6332
+ if (input.action !== undefined && input.action !== 'run') {
6333
+ return { success: false, error: `unknown action ${JSON.stringify(input.action)}`, isError: true };
6334
+ }
6335
+
5771
6336
  if (typeof input.code !== 'string' || input.code.trim() === '') {
5772
6337
  return {
5773
6338
  success: false,
@@ -5784,6 +6349,10 @@ export class AgentFramework {
5784
6349
  surface.map((t) => t.name).filter((name) => name !== CODE_EXECUTION_TOOL_NAME),
5785
6350
  );
5786
6351
 
6352
+ if (input.background === true) {
6353
+ return this.startBackgroundScript(agentName, input.code, injected);
6354
+ }
6355
+
5787
6356
  const runner = this.getOrCreateScriptRunner(agentName);
5788
6357
  this.scriptDeferredEndTurn.delete(agentName);
5789
6358
  const exec = await runner.exec(input.code, injected);
@@ -5797,6 +6366,291 @@ export class AgentFramework {
5797
6366
  };
5798
6367
  }
5799
6368
 
6369
+ // ---------------------------------------------------------------------------
6370
+ // Background (daemon) scripts — model-authored watchers that outlive the turn
6371
+ // ---------------------------------------------------------------------------
6372
+
6373
+ /**
6374
+ * Start a background script: dedicated interpreter, output journaled to a
6375
+ * workspace file, wake_agent() available in-script. The tool result
6376
+ * returns immediately; the script runs until it ends, hits the lifetime
6377
+ * ceiling, or is cancelled. Scripts DIE WITH THE HOST PROCESS — that
6378
+ * limitation is stated in the tool description so agents can calibrate
6379
+ * how much to rely on an armed wake.
6380
+ */
6381
+ private startBackgroundScript(
6382
+ agentName: string,
6383
+ code: string,
6384
+ injected: import('./code-execution/py-runner.js').InjectedTool[],
6385
+ ): ToolResult {
6386
+ // v1: primary-agent-only. A conversation fork's or ephemeral's daemon
6387
+ // would outlive its owner and its wake would land in the primary
6388
+ // conversation anyway — refuse loudly instead of surprising anyone.
6389
+ if (this.primaryAgentName && agentName !== this.primaryAgentName) {
6390
+ return {
6391
+ success: false,
6392
+ error: 'background scripts are available to the primary agent only',
6393
+ isError: true,
6394
+ };
6395
+ }
6396
+ const cfg = this.codeExecutionConfig;
6397
+ const maxScripts = cfg?.maxBackgroundScripts ?? 3;
6398
+ const runningCount = [...this.backgroundScripts.values()]
6399
+ .filter((s) => s.agentName === agentName && s.status === 'running').length;
6400
+ if (runningCount >= maxScripts) {
6401
+ return {
6402
+ success: false,
6403
+ error: `background script limit reached (${maxScripts} running). ` +
6404
+ 'Use code_execution {"action": "list"} to inspect and {"action": "cancel", "script_id": ...} to free a slot.',
6405
+ isError: true,
6406
+ };
6407
+ }
6408
+
6409
+ const scriptId = `bg-${++this.backgroundScriptCounter}`;
6410
+ const lifetimeMs = cfg?.backgroundMaxLifetimeMs ?? 86_400_000;
6411
+
6412
+ // Journal: a file under the agent's first read-write workspace mount so
6413
+ // their existing read/grep/shell tools work on it. Python appends
6414
+ // directly to the materialized path; watch-mode mounts sync it back to
6415
+ // the chronicle tree on their usual cadence.
6416
+ let logPath: string | null = null;
6417
+ let logAbsPath: string | null = null;
6418
+ const workspace = this.getWorkspaceModule();
6419
+ if (workspace) {
6420
+ const mountName = this.firstWritableMountName(workspace);
6421
+ if (mountName) {
6422
+ logPath = `${mountName}/background-scripts/${scriptId}.log`;
6423
+ logAbsPath = workspace.resolveAbsolutePath(logPath);
6424
+ }
6425
+ }
6426
+
6427
+ const runner = new PyRunner({
6428
+ pythonPath: cfg?.pythonPath,
6429
+ toolCallTimeoutMs: cfg?.toolCallTimeoutMs,
6430
+ scriptTimeoutMs: cfg?.scriptTimeoutMs,
6431
+ idleReclaimMs: 0, // dedicated runner; lifetime is the exec deadline
6432
+ label: `${agentName}:${scriptId}`,
6433
+ onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args),
6434
+ });
6435
+
6436
+ const record: BackgroundScriptRecord = {
6437
+ id: scriptId,
6438
+ agentName,
6439
+ runner,
6440
+ code,
6441
+ startedAt: Date.now(),
6442
+ wakes: 0,
6443
+ lastWakeAt: null,
6444
+ logPath,
6445
+ status: 'running',
6446
+ cancelled: false,
6447
+ };
6448
+ this.backgroundScripts.set(scriptId, record);
6449
+ this.emitTrace({
6450
+ type: 'tool:started', module: 'code_execution', tool: `background:${scriptId}`,
6451
+ callId: scriptId, input: { lines: code.split('\n').length },
6452
+ });
6453
+
6454
+ void runner
6455
+ .exec(code, injected, {
6456
+ logPath: logAbsPath,
6457
+ lifetimeMs,
6458
+ onWake: (line, payload) => this.handleScriptWake(record, line, payload),
6459
+ })
6460
+ .then((exec) => this.settleBackgroundScript(record, exec))
6461
+ .catch((err) => {
6462
+ // exec never rejects by contract; this is the belt-and-suspenders.
6463
+ console.error(`[pytc:${agentName}:${scriptId}] background exec rejected: ${String(err)}`);
6464
+ this.settleBackgroundScript(record, {
6465
+ stdout: '', stderr: String(err), returnCode: 1, aborted: true,
6466
+ });
6467
+ });
6468
+
6469
+ return {
6470
+ success: true,
6471
+ data: {
6472
+ status: 'background script started',
6473
+ script_id: scriptId,
6474
+ log: logPath ?? 'no writable workspace mount — output is not retrievable; only wake_agent() reaches you',
6475
+ lifetime_hours: Math.round(lifetimeMs / 3_600_000 * 10) / 10,
6476
+ note: 'The script dies if the host process restarts. Manage with code_execution {"action": "list"|"cancel"}.',
6477
+ },
6478
+ };
6479
+ }
6480
+
6481
+ /** First read-write mount name, or null. */
6482
+ private firstWritableMountName(workspace: WorkspaceModule): string | null {
6483
+ const mounts = (workspace as unknown as {
6484
+ getMounts?: () => Array<{ name: string; mode: string }>;
6485
+ }).getMounts?.();
6486
+ if (mounts) {
6487
+ const rw = mounts.find((m) => m.mode !== 'read-only');
6488
+ return rw?.name ?? null;
6489
+ }
6490
+ return null;
6491
+ }
6492
+
6493
+ /**
6494
+ * wake_agent() from a background script: enforce the per-script wake cap
6495
+ * and rate floor (delay, not drop — the script awaits the ack), then
6496
+ * inject the provenance envelope + payload and request inference.
6497
+ * Resolves null on delivery; an error string refuses the wake (raises
6498
+ * RuntimeError inside the script).
6499
+ */
6500
+ private async handleScriptWake(
6501
+ record: BackgroundScriptRecord,
6502
+ line: number,
6503
+ payload: unknown,
6504
+ ): Promise<string | null> {
6505
+ if (record.cancelled) return 'script was cancelled';
6506
+ const cfg = this.codeExecutionConfig;
6507
+ const maxWakes = cfg?.maxWakesPerScript ?? 100;
6508
+ if (record.wakes >= maxWakes) {
6509
+ return `wake limit reached (${maxWakes} per script)`;
6510
+ }
6511
+ const floorMs = cfg?.wakeMinIntervalMs ?? 60_000;
6512
+ if (record.lastWakeAt !== null) {
6513
+ const wait = record.lastWakeAt + floorMs - Date.now();
6514
+ if (wait > 0) {
6515
+ await new Promise((r) => {
6516
+ const t = setTimeout(r, wait);
6517
+ (t as { unref?: () => void }).unref?.();
6518
+ });
6519
+ if (record.cancelled) return 'script was cancelled';
6520
+ }
6521
+ }
6522
+ record.wakes += 1;
6523
+ record.lastWakeAt = Date.now();
6524
+
6525
+ const elapsedMin = Math.round((Date.now() - record.startedAt) / 60_000);
6526
+ const payloadJson = payload === undefined || payload === null
6527
+ ? 'null'
6528
+ : JSON.stringify(payload, null, 1) ?? String(payload);
6529
+ const agent = this.agents.get(record.agentName);
6530
+ const maxChars = agent ? this.getMaxToolResultChars(agent) : undefined;
6531
+ const payloadText = await this.spillOrTruncate(
6532
+ payloadJson, maxChars, `${record.id}-wake${record.wakes}`,
6533
+ );
6534
+
6535
+ const envelope =
6536
+ `[background script ${record.id}] Woke you: wake_agent() called at line ${line} of your script, ` +
6537
+ `${elapsedMin}m after you started it (wake ${record.wakes} of ${maxWakes}).\n` +
6538
+ `Arguments:\n${payloadText}\n` +
6539
+ (record.logPath ? `Script output so far: workspace file ${record.logPath}\n` : '') +
6540
+ `Script status: still running`;
6541
+
6542
+ this.injectScriptWake(record, envelope);
6543
+ return null;
6544
+ }
6545
+
6546
+ /** Background script ended (clean, crash, deadline, cancel/dispose). */
6547
+ private settleBackgroundScript(
6548
+ record: BackgroundScriptRecord,
6549
+ exec: import('./code-execution/py-runner.js').ExecResult,
6550
+ ): void {
6551
+ record.runner.dispose();
6552
+ if (record.status === 'running') {
6553
+ record.status = exec.returnCode === 0 ? 'finished' : 'died';
6554
+ }
6555
+ this.emitTrace({
6556
+ type: 'tool:completed', module: 'code_execution', tool: `background:${record.id}`,
6557
+ callId: record.id, durationMs: Date.now() - record.startedAt,
6558
+ });
6559
+ // Crash honesty: a resident sleeping on a promise must never have that
6560
+ // promise die silently. Deliberate cancels/disposes stay silent (the
6561
+ // agent or host chose them); clean exits stay silent (the null path).
6562
+ if (record.status === 'died' && !record.cancelled) {
6563
+ const tail = (exec.tail ?? exec.stderr ?? '').slice(-2000);
6564
+ const elapsedMin = Math.round((Date.now() - record.startedAt) / 60_000);
6565
+ const envelope =
6566
+ `[background script ${record.id}] Your background script DIED ${elapsedMin}m after start ` +
6567
+ `(it did not call wake_agent and is no longer watching).\n` +
6568
+ `Last output:\n${tail || '(none)'}\n` +
6569
+ (record.logPath ? `Full journal: workspace file ${record.logPath}` : '');
6570
+ this.injectScriptWake(record, envelope);
6571
+ }
6572
+ // Keep a short memory of settled scripts for {"action":"list"}, then drop.
6573
+ const settled = [...this.backgroundScripts.values()]
6574
+ .filter((s) => s.agentName === record.agentName && s.status !== 'running');
6575
+ for (const old of settled.slice(0, Math.max(0, settled.length - 5))) {
6576
+ this.backgroundScripts.delete(old.id);
6577
+ }
6578
+ }
6579
+
6580
+ /**
6581
+ * Deliver a background-script event: provenance envelope into the agent's
6582
+ * context + an inference request. Deliberately does NOT consult the event
6583
+ * gate — the agent armed this wake themselves (wake-through-sleep
6584
+ * authority); it enters tagged so gate policies could be taught about it
6585
+ * later if that ever needs revisiting.
6586
+ */
6587
+ private injectScriptWake(record: BackgroundScriptRecord, envelope: string): void {
6588
+ try {
6589
+ const messageId = this.addMessage('user', [{ type: 'text', text: envelope }], {
6590
+ source: 'background-script',
6591
+ scriptId: record.id,
6592
+ tags: ['script:wake'],
6593
+ });
6594
+ this.pendingRequests.push({
6595
+ agentName: record.agentName,
6596
+ reason: 'script:wake',
6597
+ source: 'code_execution',
6598
+ timestamp: Date.now(),
6599
+ });
6600
+ this.emitTrace({
6601
+ type: 'message:added',
6602
+ messageId,
6603
+ source: `background-script:${record.id}`,
6604
+ });
6605
+ } catch (err) {
6606
+ console.error(
6607
+ `[pytc:${record.agentName}:${record.id}] wake injection failed: ${err instanceof Error ? err.message : String(err)}`,
6608
+ );
6609
+ }
6610
+ }
6611
+
6612
+ /**
6613
+ * Oversized content policy (antra, 2026-07-31): never hit the agent in
6614
+ * the face with a huge blob, never silently destroy it either. Content
6615
+ * over the inline cap is materialized to a workspace file (readable with
6616
+ * the agent's own tools) and replaced by a truncated head + a trailing
6617
+ * file reference. Falls back to plain truncation when there is no
6618
+ * writable workspace. The cap can be lifted temporarily via
6619
+ * agent_settings `tool_result_inline_max_chars`.
6620
+ */
6621
+ private async spillOrTruncate(
6622
+ content: string,
6623
+ maxChars: number | undefined,
6624
+ label: string,
6625
+ agentName?: string,
6626
+ ): Promise<string> {
6627
+ const override = agentName !== undefined
6628
+ ? this.toolResultInlineMaxCharsOverride.get(agentName)
6629
+ : undefined;
6630
+ const cap = override ?? maxChars;
6631
+ if (!cap || content.length <= cap) return content;
6632
+
6633
+ const workspace = this.getWorkspaceModule();
6634
+ const mountName = workspace ? this.firstWritableMountName(workspace) : null;
6635
+ if (workspace && mountName) {
6636
+ const safeLabel = label.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
6637
+ const path = `${mountName}/tool-results/${safeLabel}.txt`;
6638
+ try {
6639
+ const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain');
6640
+ if (result.success) {
6641
+ return safeSlice(content, 0, cap)
6642
+ + `\n\n[truncated — showing ${cap} of ${content.length} chars; full content: workspace file ${path}. `
6643
+ + 'Read/grep it with your file tools, or raise the inline cap temporarily via '
6644
+ + 'agent_settings update tool_result_inline_max_chars.]';
6645
+ }
6646
+ } catch {
6647
+ // fall through to plain truncation
6648
+ }
6649
+ }
6650
+ return safeSlice(content, 0, cap)
6651
+ + '\n\n[truncated — original was ' + content.length + ' chars]';
6652
+ }
6653
+
5800
6654
  private getOrCreateScriptRunner(agentName: string): PyRunner {
5801
6655
  let runner = this.codeExecutionRunners.get(agentName);
5802
6656
  if (!runner) {
@@ -5842,9 +6696,10 @@ export class AgentFramework {
5842
6696
  return `Error: ${result.error ?? 'tool call failed'}`;
5843
6697
  }
5844
6698
  if (result.data === undefined) return '';
5845
- const agent = this.agents.get(agentName);
5846
- const maxChars = agent ? this.getMaxToolResultChars(agent) : undefined;
5847
- return toolResultDataToHistoryString(result.data, maxChars);
6699
+ // Scripts get (nearly) full data — the script environment IS the spill:
6700
+ // filtering big results in code is the whole point. 5MB protocol safety
6701
+ // cap only; no context-cap truncation, no file spill.
6702
+ return toolResultDataToHistoryString(result.data, 5_000_000);
5848
6703
  }
5849
6704
 
5850
6705
  /**
@@ -5900,7 +6755,14 @@ export class AgentFramework {
5900
6755
  }
5901
6756
  }
5902
6757
 
5903
- private toMembraneToolResult(callId: string, afResult: ToolResult, maxChars?: number): MembraneToolResult {
6758
+ private toMembraneToolResult(
6759
+ callId: string,
6760
+ afResult: ToolResult,
6761
+ maxChars?: number,
6762
+ /** Pre-spilled string from the history path — used for the non-image
6763
+ * path so the live wire copy byte-matches what the window stored. */
6764
+ precomputed?: string,
6765
+ ): MembraneToolResult {
5904
6766
  if (afResult.isError) {
5905
6767
  return { toolUseId: callId, content: afResult.error ?? 'Unknown error', isError: true };
5906
6768
  }
@@ -5912,6 +6774,9 @@ export class AgentFramework {
5912
6774
  if (blocks) {
5913
6775
  return { toolUseId: callId, content: blocks, isError: false };
5914
6776
  }
6777
+ if (precomputed !== undefined) {
6778
+ return { toolUseId: callId, content: precomputed, isError: false };
6779
+ }
5915
6780
  // JSON.stringify returns the VALUE undefined (not a string) for undefined
5916
6781
  // input — a module tool returning `{ success: true }` with no data would
5917
6782
  // otherwise make `content.length` below throw a TypeError mid-turn.
@@ -6091,6 +6956,11 @@ export class AgentFramework {
6091
6956
  }
6092
6957
 
6093
6958
  // Route the agent's typed, allowlisted hot-settings surface.
6959
+ if (enrichedCall.name === 'utils') {
6960
+ this.dispatchUtilsToolCall(agentName, enrichedCall);
6961
+ return;
6962
+ }
6963
+
6094
6964
  if (enrichedCall.name === 'agent_settings') {
6095
6965
  this.dispatchAgentSettingsToolCall(agentName, enrichedCall);
6096
6966
  return;
@@ -6249,15 +7119,27 @@ export class AgentFramework {
6249
7119
 
6250
7120
  // Defer non-tool_result messages while a tool cycle is mid-flight
6251
7121
  // (pendingAssistantBlocks: preserves tool_use → tool_result adjacency)
6252
- // OR while the target agent has a live stream at all. A message stored
6253
- // mid-stream lands BEFORE the turn's assistant blocks in the window even
6254
- // though the live conversation never saw it — so the next compile
6255
- // diverges from the live prefix (prompt-cache bust) and the message
6256
- // misses mid-turn injection. Deferred messages flush at the agent's next
6257
- // tool boundary (where they are ALSO injected into the live stream —
6258
- // hear-while-acting) or in driveStream's finally when the turn ends.
7122
+ // OR while the target agent has a turn in progress AT ALL. A message
7123
+ // stored mid-turn lands BEFORE the turn's assistant blocks in the window
7124
+ // even though the live conversation never saw it — so the next compile
7125
+ // diverges from the live prefix (prompt-cache bust), the message misses
7126
+ // mid-turn injection, and the window falsely testifies the agent saw it
7127
+ // and moved on. Turn-alive (activeTurnTokens) is the guard, not stream-
7128
+ // alive: a turn exists from dequeue through hooks + compile before its
7129
+ // stream registers in activeStreams, and the 2026-07-31 Mythos incident
7130
+ // was precisely a deferred flush landing inside that compile window.
7131
+ // (activeStreams is kept as a belt-and-suspenders backstop.) Deferred
7132
+ // messages flush at the next turn's START (before its compile — riding
7133
+ // their own wake), at the agent's next tool boundary (where they are
7134
+ // ALSO injected into the live stream — hear-while-acting), or in
7135
+ // driveStream's finally when the turn ends.
6259
7136
  const hasToolResult = content.some(b => b.type === 'tool_result');
6260
- if (!hasToolResult && (this.pendingAssistantBlocks.size > 0 || this.activeStreams.has(agent.name))) {
7137
+ if (
7138
+ !hasToolResult &&
7139
+ (this.pendingAssistantBlocks.size > 0 ||
7140
+ this.activeTurnTokens.has(agent.name) ||
7141
+ this.activeStreams.has(agent.name))
7142
+ ) {
6261
7143
  this.deferredMessages.push({ participant, content, metadata });
6262
7144
  return '' as MessageId; // Deferred — flushed at the next boundary
6263
7145
  }
@@ -8031,7 +8913,7 @@ export class AgentFramework {
8031
8913
  this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input });
8032
8914
  const startTime = Date.now();
8033
8915
 
8034
- this.channelRegistry!.handleChannelToolCall(call.name, call.input)
8916
+ this.channelRegistry!.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName })
8035
8917
  .then((result) => {
8036
8918
  const durationMs = Date.now() - startTime;
8037
8919
  this.emitTrace({ type: 'tool:completed', module: 'channels', tool: call.name, callId: call.id, durationMs });
@@ -8258,6 +9140,38 @@ export class AgentFramework {
8258
9140
  if (typeof input.path !== 'string' || input.path.length === 0) {
8259
9141
  throw new Error('read_image: `path` (mount-prefixed) is required');
8260
9142
  }
9143
+ // Anthropic rejects images >5MB (and >8000px); guard the hard byte
9144
+ // limit here — resizing is out of scope without an image library.
9145
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
9146
+ // Prefer the workspace module's own read_image handler when it exists:
9147
+ // it reads tree-first with a FILESYSTEM FALLBACK, so binaries that live
9148
+ // only on disk (shell-created files, pre-fix mangled tree blobs) still
9149
+ // render. The legacy tree-only path below stays for older workspace
9150
+ // modules that don't advertise the tool.
9151
+ if (
9152
+ typeof workspace.handleToolCall === 'function' &&
9153
+ typeof workspace.getTools === 'function' &&
9154
+ workspace.getTools().some((tool) => tool.name === 'read_image')
9155
+ ) {
9156
+ const result = await workspace.handleToolCall({ ...call, name: 'read_image' });
9157
+ if (result.success && Array.isArray(result.data)) {
9158
+ const image = result.data.find(
9159
+ (block): block is { type: 'image'; data: string; mimeType: string } =>
9160
+ typeof block === 'object' && block !== null &&
9161
+ (block as { type?: unknown }).type === 'image' &&
9162
+ typeof (block as { data?: unknown }).data === 'string',
9163
+ );
9164
+ const byteLength = image ? Math.floor((image.data.length * 3) / 4) : 0;
9165
+ if (byteLength > MAX_IMAGE_BYTES) {
9166
+ throw new Error(
9167
+ `read_image: "${input.path}" is ~${byteLength} bytes — ` +
9168
+ `over the ${MAX_IMAGE_BYTES}-byte model limit`,
9169
+ );
9170
+ }
9171
+ }
9172
+ finish(result);
9173
+ return;
9174
+ }
8261
9175
  const read = await workspace.readBinary(input.path);
8262
9176
  if ('error' in read) throw new Error(read.error);
8263
9177
  const mediaType = sniffImageMediaType(read.data);
@@ -8267,9 +9181,6 @@ export class AgentFramework {
8267
9181
  '(png/jpeg/gif/webp — checked by magic bytes, not extension)',
8268
9182
  );
8269
9183
  }
8270
- // Anthropic rejects images >5MB (and >8000px); guard the hard byte
8271
- // limit here — resizing is out of scope without an image library.
8272
- const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
8273
9184
  if (read.data.byteLength > MAX_IMAGE_BYTES) {
8274
9185
  throw new Error(
8275
9186
  `read_image: "${input.path}" is ${read.data.byteLength} bytes — ` +
@@ -8443,6 +9354,83 @@ export class AgentFramework {
8443
9354
  })();
8444
9355
  }
8445
9356
 
9357
+ /** The `utils` meta-tool: list/describe/run over module utilities
9358
+ * (Module.getUtilities). Shared by the model-facing dispatcher and the
9359
+ * programmatic path (executeToolCall), so code_execution scripts and
9360
+ * ephemerals reach utilities too. */
9361
+ private async handleUtilsToolCall(agentName: string, call: ToolCall): Promise<ToolResult> {
9362
+ const input = (call.input ?? {}) as { action?: unknown; name?: unknown; args?: unknown };
9363
+ const utilities = this.moduleRegistry.getAllUtilities();
9364
+ const available = (): string =>
9365
+ utilities.length ? utilities.map((u) => u.name).join(', ') : '(none registered)';
9366
+ switch (input.action) {
9367
+ case 'list':
9368
+ return {
9369
+ success: true,
9370
+ data: utilities.map((u) => ({ name: u.name, description: utilityOneLiner(u.description) })),
9371
+ };
9372
+ case 'describe': {
9373
+ const def = utilities.find((u) => u.name === input.name);
9374
+ if (!def) {
9375
+ return { success: false, isError: true, error: `No utility "${String(input.name)}". Available: ${available()}` };
9376
+ }
9377
+ return { success: true, data: def };
9378
+ }
9379
+ case 'run': {
9380
+ const def = utilities.find((u) => u.name === input.name);
9381
+ if (!def) {
9382
+ return { success: false, isError: true, error: `No utility "${String(input.name)}". Available: ${available()}` };
9383
+ }
9384
+ const args = input.args ?? {};
9385
+ const problem = validateUtilityArgs(args, def.inputSchema);
9386
+ if (problem) {
9387
+ // Teach by bounce: the error carries the schema, so the retry needs
9388
+ // no describe round-trip.
9389
+ return {
9390
+ success: false,
9391
+ isError: true,
9392
+ error: `${problem}. Schema for ${def.name}: ${JSON.stringify(def.inputSchema)}`,
9393
+ };
9394
+ }
9395
+ // Same dispatch as a first-class tool call — the module cannot tell
9396
+ // which surface the call came through.
9397
+ return this.moduleRegistry.handleToolCall({
9398
+ id: call.id,
9399
+ name: def.name,
9400
+ input: args,
9401
+ callerAgentName: agentName,
9402
+ });
9403
+ }
9404
+ default:
9405
+ return { success: false, isError: true, error: 'action must be "list", "describe", or "run"' };
9406
+ }
9407
+ }
9408
+
9409
+ private dispatchUtilsToolCall(agentName: string, call: ToolCall): void {
9410
+ this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'utils', callId: call.id, input: call.input });
9411
+ const startedAt = Date.now();
9412
+ void this.handleUtilsToolCall(agentName, call)
9413
+ .catch((err): ToolResult => ({
9414
+ success: false,
9415
+ error: err instanceof Error ? err.message : String(err),
9416
+ isError: true,
9417
+ }))
9418
+ .then((result) => {
9419
+ if (result.success) {
9420
+ this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'utils', callId: call.id, durationMs: Date.now() - startedAt });
9421
+ } else {
9422
+ this.emitTrace({ type: 'tool:failed', module: 'framework', tool: 'utils', callId: call.id, error: result.error ?? 'unknown error' });
9423
+ }
9424
+ this.pushEvent({
9425
+ type: 'tool-result',
9426
+ callId: call.id,
9427
+ agentName,
9428
+ moduleName: 'framework',
9429
+ result,
9430
+ });
9431
+ });
9432
+ }
9433
+
8446
9434
  private dispatchAgentSettingsToolCall(agentName: string, call: ToolCall): void {
8447
9435
  this.emitTrace({
8448
9436
  type: 'tool:started',
@@ -8459,6 +9447,7 @@ export class AgentFramework {
8459
9447
  tail_tokens?: unknown;
8460
9448
  transition_pace_tokens?: unknown;
8461
9449
  same_round_think_text_policy?: unknown;
9450
+ immediate?: unknown;
8462
9451
  settings?: unknown;
8463
9452
  } & Record<string, unknown>;
8464
9453
  const extensions = this.collectAgentSettingsExtensions();
@@ -8490,6 +9479,9 @@ export class AgentFramework {
8490
9479
  if (input.same_round_think_text_policy !== undefined) {
8491
9480
  patch.sameRoundThinkTextPolicy = input.same_round_think_text_policy as AgentRuntimeSettingsPatch['sameRoundThinkTextPolicy'];
8492
9481
  }
9482
+ // Immediate budget decrease: skip the paced descent; the next
9483
+ // compile plans straight at the new budget (one-shot fold-down).
9484
+ if (input.immediate !== undefined) patch.immediate = Boolean(input.immediate);
8493
9485
  // Route extension-owned keys to their modules; apply the core patch
8494
9486
  // only when it touches core keys (an extension-only update must not
8495
9487
  // disturb a converging budget transition).