@animalabs/agent-framework 0.7.4 → 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 (92) hide show
  1. package/dist/src/code-execution/index.d.ts +1 -1
  2. package/dist/src/code-execution/index.d.ts.map +1 -1
  3. package/dist/src/code-execution/index.js.map +1 -1
  4. package/dist/src/code-execution/py-runner.d.ts +23 -1
  5. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  6. package/dist/src/code-execution/py-runner.js +40 -4
  7. package/dist/src/code-execution/py-runner.js.map +1 -1
  8. package/dist/src/code-execution/runtime-py.d.ts +15 -2
  9. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  10. package/dist/src/code-execution/runtime-py.js +156 -7
  11. package/dist/src/code-execution/runtime-py.js.map +1 -1
  12. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  13. package/dist/src/code-execution/tool-definition.js +27 -2
  14. package/dist/src/code-execution/tool-definition.js.map +1 -1
  15. package/dist/src/framework.d.ts +73 -0
  16. package/dist/src/framework.d.ts.map +1 -1
  17. package/dist/src/framework.js +818 -23
  18. package/dist/src/framework.js.map +1 -1
  19. package/dist/src/mcpl/channel-registry.d.ts +28 -2
  20. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  21. package/dist/src/mcpl/channel-registry.js +53 -7
  22. package/dist/src/mcpl/channel-registry.js.map +1 -1
  23. package/dist/src/mcpl/transport.d.ts +1 -1
  24. package/dist/src/mcpl/transport.d.ts.map +1 -1
  25. package/dist/src/mcpl/transport.js +25 -7
  26. package/dist/src/mcpl/transport.js.map +1 -1
  27. package/dist/src/mcpl/types.d.ts +10 -0
  28. package/dist/src/mcpl/types.d.ts.map +1 -1
  29. package/dist/src/mcpl/types.js.map +1 -1
  30. package/dist/src/module-registry.d.ts +12 -0
  31. package/dist/src/module-registry.d.ts.map +1 -1
  32. package/dist/src/module-registry.js +26 -0
  33. package/dist/src/module-registry.js.map +1 -1
  34. package/dist/src/modules/workspace/index.d.ts +20 -0
  35. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  36. package/dist/src/modules/workspace/index.js +48 -15
  37. package/dist/src/modules/workspace/index.js.map +1 -1
  38. package/dist/src/modules/workspace/sync.d.ts +4 -0
  39. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  40. package/dist/src/modules/workspace/sync.js +1 -1
  41. package/dist/src/modules/workspace/sync.js.map +1 -1
  42. package/dist/src/types/framework.d.ts +10 -0
  43. package/dist/src/types/framework.d.ts.map +1 -1
  44. package/dist/src/types/module.d.ts +22 -0
  45. package/dist/src/types/module.d.ts.map +1 -1
  46. package/dist/test/busy-agent-scheduler.test.js +1 -0
  47. package/dist/test/busy-agent-scheduler.test.js.map +1 -1
  48. package/dist/test/code-execution.test.js +327 -1
  49. package/dist/test/code-execution.test.js.map +1 -1
  50. package/dist/test/deferred-wake-integrity.test.d.ts +29 -0
  51. package/dist/test/deferred-wake-integrity.test.d.ts.map +1 -0
  52. package/dist/test/deferred-wake-integrity.test.js +320 -0
  53. package/dist/test/deferred-wake-integrity.test.js.map +1 -0
  54. package/dist/test/machine-close-provenance.test.d.ts +2 -0
  55. package/dist/test/machine-close-provenance.test.d.ts.map +1 -0
  56. package/dist/test/machine-close-provenance.test.js +110 -0
  57. package/dist/test/machine-close-provenance.test.js.map +1 -0
  58. package/dist/test/mcpl-access-provider.test.d.ts +2 -0
  59. package/dist/test/mcpl-access-provider.test.d.ts.map +1 -0
  60. package/dist/test/mcpl-access-provider.test.js +20 -0
  61. package/dist/test/mcpl-access-provider.test.js.map +1 -0
  62. package/dist/test/module-context-integration.test.d.ts +18 -0
  63. package/dist/test/module-context-integration.test.d.ts.map +1 -0
  64. package/dist/test/module-context-integration.test.js +181 -0
  65. package/dist/test/module-context-integration.test.js.map +1 -0
  66. package/dist/test/present-while-acting.test.js +72 -0
  67. package/dist/test/present-while-acting.test.js.map +1 -1
  68. package/dist/test/utils-surface.test.d.ts +2 -0
  69. package/dist/test/utils-surface.test.d.ts.map +1 -0
  70. package/dist/test/utils-surface.test.js +145 -0
  71. package/dist/test/utils-surface.test.js.map +1 -0
  72. package/dist/test/workspace-read-image.test.js +71 -0
  73. package/dist/test/workspace-read-image.test.js.map +1 -1
  74. package/dist/test/workspace-restore-order.test.d.ts +16 -0
  75. package/dist/test/workspace-restore-order.test.d.ts.map +1 -0
  76. package/dist/test/workspace-restore-order.test.js +140 -0
  77. package/dist/test/workspace-restore-order.test.js.map +1 -0
  78. package/dist/tsconfig.tsbuildinfo +1 -1
  79. package/package.json +1 -1
  80. package/src/code-execution/index.ts +1 -0
  81. package/src/code-execution/py-runner.ts +56 -4
  82. package/src/code-execution/runtime-py.ts +156 -7
  83. package/src/code-execution/tool-definition.ts +27 -2
  84. package/src/framework.ts +931 -25
  85. package/src/mcpl/channel-registry.ts +98 -4
  86. package/src/mcpl/transport.ts +26 -7
  87. package/src/mcpl/types.ts +11 -0
  88. package/src/module-registry.ts +34 -0
  89. package/src/modules/workspace/index.ts +49 -15
  90. package/src/modules/workspace/sync.ts +1 -1
  91. package/src/types/framework.ts +10 -0
  92. 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
 
@@ -3446,6 +3619,23 @@ export class AgentFramework {
3446
3619
  // Compute truncation limit from agent's strategy (maxMessageTokens * 4 chars)
3447
3620
  const maxChars = this.getMaxToolResultChars(agent);
3448
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
+
3449
3639
  // Store tool results as a user message (tool_result blocks).
3450
3640
  // Use the history serializer so MCP image blocks become a short
3451
3641
  // `[image: type, size]` placeholder instead of megabytes of base64
@@ -3455,7 +3645,7 @@ export class AgentFramework {
3455
3645
  toolUseId: tc.id,
3456
3646
  content: tc.result.isError
3457
3647
  ? tc.result.error ?? 'Unknown error'
3458
- : toolResultDataToHistoryString(tc.result.data, maxChars),
3648
+ : spilled.get(tc.id) ?? toolResultDataToHistoryString(tc.result.data, maxChars),
3459
3649
  isError: tc.result.isError,
3460
3650
  }));
3461
3651
  agent.getContextManager().addMessage('user', toolResultContent);
@@ -3529,6 +3719,73 @@ export class AgentFramework {
3529
3719
  && agent.lastStreamInputTokens > 0
3530
3720
  && agent.lastStreamInputTokens > agent.maxStreamTokens;
3531
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
+
3532
3789
  if (shouldEndTurn) {
3533
3790
  // endTurn: messages already stored above, cancel stream, reset to idle.
3534
3791
  if (currentState.stream) {
@@ -3553,6 +3810,16 @@ export class AgentFramework {
3553
3810
  // endTurn above).
3554
3811
  if (currentState.stream) {
3555
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();
3556
3823
  }
3557
3824
  agent.cancelStream();
3558
3825
  this.emitTrace({
@@ -3574,7 +3841,7 @@ export class AgentFramework {
3574
3841
  // messages (membrane ≥0.5.72) — appended after the tool_result
3575
3842
  // envelope so the next round of THIS turn hears them.
3576
3843
  const membraneResults = currentState.toolResults.map(tc =>
3577
- this.toMembraneToolResult(tc.id, tc.result, maxChars)
3844
+ this.toMembraneToolResult(tc.id, tc.result, maxChars, spilled.get(tc.id))
3578
3845
  );
3579
3846
  currentState.stream.provideToolResults(
3580
3847
  membraneResults,
@@ -4292,8 +4559,24 @@ export class AgentFramework {
4292
4559
  continue;
4293
4560
  }
4294
4561
 
4295
- // Skip if agent is busy (inferring, streaming, or waiting for tools)
4296
- 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') {
4297
4580
  // Re-queue requests, but warn if they've been pending too long
4298
4581
  const oldest = Math.min(...requests.map(r => r.timestamp));
4299
4582
  if (
@@ -4305,15 +4588,22 @@ export class AgentFramework {
4305
4588
  // (up to 100/sec after the scheduler backoff), adding avoidable work
4306
4589
  // precisely while the agent was already under pressure.
4307
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;
4308
4598
  this.emitTrace({
4309
4599
  type: 'inference:request_stale',
4310
4600
  agentName,
4311
- agentStatus: agent.state.status,
4601
+ agentStatus: shownStatus,
4312
4602
  requestCount: requests.length,
4313
4603
  oldestRequestAge: now - oldest,
4314
4604
  });
4315
4605
  console.error(
4316
- `[inference-stale] agent=${agentName} busy (${agent.state.status}) — ` +
4606
+ `[inference-stale] agent=${agentName} busy (${shownStatus}) — ` +
4317
4607
  `${requests.length} request(s) waiting ${Math.round((now - oldest) / 1000)}s`,
4318
4608
  );
4319
4609
  }
@@ -4637,6 +4927,85 @@ export class AgentFramework {
4637
4927
  }
4638
4928
 
4639
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
+
4640
5009
  // Record turn checkpoint before inference (only on first attempt, not retries)
4641
5010
  if (attempt === 0) {
4642
5011
  this.recordTurnCheckpoint(agent.name);
@@ -4777,6 +5146,9 @@ export class AgentFramework {
4777
5146
  compiledRequest,
4778
5147
  );
4779
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;
4780
5152
  } catch (error) {
4781
5153
  // The early typing indicator has no driveStream `finally` on this path —
4782
5154
  // a hook/compile/stream-setup failure must not leave "typing…" stuck.
@@ -4794,8 +5166,18 @@ export class AgentFramework {
4794
5166
  const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
4795
5167
  if (action.retry) {
4796
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.
4797
5171
  await this.startAgentStream(agent, trigger, attempt + 1);
4798
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
+ }
4799
5181
  this.settleAgent(agent.name, {
4800
5182
  stopReason: 'exhausted',
4801
5183
  speech: '',
@@ -4819,6 +5201,12 @@ export class AgentFramework {
4819
5201
  this.pushEvent(action.emit);
4820
5202
  }
4821
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;
4822
5210
  }
4823
5211
  }
4824
5212
 
@@ -4833,6 +5221,10 @@ export class AgentFramework {
4833
5221
  const startTime = Date.now();
4834
5222
  const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
4835
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);
4836
5228
  let hadToolCalls = false;
4837
5229
 
4838
5230
  // ---- Present-while-acting turn state ---------------------------------
@@ -5717,6 +6109,17 @@ export class AgentFramework {
5717
6109
  this.disposeConversationAgent(agent.name);
5718
6110
  }
5719
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
+
5720
6123
  // Flush any deferred messages (e.g. if stream failed while tools were pending)
5721
6124
  if (this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
5722
6125
  const deferred = this.deferredMessages.splice(0);
@@ -5731,8 +6134,23 @@ export class AgentFramework {
5731
6134
  * Execute a tool call and return the result.
5732
6135
  * Routes to the appropriate handler (module registry or MCPL).
5733
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.
5734
6145
  */
5735
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> {
5736
6154
  // Client-side programmatic tool calling for promise-based callers
5737
6155
  // (SubagentModule ephemerals). Keyed by callerAgentName so each ephemeral
5738
6156
  // gets its own interpreter state.
@@ -5753,6 +6171,27 @@ export class AgentFramework {
5753
6171
  }
5754
6172
  }
5755
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
+
5756
6195
  // Module tools
5757
6196
  return this.moduleRegistry.handleToolCall(call);
5758
6197
  }
@@ -5849,7 +6288,51 @@ export class AgentFramework {
5849
6288
  * results reach the running script and never the model context.
5850
6289
  */
5851
6290
  private async runCodeExecution(agentName: string, call: ToolCall): Promise<ToolResult> {
5852
- 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
+
5853
6336
  if (typeof input.code !== 'string' || input.code.trim() === '') {
5854
6337
  return {
5855
6338
  success: false,
@@ -5866,6 +6349,10 @@ export class AgentFramework {
5866
6349
  surface.map((t) => t.name).filter((name) => name !== CODE_EXECUTION_TOOL_NAME),
5867
6350
  );
5868
6351
 
6352
+ if (input.background === true) {
6353
+ return this.startBackgroundScript(agentName, input.code, injected);
6354
+ }
6355
+
5869
6356
  const runner = this.getOrCreateScriptRunner(agentName);
5870
6357
  this.scriptDeferredEndTurn.delete(agentName);
5871
6358
  const exec = await runner.exec(input.code, injected);
@@ -5879,6 +6366,291 @@ export class AgentFramework {
5879
6366
  };
5880
6367
  }
5881
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
+
5882
6654
  private getOrCreateScriptRunner(agentName: string): PyRunner {
5883
6655
  let runner = this.codeExecutionRunners.get(agentName);
5884
6656
  if (!runner) {
@@ -5924,9 +6696,10 @@ export class AgentFramework {
5924
6696
  return `Error: ${result.error ?? 'tool call failed'}`;
5925
6697
  }
5926
6698
  if (result.data === undefined) return '';
5927
- const agent = this.agents.get(agentName);
5928
- const maxChars = agent ? this.getMaxToolResultChars(agent) : undefined;
5929
- 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);
5930
6703
  }
5931
6704
 
5932
6705
  /**
@@ -5982,7 +6755,14 @@ export class AgentFramework {
5982
6755
  }
5983
6756
  }
5984
6757
 
5985
- 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 {
5986
6766
  if (afResult.isError) {
5987
6767
  return { toolUseId: callId, content: afResult.error ?? 'Unknown error', isError: true };
5988
6768
  }
@@ -5994,6 +6774,9 @@ export class AgentFramework {
5994
6774
  if (blocks) {
5995
6775
  return { toolUseId: callId, content: blocks, isError: false };
5996
6776
  }
6777
+ if (precomputed !== undefined) {
6778
+ return { toolUseId: callId, content: precomputed, isError: false };
6779
+ }
5997
6780
  // JSON.stringify returns the VALUE undefined (not a string) for undefined
5998
6781
  // input — a module tool returning `{ success: true }` with no data would
5999
6782
  // otherwise make `content.length` below throw a TypeError mid-turn.
@@ -6173,6 +6956,11 @@ export class AgentFramework {
6173
6956
  }
6174
6957
 
6175
6958
  // Route the agent's typed, allowlisted hot-settings surface.
6959
+ if (enrichedCall.name === 'utils') {
6960
+ this.dispatchUtilsToolCall(agentName, enrichedCall);
6961
+ return;
6962
+ }
6963
+
6176
6964
  if (enrichedCall.name === 'agent_settings') {
6177
6965
  this.dispatchAgentSettingsToolCall(agentName, enrichedCall);
6178
6966
  return;
@@ -6331,15 +7119,27 @@ export class AgentFramework {
6331
7119
 
6332
7120
  // Defer non-tool_result messages while a tool cycle is mid-flight
6333
7121
  // (pendingAssistantBlocks: preserves tool_use → tool_result adjacency)
6334
- // OR while the target agent has a live stream at all. A message stored
6335
- // mid-stream lands BEFORE the turn's assistant blocks in the window even
6336
- // though the live conversation never saw it — so the next compile
6337
- // diverges from the live prefix (prompt-cache bust) and the message
6338
- // misses mid-turn injection. Deferred messages flush at the agent's next
6339
- // tool boundary (where they are ALSO injected into the live stream —
6340
- // 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.
6341
7136
  const hasToolResult = content.some(b => b.type === 'tool_result');
6342
- 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
+ ) {
6343
7143
  this.deferredMessages.push({ participant, content, metadata });
6344
7144
  return '' as MessageId; // Deferred — flushed at the next boundary
6345
7145
  }
@@ -8113,7 +8913,7 @@ export class AgentFramework {
8113
8913
  this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input });
8114
8914
  const startTime = Date.now();
8115
8915
 
8116
- this.channelRegistry!.handleChannelToolCall(call.name, call.input)
8916
+ this.channelRegistry!.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName })
8117
8917
  .then((result) => {
8118
8918
  const durationMs = Date.now() - startTime;
8119
8919
  this.emitTrace({ type: 'tool:completed', module: 'channels', tool: call.name, callId: call.id, durationMs });
@@ -8340,6 +9140,38 @@ export class AgentFramework {
8340
9140
  if (typeof input.path !== 'string' || input.path.length === 0) {
8341
9141
  throw new Error('read_image: `path` (mount-prefixed) is required');
8342
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
+ }
8343
9175
  const read = await workspace.readBinary(input.path);
8344
9176
  if ('error' in read) throw new Error(read.error);
8345
9177
  const mediaType = sniffImageMediaType(read.data);
@@ -8349,9 +9181,6 @@ export class AgentFramework {
8349
9181
  '(png/jpeg/gif/webp — checked by magic bytes, not extension)',
8350
9182
  );
8351
9183
  }
8352
- // Anthropic rejects images >5MB (and >8000px); guard the hard byte
8353
- // limit here — resizing is out of scope without an image library.
8354
- const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
8355
9184
  if (read.data.byteLength > MAX_IMAGE_BYTES) {
8356
9185
  throw new Error(
8357
9186
  `read_image: "${input.path}" is ${read.data.byteLength} bytes — ` +
@@ -8525,6 +9354,83 @@ export class AgentFramework {
8525
9354
  })();
8526
9355
  }
8527
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
+
8528
9434
  private dispatchAgentSettingsToolCall(agentName: string, call: ToolCall): void {
8529
9435
  this.emitTrace({
8530
9436
  type: 'tool:started',