@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
@@ -157,6 +157,61 @@ const PROSE_HELP_TOOL = {
157
157
  };
158
158
  /** Strip the `server--` MCPL prefix from a tool name. */
159
159
  const bareToolName = (n) => n.split('--').pop();
160
+ /** The single slot behind which every rarely-used capability lives. Only
161
+ * advertised when at least one module contributes utilities — with none
162
+ * registered the surface is byte-identical to before this existed. */
163
+ const UTILS_TOOL = {
164
+ name: 'utils',
165
+ description: 'Rarely-used utilities, behind one slot instead of each costing a tool. ' +
166
+ '"list" names them all with one-liners; "describe" returns one utility\'s full ' +
167
+ 'input schema; "run" invokes it (args are validated against that schema — a ' +
168
+ 'miss bounces back with the schema and the specific error).',
169
+ inputSchema: {
170
+ type: 'object',
171
+ properties: {
172
+ action: { type: 'string', enum: ['list', 'describe', 'run'], description: 'What to do.' },
173
+ name: { type: 'string', description: 'Utility name from list (required for describe/run).' },
174
+ args: { type: 'object', description: "Arguments for run, per the utility's schema." },
175
+ },
176
+ required: ['action'],
177
+ },
178
+ };
179
+ /** First sentence (or line) of a description — what `utils list` shows. */
180
+ const utilityOneLiner = (description) => {
181
+ const line = description.split('\n')[0];
182
+ const dot = line.indexOf('. ');
183
+ return (dot >= 0 ? line.slice(0, dot + 1) : line).slice(0, 160);
184
+ };
185
+ /** Shallow validation of run args against a utility's declared inputSchema:
186
+ * required keys, primitive types, enums. Deliberately not a full JSON-Schema
187
+ * engine — handlers still hand-validate (they always did); this layer exists
188
+ * so an arg-shape miss teaches by bounce instead of failing deep in a handler
189
+ * with a less instructive message. Returns an error string or null. */
190
+ function validateUtilityArgs(input, schema) {
191
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
192
+ return 'args must be an object';
193
+ }
194
+ const obj = input;
195
+ for (const req of schema.required ?? []) {
196
+ if (!(req in obj) || obj[req] === undefined)
197
+ return `missing required "${req}"`;
198
+ }
199
+ for (const [key, value] of Object.entries(obj)) {
200
+ const param = schema.properties?.[key];
201
+ if (!param || value === undefined)
202
+ continue; // unknown keys: handler's business
203
+ if (param.enum && !param.enum.includes(value)) {
204
+ return `"${key}" must be one of ${JSON.stringify(param.enum)}`;
205
+ }
206
+ if (param.type) {
207
+ const actual = Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value;
208
+ const expected = param.type === 'integer' ? 'number' : param.type;
209
+ if (actual !== expected)
210
+ return `"${key}" must be ${param.type}, got ${actual}`;
211
+ }
212
+ }
213
+ return null;
214
+ }
160
215
  import { CheckpointManager } from './mcpl/checkpoint-manager.js';
161
216
  import { isToolAllowed } from './mcpl/tool-policy.js';
162
217
  import { EventGate } from './gate/event-gate.js';
@@ -398,6 +453,24 @@ export class AgentFramework {
398
453
  // Messages deferred while an agent is waiting_for_tools (to preserve
399
454
  // tool_use → tool_result adjacency required by the Anthropic API).
400
455
  deferredMessages = [];
456
+ // Turn-alive markers (2026-07-31 Mythos phantom-skip incident): agentName →
457
+ // token of the turn currently in progress. Set at startAgentStream ENTRY —
458
+ // before hooks, compile, and stream setup, all of which await — and cleared
459
+ // token-matched when that turn's teardown finishes (driveStream finally /
460
+ // terminal failure path), so a successor turn's marker survives a
461
+ // predecessor's late finally. A turn is alive from dequeue to settled
462
+ // teardown: strictly longer than `activeStreams` membership, which begins
463
+ // only after the stream exists. Guarding addMessage on stream-liveness
464
+ // alone left the compile window open — a deferred message flushed by the
465
+ // previous turn's finally landed in the window MID-COMPILE of the turn its
466
+ // own wake had started: positioned before the new turn's blocks yet absent
467
+ // from its wire request. The agent then "skipped" a message it never saw
468
+ // (falsified history — it later confabulated an apology for the skip), and
469
+ // every subsequent compile diverged from the live prefix at that message
470
+ // (KV bust). Invariant: nothing enters the window between a turn's dequeue
471
+ // and its settle except that turn's own blocks.
472
+ activeTurnTokens = new Map();
473
+ nextTurnToken = 1;
401
474
  // Undo/redo state
402
475
  turnCounters = new Map(); // agentName → next turnIndex
403
476
  redoStacks = new Map(); // agentName → redo entries
@@ -438,6 +511,15 @@ export class AgentFramework {
438
511
  * deferred and applied to the final code_execution result instead of
439
512
  * cancelling the stream mid-script (which would wedge the turn). */
440
513
  scriptDeferredEndTurn = new Set();
514
+ /** Background (daemon) scripts: model-authored watchers that outlive their
515
+ * spawning turn. Each gets a DEDICATED PyRunner; wake_agent() injects a
516
+ * provenance envelope + payload and requests inference. Keyed by script id. */
517
+ backgroundScripts = new Map();
518
+ backgroundScriptCounter = 0;
519
+ /** Ephemeral per-agent override of the tool-result inline cap (chars).
520
+ * Set via agent_settings `tool_result_inline_max_chars`; NOT persisted —
521
+ * the lift is meant as a temporary gate, reverts on restart/reset. */
522
+ toolResultInlineMaxCharsOverride = new Map();
441
523
  mcplTools = [];
442
524
  mcplToolRefreshInFlight = false;
443
525
  mcplToolRefreshPending = false;
@@ -495,7 +577,12 @@ export class AgentFramework {
495
577
  queryMessages: (filter) => this.queryMessages(filter),
496
578
  pushEvent: (event) => this.pushEvent(event),
497
579
  onTrace: (listener) => this.onTrace(listener),
498
- callTool: (call) => this.executeToolCall(call),
580
+ // The ONLY entry that confers module origin — a private closure the
581
+ // framework hands to its own module registry. The public
582
+ // executeToolCall() is shared with model/ephemeral callers and always
583
+ // stamps agent origin (see there).
584
+ callTool: (call) => this.executeToolCallFrom(call, { kind: 'module' }),
585
+ notifyOps: (kind, agentName, message, data) => this.notifyOps(kind, agentName, message, data),
499
586
  });
500
587
  }
501
588
  /**
@@ -745,6 +832,17 @@ export class AgentFramework {
745
832
  runner.dispose();
746
833
  }
747
834
  this.codeExecutionRunners.clear();
835
+ // Background daemons die with the process (documented limitation) —
836
+ // mark cancelled FIRST so their settle path stays silent (no crash-wake
837
+ // into a framework that is shutting down).
838
+ for (const record of this.backgroundScripts.values()) {
839
+ if (record.status === 'running') {
840
+ record.cancelled = true;
841
+ record.status = 'cancelled';
842
+ }
843
+ record.runner.dispose();
844
+ }
845
+ this.backgroundScripts.clear();
748
846
  // Cancel all active streams
749
847
  for (const agent of this.agents.values()) {
750
848
  if (agent.state.status === 'streaming' ||
@@ -1003,6 +1101,8 @@ export class AgentFramework {
1003
1101
  : [];
1004
1102
  return [
1005
1103
  ...moduleTools,
1104
+ // Utilities collapse into one slot; absent entirely when none exist.
1105
+ ...(this.moduleRegistry.getAllUtilities().length > 0 ? [UTILS_TOOL] : []),
1006
1106
  ...this.mcplTools,
1007
1107
  ...channelTools,
1008
1108
  ...gateTools,
@@ -1038,6 +1138,15 @@ export class AgentFramework {
1038
1138
  collectAgentSettingsExtensions() {
1039
1139
  const result = new Map();
1040
1140
  const taken = new Set(AgentFramework.AGENT_SETTINGS_CORE_KEYS);
1141
+ // Framework-owned extension: the tool-result inline cap lift. Registered
1142
+ // through the same extension surface modules use so get/update/reset all
1143
+ // work with zero extra plumbing. EPHEMERAL by design (a temporary gate —
1144
+ // reverts on restart), unlike module extensions which persist their own.
1145
+ {
1146
+ const ext = this.spillSettingsExtension();
1147
+ ext.keys.forEach((k) => taken.add(k));
1148
+ result.set('_framework', ext);
1149
+ }
1041
1150
  for (const module of this.moduleRegistry.getAllModules()) {
1042
1151
  const ext = module.getAgentSettingsExtension?.();
1043
1152
  if (!ext)
@@ -1052,6 +1161,39 @@ export class AgentFramework {
1052
1161
  }
1053
1162
  return result;
1054
1163
  }
1164
+ /** agent_settings extension for the spill gate (see spillOrTruncate). */
1165
+ spillSettingsExtension() {
1166
+ return {
1167
+ properties: {
1168
+ tool_result_inline_max_chars: {
1169
+ type: 'number',
1170
+ description: 'Inline size cap (chars) for tool results and background-script wake payloads. ' +
1171
+ 'Content over the cap is written to a workspace file under tool-results/ and ' +
1172
+ 'replaced by a truncated preview + file reference. Raise it temporarily when you ' +
1173
+ 'genuinely want a large result inline; reset restores the default (derived from ' +
1174
+ 'your strategy). Ephemeral — reverts on host restart.',
1175
+ },
1176
+ },
1177
+ keys: ['tool_result_inline_max_chars'],
1178
+ get: (agentName) => ({
1179
+ tool_result_inline_max_chars: this.toolResultInlineMaxCharsOverride.get(agentName) ?? null,
1180
+ }),
1181
+ update: (agentName, patch) => {
1182
+ const n = Number(patch.tool_result_inline_max_chars);
1183
+ if (!Number.isFinite(n) || n < 1000) {
1184
+ throw new Error('tool_result_inline_max_chars must be a number >= 1000');
1185
+ }
1186
+ this.toolResultInlineMaxCharsOverride.set(agentName, Math.floor(n));
1187
+ return { tool_result_inline_max_chars: Math.floor(n) };
1188
+ },
1189
+ reset: (agentName, keys) => {
1190
+ if (!keys || keys.includes('tool_result_inline_max_chars')) {
1191
+ this.toolResultInlineMaxCharsOverride.delete(agentName);
1192
+ }
1193
+ return { tool_result_inline_max_chars: null };
1194
+ },
1195
+ };
1196
+ }
1055
1197
  captureInferenceToolSnapshot(agent) {
1056
1198
  return {
1057
1199
  sameRoundThinkTextPolicy: agent.getEffectiveSameRoundThinkTextPolicy(),
@@ -1564,6 +1706,12 @@ export class AgentFramework {
1564
1706
  // ephemeral agents leave checkpoint-tree keys and diagnostics map
1565
1707
  // entries behind for the life of the store/session.
1566
1708
  this.evictTurnCheckpoints(agent.name);
1709
+ // Turn-alive marker, same spawn-and-dispose logic: a stalled ephemeral
1710
+ // (watchdog fired, driveStream's terminal event lost) would strand its
1711
+ // token forever — no wedge (ephemeral names are unique per run) but
1712
+ // unbounded map growth. Blind delete is safe: the agent is already out
1713
+ // of the map, and a late driveStream finally token-match no-ops.
1714
+ this.activeTurnTokens.delete(agent.name);
1567
1715
  }
1568
1716
  }
1569
1717
  /**
@@ -2847,6 +2995,18 @@ export class AgentFramework {
2847
2995
  }
2848
2996
  // Compute truncation limit from agent's strategy (maxMessageTokens * 4 chars)
2849
2997
  const maxChars = this.getMaxToolResultChars(agent);
2998
+ // Oversized results spill to a workspace file (truncated preview +
2999
+ // file reference) instead of being blind-truncated. Computed ONCE
3000
+ // per call and reused for both the history copy and the live wire
3001
+ // copy below — the two must stay byte-matched (the window stores
3002
+ // what the membrane sends; divergence breaks the compile prefix).
3003
+ const spilled = new Map();
3004
+ const dateLabel = new Date().toISOString().slice(0, 10);
3005
+ for (const tc of currentState.toolResults) {
3006
+ if (tc.result.isError)
3007
+ continue;
3008
+ spilled.set(tc.id, await this.spillOrTruncate(toolResultDataToHistoryString(tc.result.data, undefined), maxChars, `${dateLabel}-${tc.id}`, agent.name));
3009
+ }
2850
3010
  // Store tool results as a user message (tool_result blocks).
2851
3011
  // Use the history serializer so MCP image blocks become a short
2852
3012
  // `[image: type, size]` placeholder instead of megabytes of base64
@@ -2856,7 +3016,7 @@ export class AgentFramework {
2856
3016
  toolUseId: tc.id,
2857
3017
  content: tc.result.isError
2858
3018
  ? tc.result.error ?? 'Unknown error'
2859
- : toolResultDataToHistoryString(tc.result.data, maxChars),
3019
+ : spilled.get(tc.id) ?? toolResultDataToHistoryString(tc.result.data, maxChars),
2860
3020
  isError: tc.result.isError,
2861
3021
  }));
2862
3022
  agent.getContextManager().addMessage('user', toolResultContent);
@@ -2923,6 +3083,66 @@ export class AgentFramework {
2923
3083
  const overBudget = currentState.stream
2924
3084
  && agent.lastStreamInputTokens > 0
2925
3085
  && agent.lastStreamInputTokens > agent.maxStreamTokens;
3086
+ // Addressed re-pin (2026-07-31 Mythos misroutes, antra-ratified):
3087
+ // when someone ADDRESSES the agent mid-turn from another channel
3088
+ // (mention / reply / DM — the same chat:addressed signal the
3089
+ // turn-START batch policy prefers), the agent's subsequent plain
3090
+ // prose almost always answers THEM. Six recorded misroutes
3091
+ // delivered those answers to the stale frozen locus instead (the
3092
+ // LabClaude answer into #hospital_commons mid-examination; the
3093
+ // hospital scene into antra's DM). Re-pin the turn locus to the
3094
+ // LAST addressed conversational injection, at segment granularity:
3095
+ // prose routed in earlier rounds already went to the old pin,
3096
+ // which was correct when it was delivered. Ambient chatter,
3097
+ // reactions, and system markers still cannot move the pin — the
3098
+ // 2026-07-21 Cairn hijack protection is unchanged; this extends
3099
+ // the ratified addressed-outranks-ambient principle from turn
3100
+ // start to boundaries. Locus mode only (explicit mode has no pin).
3101
+ // Skipped on endTurn/overBudget: no further prose this stream, and
3102
+ // a stale "for this turn" notice would be pure noise.
3103
+ //
3104
+ // The notice rides the SAME injection batch — stored right after
3105
+ // the flushed messages and appended to midTurnInjections — so the
3106
+ // window byte-matches the wire (short event-style notice: the
3107
+ // classifier-safe D-class from the 2026-07-24 ablation). It also
3108
+ // updates lastAnnouncedLocus so the next turn's announce-on-change
3109
+ // diffs against what the agent was actually last told.
3110
+ if (agent.proseRouting !== 'explicit' &&
3111
+ !shouldEndTurn && !overBudget && currentState.stream) {
3112
+ const lastAddressed = [...midTurnInjections].reverse().find((inj) => {
3113
+ const m = inj.metadata;
3114
+ return (isConversationalInjection(inj.metadata) &&
3115
+ typeof m?.channelId === 'string' &&
3116
+ isAddressedMessage(m.tags, m));
3117
+ });
3118
+ const newLocus = lastAddressed?.metadata
3119
+ ?.channelId;
3120
+ if (newLocus && this.turnLocusPins.get(agent.name) !== newLocus) {
3121
+ const prevPin = this.turnLocusPins.get(agent.name) ?? null;
3122
+ this.turnLocusPins.set(agent.name, newLocus);
3123
+ this.lastAnnouncedLocus.set(agent.name, newLocus);
3124
+ const label = this.channelRegistry?.getDescriptor(newLocus)?.label;
3125
+ const shown = label && label !== newLocus
3126
+ ? `${label.startsWith('#') ? label : `#${label}`} (${newLocus})`
3127
+ : newLocus;
3128
+ const noticeContent = [{
3129
+ type: 'text',
3130
+ text: `[routing] You were just addressed from ${shown} — your plain ` +
3131
+ 'speech now lands there for the rest of this turn. Other ' +
3132
+ 'channels need an explicit send tool.',
3133
+ }];
3134
+ const noticeMeta = { system: true, kind: 'routing-notice' };
3135
+ try {
3136
+ const id = agent.getContextManager().addMessage('user', noticeContent, noticeMeta);
3137
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
3138
+ midTurnInjections.push({ participant: 'user', content: noticeContent, metadata: noticeMeta });
3139
+ console.error(`[routing] ${agent.name}: mid-turn addressed re-pin ${prevPin ?? '(none)'} -> ${newLocus}`);
3140
+ }
3141
+ catch (err) {
3142
+ console.error('mid-turn locus re-pin: failed to record routing notice:', err);
3143
+ }
3144
+ }
3145
+ }
2926
3146
  if (shouldEndTurn) {
2927
3147
  // endTurn: messages already stored above, cancel stream, reset to idle.
2928
3148
  if (currentState.stream) {
@@ -2948,6 +3168,16 @@ export class AgentFramework {
2948
3168
  // endTurn above).
2949
3169
  if (currentState.stream) {
2950
3170
  this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart');
3171
+ // Cancel the LIVE stream object directly (like the endTurn
3172
+ // branch above): at a tool boundary the state machine reads
3173
+ // 'ready', and Agent.cancelStream only signals the stream from
3174
+ // 'streaming'/'waiting_for_tools' — so the old call left the
3175
+ // provider stream parked forever awaiting tool results that
3176
+ // would never come (zombie: driveStream's finally never ran,
3177
+ // its activeStreams entry survived until the restart's stream
3178
+ // overwrote it). Harmless by accident before turn-alive
3179
+ // tracking; a genuine teardown leak after it.
3180
+ currentState.stream.cancel();
2951
3181
  }
2952
3182
  agent.cancelStream();
2953
3183
  this.emitTrace({
@@ -2969,7 +3199,7 @@ export class AgentFramework {
2969
3199
  // Mid-turn messages collected above ride along as injected user
2970
3200
  // messages (membrane ≥0.5.72) — appended after the tool_result
2971
3201
  // envelope so the next round of THIS turn hears them.
2972
- const membraneResults = currentState.toolResults.map(tc => this.toMembraneToolResult(tc.id, tc.result, maxChars));
3202
+ const membraneResults = currentState.toolResults.map(tc => this.toMembraneToolResult(tc.id, tc.result, maxChars, spilled.get(tc.id)));
2973
3203
  currentState.stream.provideToolResults(membraneResults, midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections } : undefined);
2974
3204
  agent.setStreaming(currentState.stream);
2975
3205
  this.emitTrace({
@@ -3576,8 +3806,24 @@ export class AgentFramework {
3576
3806
  console.error(`[inference-dropped] agent=${agentName} reason=agent_not_found requests=${requests.length}`);
3577
3807
  continue;
3578
3808
  }
3579
- // Skip if agent is busy (inferring, streaming, or waiting for tools)
3580
- if (agent.state.status === 'inferring' || agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') {
3809
+ // Skip if agent is busy (inferring, streaming, or waiting for tools) —
3810
+ // or if a turn is alive at all (activeTurnTokens): the state machine
3811
+ // goes idle at endTurn's reset while driveStream teardown is still
3812
+ // pending, and a successor turn dequeued in that gap compiles while the
3813
+ // predecessor's deferred flush can still write (2026-07-31 Mythos).
3814
+ // Turn-alive spans dequeue → settled teardown; wakes wait it out.
3815
+ //
3816
+ // EXCEPT a context-budget restart: it CONTINUES the turn whose token is
3817
+ // held — queueing it behind its own turn's teardown is a deadlock when
3818
+ // that teardown lags (the cancelled stream's `aborted` event is
3819
+ // asynchronous and, historically, not even guaranteed — see the
3820
+ // overBudget branch). The restart's startAgentStream overwrites the
3821
+ // token; the predecessor frame's late finally no-ops via token-match.
3822
+ // This mirrors how the restart has always overwritten activeStreams
3823
+ // rather than waiting for the old stream's teardown.
3824
+ const isBudgetRestart = requests.some((r) => r.reason === 'context_budget_restart');
3825
+ const turnAlive = !isBudgetRestart && this.activeTurnTokens.has(agentName);
3826
+ if (turnAlive || agent.state.status === 'inferring' || agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') {
3581
3827
  // Re-queue requests, but warn if they've been pending too long
3582
3828
  const oldest = Math.min(...requests.map(r => r.timestamp));
3583
3829
  if (now - oldest > STALE_REQUEST_MS &&
@@ -3587,14 +3833,21 @@ export class AgentFramework {
3587
3833
  // (up to 100/sec after the scheduler backoff), adding avoidable work
3588
3834
  // precisely while the agent was already under pressure.
3589
3835
  this.staleWarnAt.set(agentName, now);
3836
+ // Surface turn-alive separately from the state machine: status can
3837
+ // read 'idle' while a turn's teardown is pending, and a LEAKED turn
3838
+ // token would look exactly like this — permanently requeued wakes.
3839
+ // This line is the wedge's tell (idle+turn-alive, forever).
3840
+ const shownStatus = turnAlive && agent.state.status === 'idle'
3841
+ ? 'idle+turn-alive'
3842
+ : agent.state.status;
3590
3843
  this.emitTrace({
3591
3844
  type: 'inference:request_stale',
3592
3845
  agentName,
3593
- agentStatus: agent.state.status,
3846
+ agentStatus: shownStatus,
3594
3847
  requestCount: requests.length,
3595
3848
  oldestRequestAge: now - oldest,
3596
3849
  });
3597
- console.error(`[inference-stale] agent=${agentName} busy (${agent.state.status}) — ` +
3850
+ console.error(`[inference-stale] agent=${agentName} busy (${shownStatus}) — ` +
3598
3851
  `${requests.length} request(s) waiting ${Math.round((now - oldest) / 1000)}s`);
3599
3852
  }
3600
3853
  this.pendingRequests.push(...requests);
@@ -3897,6 +4150,75 @@ export class AgentFramework {
3897
4150
  }
3898
4151
  }
3899
4152
  async startAgentStream(agent, trigger, attempt = 0) {
4153
+ // Mark the turn alive before ANYTHING awaits (hooks, compile, stream
4154
+ // setup): from here until this turn's teardown, cross-turn writers defer
4155
+ // (addMessage guard) instead of appending — nothing may enter the window
4156
+ // between a turn's dequeue and its settle except the turn's own blocks.
4157
+ // A retry re-enters with a fresh token; teardown clears token-matched.
4158
+ //
4159
+ // The try/finally is the leak-proofing: a token whose owner never clears
4160
+ // it permanently requeues every wake for the agent (the 'idle+turn-alive'
4161
+ // wedge — worse than any single lost turn). Ownership is handed off
4162
+ // exactly once, to driveStream, the moment the stream handle is
4163
+ // registered; on every other exit — including a throw from the turn-start
4164
+ // flush, recordTurnCheckpoint, or locus resolution, none of which the
4165
+ // inner catch covers (ENOSPC-class store writes live there) — the finally
4166
+ // clears our own token. Token-matched, so when a retry frame has replaced
4167
+ // the token, or the terminal-failure branch already deleted it, this
4168
+ // no-ops instead of clobbering a successor's marker.
4169
+ const turnToken = this.nextTurnToken++;
4170
+ this.activeTurnTokens.set(agent.name, turnToken);
4171
+ let tokenHandedOff = false;
4172
+ try {
4173
+ tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken);
4174
+ }
4175
+ finally {
4176
+ if (!tokenHandedOff && this.activeTurnTokens.get(agent.name) === turnToken) {
4177
+ this.activeTurnTokens.delete(agent.name);
4178
+ }
4179
+ }
4180
+ }
4181
+ /**
4182
+ * Body of startAgentStream, split out so the caller's try/finally owns the
4183
+ * turn token unconditionally. Returns true iff the token was handed off to
4184
+ * driveStream (whose finally then owns clearing it).
4185
+ */
4186
+ async beginAgentTurn(agent, trigger, attempt, turnToken) {
4187
+ // Flush messages deferred during the PREVIOUS turn — before the
4188
+ // checkpoint, the locus announcement, and the compile — so a turn started
4189
+ // by a queued wake actually CONTAINS the message that woke it. (2026-07-31
4190
+ // Mythos: a DM's wake fired a turn whose compile ran before the deferred
4191
+ // DM flushed; the model saw only the routing-shift notice, reasonably
4192
+ // skipped, and the DM then landed before the skip in the window — false
4193
+ // "saw it and ignored it" history plus KV divergence. Flushed here, the
4194
+ // payload rides its own wake, and the notice/payload can never split.)
4195
+ // Primary agent only — deferred messages are framework-level, meant for
4196
+ // the main conversation (same target rule as the tool-boundary flush).
4197
+ // Fresh turns only: a context-budget restart continues the same logical
4198
+ // turn, and flushing there would insert messages between its rounds.
4199
+ // Before recordTurnCheckpoint: these are the turn's inputs, not its
4200
+ // products — undoing the turn must not destroy them.
4201
+ if (attempt === 0 &&
4202
+ trigger?.reason !== 'context_budget_restart' &&
4203
+ this.deferredMessages.length > 0) {
4204
+ const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent;
4205
+ if (target === agent) {
4206
+ const deferred = this.deferredMessages.splice(0);
4207
+ for (const msg of deferred) {
4208
+ // Per-message try/catch (mirrors announceLocusIfChanged): one
4209
+ // poison message — or a transient store-write failure — must not
4210
+ // abort the turn or drop the messages behind it in the queue.
4211
+ try {
4212
+ const id = agent.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
4213
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'deferred-flush:turn-start' });
4214
+ }
4215
+ catch (err) {
4216
+ console.error(`[deferred-flush] turn-start flush failed to store a message for ${agent.name} ` +
4217
+ `(participant=${msg.participant}):`, err);
4218
+ }
4219
+ }
4220
+ }
4221
+ }
3900
4222
  // Record turn checkpoint before inference (only on first attempt, not retries)
3901
4223
  if (attempt === 0) {
3902
4224
  this.recordTurnCheckpoint(agent.name);
@@ -4023,6 +4345,9 @@ export class AgentFramework {
4023
4345
  const { stream, request: compiledRequest } = await agent.startStreamWithInjections(tools, injections);
4024
4346
  const handle = this.driveStream(agent, stream, requestSnapshot, trigger, attempt, compiledRequest);
4025
4347
  this.activeStreams.set(agent.name, handle);
4348
+ // Handoff: driveStream captured the token in its synchronous prefix;
4349
+ // its finally now owns clearing it. The caller's finally must not.
4350
+ return true;
4026
4351
  }
4027
4352
  catch (error) {
4028
4353
  // The early typing indicator has no driveStream `finally` on this path —
@@ -4040,9 +4365,19 @@ export class AgentFramework {
4040
4365
  const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
4041
4366
  if (action.retry) {
4042
4367
  await new Promise((resolve) => setTimeout(resolve, action.delayMs));
4368
+ // The retry re-enters startAgentStream, which replaces this frame's
4369
+ // turn token with its own; cleanup belongs to the innermost frame.
4043
4370
  await this.startAgentStream(agent, trigger, attempt + 1);
4044
4371
  }
4045
4372
  else {
4373
+ // Terminal failure with no driveStream: this frame owns teardown.
4374
+ // Token-matched (like the finally in driveStream) so a stale frame
4375
+ // can never clear a successor turn's marker. A leaked token would
4376
+ // permanently requeue every wake for this agent (same wedge shape as
4377
+ // the eventGate `inferring` leak) — every exit path must clear it.
4378
+ if (this.activeTurnTokens.get(agent.name) === turnToken) {
4379
+ this.activeTurnTokens.delete(agent.name);
4380
+ }
4046
4381
  this.settleAgent(agent.name, {
4047
4382
  stopReason: 'exhausted',
4048
4383
  speech: '',
@@ -4066,12 +4401,22 @@ export class AgentFramework {
4066
4401
  this.pushEvent(action.emit);
4067
4402
  }
4068
4403
  }
4404
+ // No handoff on either failure branch: the terminal branch deleted the
4405
+ // token above; the retry branch's recursive startAgentStream replaced
4406
+ // it with the inner frame's token. Returning false lets the caller's
4407
+ // token-matched finally no-op in both cases (and actually clear it if
4408
+ // something above threw before either branch ran).
4409
+ return false;
4069
4410
  }
4070
4411
  }
4071
4412
  async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest) {
4072
4413
  const startTime = Date.now();
4073
4414
  const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
4074
4415
  const myStreamId = agent.streamId;
4416
+ // This turn's alive-marker, set at startAgentStream entry. Safe to read
4417
+ // from the map here: the turn-alive busy check in processInferenceRequests
4418
+ // means no successor turn can have replaced it while we compiled.
4419
+ const myTurnToken = this.activeTurnTokens.get(agent.name);
4075
4420
  let hadToolCalls = false;
4076
4421
  // ---- Present-while-acting turn state ---------------------------------
4077
4422
  // Output locus for the WHOLE logical turn: frozen in startAgentStream
@@ -4872,6 +5217,16 @@ export class AgentFramework {
4872
5217
  if (this.closingConversationAgents.has(agent.name)) {
4873
5218
  this.disposeConversationAgent(agent.name);
4874
5219
  }
5220
+ // The turn is torn down — release the turn-alive marker BEFORE the
5221
+ // deferred flush below, so the flush appends at the settled tail.
5222
+ // Token-matched: if a successor turn already owns the agent (endTurn
5223
+ // reset the state machine before this finally ran — the 2026-07-31
5224
+ // interleave), leave its marker alone; the guarded addMessage below
5225
+ // then re-defers, and the successor's own turn-start flush / boundary
5226
+ // injection / teardown delivers the messages at a correct position.
5227
+ if (myTurnToken !== undefined && this.activeTurnTokens.get(agent.name) === myTurnToken) {
5228
+ this.activeTurnTokens.delete(agent.name);
5229
+ }
4875
5230
  // Flush any deferred messages (e.g. if stream failed while tools were pending)
4876
5231
  if (this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
4877
5232
  const deferred = this.deferredMessages.splice(0);
@@ -4885,8 +5240,22 @@ export class AgentFramework {
4885
5240
  * Execute a tool call and return the result.
4886
5241
  * Routes to the appropriate handler (module registry or MCPL).
4887
5242
  * Used by SubagentModule to dispatch tool calls for ephemeral agents.
5243
+ *
5244
+ * This public entry always carries AGENT origin. Ephemeral/promise-based
5245
+ * callers are models, and provenance must identify which internal path a
5246
+ * call entered through — a public method shared with model callers can
5247
+ * never confer module trust, whatever the input claims. Module origin
5248
+ * exists only through the private closure handed to ModuleRegistry
5249
+ * (ctx.callTool → executeToolCallFrom with {kind:'module'}). Callers that
5250
+ * don't identify themselves fail safe to agent semantics too.
4888
5251
  */
4889
5252
  async executeToolCall(call) {
5253
+ return this.executeToolCallFrom(call, {
5254
+ kind: 'agent',
5255
+ agentName: call.callerAgentName ?? '__ephemeral__',
5256
+ });
5257
+ }
5258
+ async executeToolCallFrom(call, origin) {
4890
5259
  // Client-side programmatic tool calling for promise-based callers
4891
5260
  // (SubagentModule ephemerals). Keyed by callerAgentName so each ephemeral
4892
5261
  // gets its own interpreter state.
@@ -4905,6 +5274,25 @@ export class AgentFramework {
4905
5274
  }
4906
5275
  }
4907
5276
  }
5277
+ // Synthesized channel tools. This route was missing: when
5278
+ // subscription-GC moved off the retired MCPL unsubscribe tool onto the
5279
+ // generic channel_close (discord-mcpl b095a9f), module-originated
5280
+ // closes started failing downstream as "Invalid tool name format" —
5281
+ // the janitor had been silently broken since. `origin` is trusted
5282
+ // dispatch context established at the call boundary above, not
5283
+ // inferred from which method was used: only the ModuleRegistry closure
5284
+ // carries module provenance (see handleToolClose). The agent-only
5285
+ // synthesized verbs (think, skip_reply) are deliberately not routed
5286
+ // here.
5287
+ if (call.name.startsWith('channel_') && this.channelRegistry) {
5288
+ return this.channelRegistry.handleChannelToolCall(call.name, call.input, origin);
5289
+ }
5290
+ // The utils meta-tool — programmatic callers (code_execution scripts,
5291
+ // ephemerals, ModuleContext.callTool) reach utilities the same way the
5292
+ // model does.
5293
+ if (call.name === 'utils') {
5294
+ return this.handleUtilsToolCall(call.callerAgentName ?? '__ephemeral__', call);
5295
+ }
4908
5296
  // Module tools
4909
5297
  return this.moduleRegistry.handleToolCall(call);
4910
5298
  }
@@ -5000,6 +5388,43 @@ export class AgentFramework {
5000
5388
  */
5001
5389
  async runCodeExecution(agentName, call) {
5002
5390
  const input = (call.input ?? {});
5391
+ // Management surface: the agent's own daemon fleet is inspectable and
5392
+ // killable through the same tool that spawns it.
5393
+ if (input.action === 'list') {
5394
+ const scripts = [...this.backgroundScripts.values()]
5395
+ .filter((s) => s.agentName === agentName)
5396
+ .map((s) => ({
5397
+ script_id: s.id,
5398
+ status: s.status,
5399
+ started_at: new Date(s.startedAt).toISOString(),
5400
+ wakes: s.wakes,
5401
+ last_wake_at: s.lastWakeAt ? new Date(s.lastWakeAt).toISOString() : null,
5402
+ log: s.logPath,
5403
+ }));
5404
+ return { success: true, data: { background_scripts: scripts } };
5405
+ }
5406
+ if (input.action === 'cancel') {
5407
+ if (typeof input.script_id !== 'string') {
5408
+ return { success: false, error: 'cancel requires `script_id`', isError: true };
5409
+ }
5410
+ const record = this.backgroundScripts.get(input.script_id);
5411
+ if (!record || record.agentName !== agentName) {
5412
+ return { success: false, error: `no background script '${input.script_id}'`, isError: true };
5413
+ }
5414
+ if (record.status === 'running') {
5415
+ record.cancelled = true;
5416
+ record.status = 'cancelled';
5417
+ record.runner.abort('cancelled by agent');
5418
+ record.runner.dispose();
5419
+ }
5420
+ return {
5421
+ success: true,
5422
+ data: { script_id: record.id, status: record.status, log: record.logPath },
5423
+ };
5424
+ }
5425
+ if (input.action !== undefined && input.action !== 'run') {
5426
+ return { success: false, error: `unknown action ${JSON.stringify(input.action)}`, isError: true };
5427
+ }
5003
5428
  if (typeof input.code !== 'string' || input.code.trim() === '') {
5004
5429
  return {
5005
5430
  success: false,
@@ -5012,6 +5437,9 @@ export class AgentFramework {
5012
5437
  ? this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name))
5013
5438
  : this.getAllTools(); // ephemeral agents: full surface, matching executeToolCall
5014
5439
  const injected = buildInjectedTools(surface.map((t) => t.name).filter((name) => name !== CODE_EXECUTION_TOOL_NAME));
5440
+ if (input.background === true) {
5441
+ return this.startBackgroundScript(agentName, input.code, injected);
5442
+ }
5015
5443
  const runner = this.getOrCreateScriptRunner(agentName);
5016
5444
  this.scriptDeferredEndTurn.delete(agentName);
5017
5445
  const exec = await runner.exec(input.code, injected);
@@ -5023,6 +5451,255 @@ export class AgentFramework {
5023
5451
  ...(endTurn ? { endTurn: true } : {}),
5024
5452
  };
5025
5453
  }
5454
+ // ---------------------------------------------------------------------------
5455
+ // Background (daemon) scripts — model-authored watchers that outlive the turn
5456
+ // ---------------------------------------------------------------------------
5457
+ /**
5458
+ * Start a background script: dedicated interpreter, output journaled to a
5459
+ * workspace file, wake_agent() available in-script. The tool result
5460
+ * returns immediately; the script runs until it ends, hits the lifetime
5461
+ * ceiling, or is cancelled. Scripts DIE WITH THE HOST PROCESS — that
5462
+ * limitation is stated in the tool description so agents can calibrate
5463
+ * how much to rely on an armed wake.
5464
+ */
5465
+ startBackgroundScript(agentName, code, injected) {
5466
+ // v1: primary-agent-only. A conversation fork's or ephemeral's daemon
5467
+ // would outlive its owner and its wake would land in the primary
5468
+ // conversation anyway — refuse loudly instead of surprising anyone.
5469
+ if (this.primaryAgentName && agentName !== this.primaryAgentName) {
5470
+ return {
5471
+ success: false,
5472
+ error: 'background scripts are available to the primary agent only',
5473
+ isError: true,
5474
+ };
5475
+ }
5476
+ const cfg = this.codeExecutionConfig;
5477
+ const maxScripts = cfg?.maxBackgroundScripts ?? 3;
5478
+ const runningCount = [...this.backgroundScripts.values()]
5479
+ .filter((s) => s.agentName === agentName && s.status === 'running').length;
5480
+ if (runningCount >= maxScripts) {
5481
+ return {
5482
+ success: false,
5483
+ error: `background script limit reached (${maxScripts} running). ` +
5484
+ 'Use code_execution {"action": "list"} to inspect and {"action": "cancel", "script_id": ...} to free a slot.',
5485
+ isError: true,
5486
+ };
5487
+ }
5488
+ const scriptId = `bg-${++this.backgroundScriptCounter}`;
5489
+ const lifetimeMs = cfg?.backgroundMaxLifetimeMs ?? 86_400_000;
5490
+ // Journal: a file under the agent's first read-write workspace mount so
5491
+ // their existing read/grep/shell tools work on it. Python appends
5492
+ // directly to the materialized path; watch-mode mounts sync it back to
5493
+ // the chronicle tree on their usual cadence.
5494
+ let logPath = null;
5495
+ let logAbsPath = null;
5496
+ const workspace = this.getWorkspaceModule();
5497
+ if (workspace) {
5498
+ const mountName = this.firstWritableMountName(workspace);
5499
+ if (mountName) {
5500
+ logPath = `${mountName}/background-scripts/${scriptId}.log`;
5501
+ logAbsPath = workspace.resolveAbsolutePath(logPath);
5502
+ }
5503
+ }
5504
+ const runner = new PyRunner({
5505
+ pythonPath: cfg?.pythonPath,
5506
+ toolCallTimeoutMs: cfg?.toolCallTimeoutMs,
5507
+ scriptTimeoutMs: cfg?.scriptTimeoutMs,
5508
+ idleReclaimMs: 0, // dedicated runner; lifetime is the exec deadline
5509
+ label: `${agentName}:${scriptId}`,
5510
+ onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args),
5511
+ });
5512
+ const record = {
5513
+ id: scriptId,
5514
+ agentName,
5515
+ runner,
5516
+ code,
5517
+ startedAt: Date.now(),
5518
+ wakes: 0,
5519
+ lastWakeAt: null,
5520
+ logPath,
5521
+ status: 'running',
5522
+ cancelled: false,
5523
+ };
5524
+ this.backgroundScripts.set(scriptId, record);
5525
+ this.emitTrace({
5526
+ type: 'tool:started', module: 'code_execution', tool: `background:${scriptId}`,
5527
+ callId: scriptId, input: { lines: code.split('\n').length },
5528
+ });
5529
+ void runner
5530
+ .exec(code, injected, {
5531
+ logPath: logAbsPath,
5532
+ lifetimeMs,
5533
+ onWake: (line, payload) => this.handleScriptWake(record, line, payload),
5534
+ })
5535
+ .then((exec) => this.settleBackgroundScript(record, exec))
5536
+ .catch((err) => {
5537
+ // exec never rejects by contract; this is the belt-and-suspenders.
5538
+ console.error(`[pytc:${agentName}:${scriptId}] background exec rejected: ${String(err)}`);
5539
+ this.settleBackgroundScript(record, {
5540
+ stdout: '', stderr: String(err), returnCode: 1, aborted: true,
5541
+ });
5542
+ });
5543
+ return {
5544
+ success: true,
5545
+ data: {
5546
+ status: 'background script started',
5547
+ script_id: scriptId,
5548
+ log: logPath ?? 'no writable workspace mount — output is not retrievable; only wake_agent() reaches you',
5549
+ lifetime_hours: Math.round(lifetimeMs / 3_600_000 * 10) / 10,
5550
+ note: 'The script dies if the host process restarts. Manage with code_execution {"action": "list"|"cancel"}.',
5551
+ },
5552
+ };
5553
+ }
5554
+ /** First read-write mount name, or null. */
5555
+ firstWritableMountName(workspace) {
5556
+ const mounts = workspace.getMounts?.();
5557
+ if (mounts) {
5558
+ const rw = mounts.find((m) => m.mode !== 'read-only');
5559
+ return rw?.name ?? null;
5560
+ }
5561
+ return null;
5562
+ }
5563
+ /**
5564
+ * wake_agent() from a background script: enforce the per-script wake cap
5565
+ * and rate floor (delay, not drop — the script awaits the ack), then
5566
+ * inject the provenance envelope + payload and request inference.
5567
+ * Resolves null on delivery; an error string refuses the wake (raises
5568
+ * RuntimeError inside the script).
5569
+ */
5570
+ async handleScriptWake(record, line, payload) {
5571
+ if (record.cancelled)
5572
+ return 'script was cancelled';
5573
+ const cfg = this.codeExecutionConfig;
5574
+ const maxWakes = cfg?.maxWakesPerScript ?? 100;
5575
+ if (record.wakes >= maxWakes) {
5576
+ return `wake limit reached (${maxWakes} per script)`;
5577
+ }
5578
+ const floorMs = cfg?.wakeMinIntervalMs ?? 60_000;
5579
+ if (record.lastWakeAt !== null) {
5580
+ const wait = record.lastWakeAt + floorMs - Date.now();
5581
+ if (wait > 0) {
5582
+ await new Promise((r) => {
5583
+ const t = setTimeout(r, wait);
5584
+ t.unref?.();
5585
+ });
5586
+ if (record.cancelled)
5587
+ return 'script was cancelled';
5588
+ }
5589
+ }
5590
+ record.wakes += 1;
5591
+ record.lastWakeAt = Date.now();
5592
+ const elapsedMin = Math.round((Date.now() - record.startedAt) / 60_000);
5593
+ const payloadJson = payload === undefined || payload === null
5594
+ ? 'null'
5595
+ : JSON.stringify(payload, null, 1) ?? String(payload);
5596
+ const agent = this.agents.get(record.agentName);
5597
+ const maxChars = agent ? this.getMaxToolResultChars(agent) : undefined;
5598
+ const payloadText = await this.spillOrTruncate(payloadJson, maxChars, `${record.id}-wake${record.wakes}`);
5599
+ const envelope = `[background script ${record.id}] Woke you: wake_agent() called at line ${line} of your script, ` +
5600
+ `${elapsedMin}m after you started it (wake ${record.wakes} of ${maxWakes}).\n` +
5601
+ `Arguments:\n${payloadText}\n` +
5602
+ (record.logPath ? `Script output so far: workspace file ${record.logPath}\n` : '') +
5603
+ `Script status: still running`;
5604
+ this.injectScriptWake(record, envelope);
5605
+ return null;
5606
+ }
5607
+ /** Background script ended (clean, crash, deadline, cancel/dispose). */
5608
+ settleBackgroundScript(record, exec) {
5609
+ record.runner.dispose();
5610
+ if (record.status === 'running') {
5611
+ record.status = exec.returnCode === 0 ? 'finished' : 'died';
5612
+ }
5613
+ this.emitTrace({
5614
+ type: 'tool:completed', module: 'code_execution', tool: `background:${record.id}`,
5615
+ callId: record.id, durationMs: Date.now() - record.startedAt,
5616
+ });
5617
+ // Crash honesty: a resident sleeping on a promise must never have that
5618
+ // promise die silently. Deliberate cancels/disposes stay silent (the
5619
+ // agent or host chose them); clean exits stay silent (the null path).
5620
+ if (record.status === 'died' && !record.cancelled) {
5621
+ const tail = (exec.tail ?? exec.stderr ?? '').slice(-2000);
5622
+ const elapsedMin = Math.round((Date.now() - record.startedAt) / 60_000);
5623
+ const envelope = `[background script ${record.id}] Your background script DIED ${elapsedMin}m after start ` +
5624
+ `(it did not call wake_agent and is no longer watching).\n` +
5625
+ `Last output:\n${tail || '(none)'}\n` +
5626
+ (record.logPath ? `Full journal: workspace file ${record.logPath}` : '');
5627
+ this.injectScriptWake(record, envelope);
5628
+ }
5629
+ // Keep a short memory of settled scripts for {"action":"list"}, then drop.
5630
+ const settled = [...this.backgroundScripts.values()]
5631
+ .filter((s) => s.agentName === record.agentName && s.status !== 'running');
5632
+ for (const old of settled.slice(0, Math.max(0, settled.length - 5))) {
5633
+ this.backgroundScripts.delete(old.id);
5634
+ }
5635
+ }
5636
+ /**
5637
+ * Deliver a background-script event: provenance envelope into the agent's
5638
+ * context + an inference request. Deliberately does NOT consult the event
5639
+ * gate — the agent armed this wake themselves (wake-through-sleep
5640
+ * authority); it enters tagged so gate policies could be taught about it
5641
+ * later if that ever needs revisiting.
5642
+ */
5643
+ injectScriptWake(record, envelope) {
5644
+ try {
5645
+ const messageId = this.addMessage('user', [{ type: 'text', text: envelope }], {
5646
+ source: 'background-script',
5647
+ scriptId: record.id,
5648
+ tags: ['script:wake'],
5649
+ });
5650
+ this.pendingRequests.push({
5651
+ agentName: record.agentName,
5652
+ reason: 'script:wake',
5653
+ source: 'code_execution',
5654
+ timestamp: Date.now(),
5655
+ });
5656
+ this.emitTrace({
5657
+ type: 'message:added',
5658
+ messageId,
5659
+ source: `background-script:${record.id}`,
5660
+ });
5661
+ }
5662
+ catch (err) {
5663
+ console.error(`[pytc:${record.agentName}:${record.id}] wake injection failed: ${err instanceof Error ? err.message : String(err)}`);
5664
+ }
5665
+ }
5666
+ /**
5667
+ * Oversized content policy (antra, 2026-07-31): never hit the agent in
5668
+ * the face with a huge blob, never silently destroy it either. Content
5669
+ * over the inline cap is materialized to a workspace file (readable with
5670
+ * the agent's own tools) and replaced by a truncated head + a trailing
5671
+ * file reference. Falls back to plain truncation when there is no
5672
+ * writable workspace. The cap can be lifted temporarily via
5673
+ * agent_settings `tool_result_inline_max_chars`.
5674
+ */
5675
+ async spillOrTruncate(content, maxChars, label, agentName) {
5676
+ const override = agentName !== undefined
5677
+ ? this.toolResultInlineMaxCharsOverride.get(agentName)
5678
+ : undefined;
5679
+ const cap = override ?? maxChars;
5680
+ if (!cap || content.length <= cap)
5681
+ return content;
5682
+ const workspace = this.getWorkspaceModule();
5683
+ const mountName = workspace ? this.firstWritableMountName(workspace) : null;
5684
+ if (workspace && mountName) {
5685
+ const safeLabel = label.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
5686
+ const path = `${mountName}/tool-results/${safeLabel}.txt`;
5687
+ try {
5688
+ const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain');
5689
+ if (result.success) {
5690
+ return safeSlice(content, 0, cap)
5691
+ + `\n\n[truncated — showing ${cap} of ${content.length} chars; full content: workspace file ${path}. `
5692
+ + 'Read/grep it with your file tools, or raise the inline cap temporarily via '
5693
+ + 'agent_settings update tool_result_inline_max_chars.]';
5694
+ }
5695
+ }
5696
+ catch {
5697
+ // fall through to plain truncation
5698
+ }
5699
+ }
5700
+ return safeSlice(content, 0, cap)
5701
+ + '\n\n[truncated — original was ' + content.length + ' chars]';
5702
+ }
5026
5703
  getOrCreateScriptRunner(agentName) {
5027
5704
  let runner = this.codeExecutionRunners.get(agentName);
5028
5705
  if (!runner) {
@@ -5064,9 +5741,10 @@ export class AgentFramework {
5064
5741
  }
5065
5742
  if (result.data === undefined)
5066
5743
  return '';
5067
- const agent = this.agents.get(agentName);
5068
- const maxChars = agent ? this.getMaxToolResultChars(agent) : undefined;
5069
- return toolResultDataToHistoryString(result.data, maxChars);
5744
+ // Scripts get (nearly) full data — the script environment IS the spill:
5745
+ // filtering big results in code is the whole point. 5MB protocol safety
5746
+ // cap only; no context-cap truncation, no file spill.
5747
+ return toolResultDataToHistoryString(result.data, 5_000_000);
5070
5748
  }
5071
5749
  /**
5072
5750
  * Dispatch a script-inner tool call through the NORMAL dispatch path and
@@ -5114,7 +5792,10 @@ export class AgentFramework {
5114
5792
  runner.abort(reason);
5115
5793
  }
5116
5794
  }
5117
- toMembraneToolResult(callId, afResult, maxChars) {
5795
+ toMembraneToolResult(callId, afResult, maxChars,
5796
+ /** Pre-spilled string from the history path — used for the non-image
5797
+ * path so the live wire copy byte-matches what the window stored. */
5798
+ precomputed) {
5118
5799
  if (afResult.isError) {
5119
5800
  return { toolUseId: callId, content: afResult.error ?? 'Unknown error', isError: true };
5120
5801
  }
@@ -5126,6 +5807,9 @@ export class AgentFramework {
5126
5807
  if (blocks) {
5127
5808
  return { toolUseId: callId, content: blocks, isError: false };
5128
5809
  }
5810
+ if (precomputed !== undefined) {
5811
+ return { toolUseId: callId, content: precomputed, isError: false };
5812
+ }
5129
5813
  // JSON.stringify returns the VALUE undefined (not a string) for undefined
5130
5814
  // input — a module tool returning `{ success: true }` with no data would
5131
5815
  // otherwise make `content.length` below throw a TypeError mid-turn.
@@ -5292,6 +5976,10 @@ export class AgentFramework {
5292
5976
  return;
5293
5977
  }
5294
5978
  // Route the agent's typed, allowlisted hot-settings surface.
5979
+ if (enrichedCall.name === 'utils') {
5980
+ this.dispatchUtilsToolCall(agentName, enrichedCall);
5981
+ return;
5982
+ }
5295
5983
  if (enrichedCall.name === 'agent_settings') {
5296
5984
  this.dispatchAgentSettingsToolCall(agentName, enrichedCall);
5297
5985
  return;
@@ -5427,15 +6115,25 @@ export class AgentFramework {
5427
6115
  }
5428
6116
  // Defer non-tool_result messages while a tool cycle is mid-flight
5429
6117
  // (pendingAssistantBlocks: preserves tool_use → tool_result adjacency)
5430
- // OR while the target agent has a live stream at all. A message stored
5431
- // mid-stream lands BEFORE the turn's assistant blocks in the window even
5432
- // though the live conversation never saw it — so the next compile
5433
- // diverges from the live prefix (prompt-cache bust) and the message
5434
- // misses mid-turn injection. Deferred messages flush at the agent's next
5435
- // tool boundary (where they are ALSO injected into the live stream —
5436
- // hear-while-acting) or in driveStream's finally when the turn ends.
6118
+ // OR while the target agent has a turn in progress AT ALL. A message
6119
+ // stored mid-turn lands BEFORE the turn's assistant blocks in the window
6120
+ // even though the live conversation never saw it — so the next compile
6121
+ // diverges from the live prefix (prompt-cache bust), the message misses
6122
+ // mid-turn injection, and the window falsely testifies the agent saw it
6123
+ // and moved on. Turn-alive (activeTurnTokens) is the guard, not stream-
6124
+ // alive: a turn exists from dequeue through hooks + compile before its
6125
+ // stream registers in activeStreams, and the 2026-07-31 Mythos incident
6126
+ // was precisely a deferred flush landing inside that compile window.
6127
+ // (activeStreams is kept as a belt-and-suspenders backstop.) Deferred
6128
+ // messages flush at the next turn's START (before its compile — riding
6129
+ // their own wake), at the agent's next tool boundary (where they are
6130
+ // ALSO injected into the live stream — hear-while-acting), or in
6131
+ // driveStream's finally when the turn ends.
5437
6132
  const hasToolResult = content.some(b => b.type === 'tool_result');
5438
- if (!hasToolResult && (this.pendingAssistantBlocks.size > 0 || this.activeStreams.has(agent.name))) {
6133
+ if (!hasToolResult &&
6134
+ (this.pendingAssistantBlocks.size > 0 ||
6135
+ this.activeTurnTokens.has(agent.name) ||
6136
+ this.activeStreams.has(agent.name))) {
5439
6137
  this.deferredMessages.push({ participant, content, metadata });
5440
6138
  return ''; // Deferred — flushed at the next boundary
5441
6139
  }
@@ -6889,7 +7587,7 @@ export class AgentFramework {
6889
7587
  }
6890
7588
  this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input });
6891
7589
  const startTime = Date.now();
6892
- this.channelRegistry.handleChannelToolCall(call.name, call.input)
7590
+ this.channelRegistry.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName })
6893
7591
  .then((result) => {
6894
7592
  const durationMs = Date.now() - startTime;
6895
7593
  this.emitTrace({ type: 'tool:completed', module: 'channels', tool: call.name, callId: call.id, durationMs });
@@ -7102,6 +7800,31 @@ export class AgentFramework {
7102
7800
  if (typeof input.path !== 'string' || input.path.length === 0) {
7103
7801
  throw new Error('read_image: `path` (mount-prefixed) is required');
7104
7802
  }
7803
+ // Anthropic rejects images >5MB (and >8000px); guard the hard byte
7804
+ // limit here — resizing is out of scope without an image library.
7805
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
7806
+ // Prefer the workspace module's own read_image handler when it exists:
7807
+ // it reads tree-first with a FILESYSTEM FALLBACK, so binaries that live
7808
+ // only on disk (shell-created files, pre-fix mangled tree blobs) still
7809
+ // render. The legacy tree-only path below stays for older workspace
7810
+ // modules that don't advertise the tool.
7811
+ if (typeof workspace.handleToolCall === 'function' &&
7812
+ typeof workspace.getTools === 'function' &&
7813
+ workspace.getTools().some((tool) => tool.name === 'read_image')) {
7814
+ const result = await workspace.handleToolCall({ ...call, name: 'read_image' });
7815
+ if (result.success && Array.isArray(result.data)) {
7816
+ const image = result.data.find((block) => typeof block === 'object' && block !== null &&
7817
+ block.type === 'image' &&
7818
+ typeof block.data === 'string');
7819
+ const byteLength = image ? Math.floor((image.data.length * 3) / 4) : 0;
7820
+ if (byteLength > MAX_IMAGE_BYTES) {
7821
+ throw new Error(`read_image: "${input.path}" is ~${byteLength} bytes — ` +
7822
+ `over the ${MAX_IMAGE_BYTES}-byte model limit`);
7823
+ }
7824
+ }
7825
+ finish(result);
7826
+ return;
7827
+ }
7105
7828
  const read = await workspace.readBinary(input.path);
7106
7829
  if ('error' in read)
7107
7830
  throw new Error(read.error);
@@ -7110,9 +7833,6 @@ export class AgentFramework {
7110
7833
  throw new Error(`read_image: "${input.path}" does not look like a supported image ` +
7111
7834
  '(png/jpeg/gif/webp — checked by magic bytes, not extension)');
7112
7835
  }
7113
- // Anthropic rejects images >5MB (and >8000px); guard the hard byte
7114
- // limit here — resizing is out of scope without an image library.
7115
- const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
7116
7836
  if (read.data.byteLength > MAX_IMAGE_BYTES) {
7117
7837
  throw new Error(`read_image: "${input.path}" is ${read.data.byteLength} bytes — ` +
7118
7838
  `over the ${MAX_IMAGE_BYTES}-byte model limit`);
@@ -7274,6 +7994,81 @@ export class AgentFramework {
7274
7994
  }
7275
7995
  })();
7276
7996
  }
7997
+ /** The `utils` meta-tool: list/describe/run over module utilities
7998
+ * (Module.getUtilities). Shared by the model-facing dispatcher and the
7999
+ * programmatic path (executeToolCall), so code_execution scripts and
8000
+ * ephemerals reach utilities too. */
8001
+ async handleUtilsToolCall(agentName, call) {
8002
+ const input = (call.input ?? {});
8003
+ const utilities = this.moduleRegistry.getAllUtilities();
8004
+ const available = () => utilities.length ? utilities.map((u) => u.name).join(', ') : '(none registered)';
8005
+ switch (input.action) {
8006
+ case 'list':
8007
+ return {
8008
+ success: true,
8009
+ data: utilities.map((u) => ({ name: u.name, description: utilityOneLiner(u.description) })),
8010
+ };
8011
+ case 'describe': {
8012
+ const def = utilities.find((u) => u.name === input.name);
8013
+ if (!def) {
8014
+ return { success: false, isError: true, error: `No utility "${String(input.name)}". Available: ${available()}` };
8015
+ }
8016
+ return { success: true, data: def };
8017
+ }
8018
+ case 'run': {
8019
+ const def = utilities.find((u) => u.name === input.name);
8020
+ if (!def) {
8021
+ return { success: false, isError: true, error: `No utility "${String(input.name)}". Available: ${available()}` };
8022
+ }
8023
+ const args = input.args ?? {};
8024
+ const problem = validateUtilityArgs(args, def.inputSchema);
8025
+ if (problem) {
8026
+ // Teach by bounce: the error carries the schema, so the retry needs
8027
+ // no describe round-trip.
8028
+ return {
8029
+ success: false,
8030
+ isError: true,
8031
+ error: `${problem}. Schema for ${def.name}: ${JSON.stringify(def.inputSchema)}`,
8032
+ };
8033
+ }
8034
+ // Same dispatch as a first-class tool call — the module cannot tell
8035
+ // which surface the call came through.
8036
+ return this.moduleRegistry.handleToolCall({
8037
+ id: call.id,
8038
+ name: def.name,
8039
+ input: args,
8040
+ callerAgentName: agentName,
8041
+ });
8042
+ }
8043
+ default:
8044
+ return { success: false, isError: true, error: 'action must be "list", "describe", or "run"' };
8045
+ }
8046
+ }
8047
+ dispatchUtilsToolCall(agentName, call) {
8048
+ this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'utils', callId: call.id, input: call.input });
8049
+ const startedAt = Date.now();
8050
+ void this.handleUtilsToolCall(agentName, call)
8051
+ .catch((err) => ({
8052
+ success: false,
8053
+ error: err instanceof Error ? err.message : String(err),
8054
+ isError: true,
8055
+ }))
8056
+ .then((result) => {
8057
+ if (result.success) {
8058
+ this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'utils', callId: call.id, durationMs: Date.now() - startedAt });
8059
+ }
8060
+ else {
8061
+ this.emitTrace({ type: 'tool:failed', module: 'framework', tool: 'utils', callId: call.id, error: result.error ?? 'unknown error' });
8062
+ }
8063
+ this.pushEvent({
8064
+ type: 'tool-result',
8065
+ callId: call.id,
8066
+ agentName,
8067
+ moduleName: 'framework',
8068
+ result,
8069
+ });
8070
+ });
8071
+ }
7277
8072
  dispatchAgentSettingsToolCall(agentName, call) {
7278
8073
  this.emitTrace({
7279
8074
  type: 'tool:started',