@animalabs/agent-framework 0.6.10 → 0.7.2

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 (83) hide show
  1. package/dist/src/agent.d.ts +2 -0
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +3 -0
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/code-execution/index.d.ts +14 -0
  6. package/dist/src/code-execution/index.d.ts.map +1 -0
  7. package/dist/src/code-execution/index.js +13 -0
  8. package/dist/src/code-execution/index.js.map +1 -0
  9. package/dist/src/code-execution/py-runner.d.ts +91 -0
  10. package/dist/src/code-execution/py-runner.d.ts.map +1 -0
  11. package/dist/src/code-execution/py-runner.js +364 -0
  12. package/dist/src/code-execution/py-runner.js.map +1 -0
  13. package/dist/src/code-execution/runtime-py.d.ts +32 -0
  14. package/dist/src/code-execution/runtime-py.d.ts.map +1 -0
  15. package/dist/src/code-execution/runtime-py.js +265 -0
  16. package/dist/src/code-execution/runtime-py.js.map +1 -0
  17. package/dist/src/code-execution/tool-definition.d.ts +16 -0
  18. package/dist/src/code-execution/tool-definition.d.ts.map +1 -0
  19. package/dist/src/code-execution/tool-definition.js +45 -0
  20. package/dist/src/code-execution/tool-definition.js.map +1 -0
  21. package/dist/src/framework.d.ts +157 -15
  22. package/dist/src/framework.d.ts.map +1 -1
  23. package/dist/src/framework.js +1068 -104
  24. package/dist/src/framework.js.map +1 -1
  25. package/dist/src/index.d.ts +2 -0
  26. package/dist/src/index.d.ts.map +1 -1
  27. package/dist/src/index.js +4 -0
  28. package/dist/src/index.js.map +1 -1
  29. package/dist/src/mcpl/channel-registry.d.ts +99 -7
  30. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  31. package/dist/src/mcpl/channel-registry.js +374 -49
  32. package/dist/src/mcpl/channel-registry.js.map +1 -1
  33. package/dist/src/mcpl/prose-grammar.d.ts +24 -0
  34. package/dist/src/mcpl/prose-grammar.d.ts.map +1 -0
  35. package/dist/src/mcpl/prose-grammar.js +33 -0
  36. package/dist/src/mcpl/prose-grammar.js.map +1 -0
  37. package/dist/src/mcpl/prose-stream-router.d.ts +58 -0
  38. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -0
  39. package/dist/src/mcpl/prose-stream-router.js +168 -0
  40. package/dist/src/mcpl/prose-stream-router.js.map +1 -0
  41. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  42. package/dist/src/modules/workspace/index.js +13 -4
  43. package/dist/src/modules/workspace/index.js.map +1 -1
  44. package/dist/src/types/agent.d.ts +17 -0
  45. package/dist/src/types/agent.d.ts.map +1 -1
  46. package/dist/src/types/framework.d.ts +33 -0
  47. package/dist/src/types/framework.d.ts.map +1 -1
  48. package/dist/src/types/trace.d.ts +32 -0
  49. package/dist/src/types/trace.d.ts.map +1 -1
  50. package/dist/test/channel-registry-routing.test.js +186 -10
  51. package/dist/test/channel-registry-routing.test.js.map +1 -1
  52. package/dist/test/code-execution.test.d.ts +11 -0
  53. package/dist/test/code-execution.test.d.ts.map +1 -0
  54. package/dist/test/code-execution.test.js +412 -0
  55. package/dist/test/code-execution.test.js.map +1 -0
  56. package/dist/test/explicit-prose-routing.test.d.ts +11 -0
  57. package/dist/test/explicit-prose-routing.test.d.ts.map +1 -0
  58. package/dist/test/explicit-prose-routing.test.js +247 -0
  59. package/dist/test/explicit-prose-routing.test.js.map +1 -0
  60. package/dist/test/present-while-acting.test.js +85 -8
  61. package/dist/test/present-while-acting.test.js.map +1 -1
  62. package/dist/test/prose-stream-router.test.d.ts +2 -0
  63. package/dist/test/prose-stream-router.test.d.ts.map +1 -0
  64. package/dist/test/prose-stream-router.test.js +87 -0
  65. package/dist/test/prose-stream-router.test.js.map +1 -0
  66. package/dist/test/trunk-channel-routing.test.js +2 -2
  67. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  68. package/dist/tsconfig.tsbuildinfo +1 -1
  69. package/package.json +4 -4
  70. package/src/agent.ts +3 -0
  71. package/src/code-execution/index.ts +19 -0
  72. package/src/code-execution/py-runner.ts +433 -0
  73. package/src/code-execution/runtime-py.ts +265 -0
  74. package/src/code-execution/tool-definition.ts +52 -0
  75. package/src/framework.ts +1160 -107
  76. package/src/index.ts +17 -0
  77. package/src/mcpl/channel-registry.ts +454 -54
  78. package/src/mcpl/prose-grammar.ts +40 -0
  79. package/src/mcpl/prose-stream-router.ts +161 -0
  80. package/src/modules/workspace/index.ts +13 -4
  81. package/src/types/agent.ts +18 -0
  82. package/src/types/framework.ts +35 -0
  83. package/src/types/trace.ts +47 -1
@@ -11,11 +11,16 @@ import { FeatureSetManager } from './mcpl/feature-set-manager.js';
11
11
  import { ScopeManager } from './mcpl/scope-manager.js';
12
12
  import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
13
13
  import { PushHandler } from './mcpl/push-handler.js';
14
+ import { parseProsePrefix } from './mcpl/prose-grammar.js';
15
+ import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
14
16
  import { InferenceRouter } from './mcpl/inference-router.js';
15
17
  import { ChannelRegistry } from './mcpl/channel-registry.js';
16
18
  import { ConversationRouter } from './mcpl/conversation-router.js';
17
19
  import { safeSlice } from './safe-slice.js';
18
20
  import { toolResultDataToHistoryString } from './tool-result-history.js';
21
+ import { randomUUID } from 'node:crypto';
22
+ import { PyRunner, buildInjectedTools } from './code-execution/py-runner.js';
23
+ import { buildCodeExecutionToolDefinition, CODE_EXECUTION_TOOL_NAME, } from './code-execution/tool-definition.js';
19
24
  import { splitProseSegments } from './prose-segments.js';
20
25
  /** Detect a supported image media type from magic bytes (the model API
21
26
  * rejects mislabeled media types, so trust bytes over extensions).
@@ -57,6 +62,99 @@ function sniffImageMediaType(data) {
57
62
  const SILENCING_TOOLS = new Set([
58
63
  'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
59
64
  ]);
65
+ /**
66
+ * True when an injected message is real conversational input — something the
67
+ * agent might actually be replying to — rather than ambient machinery.
68
+ * System markers (`system: true` — send-failed notices, routing notices) and
69
+ * reactions (`chat:reaction` tag, MCPL RFC-001) don't count: they must not
70
+ * clear explicit-send suppression, and they never influence routing.
71
+ */
72
+ const isConversationalInjection = (metadata) => {
73
+ if (!metadata)
74
+ return true;
75
+ const m = metadata;
76
+ if (m.system === true)
77
+ return false;
78
+ if (Array.isArray(m.tags) && m.tags.includes('chat:reaction'))
79
+ return false;
80
+ return true;
81
+ };
82
+ /**
83
+ * Explicit delivery tools whose successful use into a closed channel OPENS
84
+ * it (send implies engagement — see openIfClosedForSend). Bare tool names,
85
+ * matched after stripping the MCPL server prefix.
86
+ */
87
+ const SEND_ENGAGEMENT_TOOLS = new Set([
88
+ 'send_message', 'reply_message', 'send_dm', 'send_file', 'send_files',
89
+ ]);
90
+ /**
91
+ * Best-effort extraction of the delivered channel id from an MCPL send-tool
92
+ * result (the discord-mcpl send tools return JSON like
93
+ * `{"messageId":"…","channelId":"…"}` as a text block). Returns undefined on
94
+ * any shape mismatch — the caller falls back to the tool's input args.
95
+ */
96
+ function extractChannelIdFromToolResult(content) {
97
+ const text = content?.find((c) => c.type === 'text' && c.text)?.text;
98
+ if (!text)
99
+ return undefined;
100
+ try {
101
+ const parsed = JSON.parse(text);
102
+ return typeof parsed.channelId === 'string' && parsed.channelId.length > 0
103
+ ? parsed.channelId
104
+ : undefined;
105
+ }
106
+ catch {
107
+ return undefined;
108
+ }
109
+ }
110
+ /**
111
+ * True when an incoming channel message explicitly addressed the agent.
112
+ * Primary signal is the MCPL RFC-001 `chat:addressed` tag (mention / reply /
113
+ * DM, as classified by the server); the metadata booleans are the fallback
114
+ * for servers that predate tags. Addressed messages outrank ambient chatter
115
+ * when picking a turn's frozen speech locus.
116
+ */
117
+ const isAddressedMessage = (tags, _metadata) => Array.isArray(tags) && tags.includes('chat:addressed');
118
+ // parseProsePrefix moved to ./mcpl/prose-grammar.ts — ONE grammar shared with
119
+ // the outgoing-stream router (Spec 14.3), so streamed chunks and delivered
120
+ // envelopes can never disagree on what is a prefix.
121
+ /** One-time primer appended when an agent's proseRouting mode changes. */
122
+ function proseModePrimer(mode) {
123
+ if (mode === 'locus') {
124
+ return '[prose-routing] Mode change: your plain text auto-routes to the ' +
125
+ 'conversational locus again. `>>` destination prefixes are no longer needed.';
126
+ }
127
+ // Deliberately terse: a short event-style notice is classifier-safe from
128
+ // the user role (ablation D, 2026-07-24), while the full grammar as a user
129
+ // message drew a deterministic reasoning_extraction refusal (ablation A/B).
130
+ // The grammar is TAUGHT BY BOUNCE: the first undelivered text produces a
131
+ // notice with the exact resend syntax, and the full reference is available
132
+ // on demand via the prose_help tool (a tool RESULT is model-requested
133
+ // content — the safest role there is). Nothing is injected into the system
134
+ // prompt: those bytes belong to the recipe, and the deepest KV prefix must
135
+ // not vary with framework code.
136
+ return ('[prose-routing] Output mode changed: plain text is delivered only with a ' +
137
+ 'destination prefix line, e.g. ">>#channel-name your text". ' +
138
+ 'The prose_help tool shows the full syntax.');
139
+ }
140
+ /** Full `>>` routing reference, served as the prose_help TOOL RESULT (never
141
+ * injected ambiently — see proseModePrimer note). */
142
+ const PROSE_ROUTING_HELP = 'Plain-text output routing (explicit mode):\n' +
143
+ ' >>#channel-name … or >>@person … (DM) or >>service:guild:id …\n' +
144
+ ' One message may hold several sections: each line starting with ">>" begins\n' +
145
+ ' a new envelope, routed to its own destination independently.\n' +
146
+ ' The first destination in a turn applies to the rest of that turn.\n' +
147
+ ' Append " !" after the destination (e.g. ">>#ops !") to start your next turn\n' +
148
+ ' immediately when this one ends, instead of pausing until the next event.\n' +
149
+ ' >>skip_reply — text stays in your context only, like the skip_reply tool.\n' +
150
+ 'Text without a destination is not delivered: it is retained, and a notice will\n' +
151
+ 'prompt you to resend — reply e.g. ">>#channel {{unsent}}" to deliver the retained\n' +
152
+ 'text unchanged. Send tools (send_message, send_dm, …) are unaffected.';
153
+ const PROSE_HELP_TOOL = {
154
+ name: 'prose_help',
155
+ description: 'Show the plain-text output routing syntax (destination prefixes, resend token, skip).',
156
+ inputSchema: { type: 'object', properties: {} },
157
+ };
60
158
  /** Strip the `server--` MCPL prefix from a tool name. */
61
159
  const bareToolName = (n) => n.split('--').pop();
62
160
  import { CheckpointManager } from './mcpl/checkpoint-manager.js';
@@ -209,20 +307,37 @@ export class AgentFramework {
209
307
  processLoggingPersist;
210
308
  processLoggingBroadcast;
211
309
  activeStreams = new Map();
212
- /** Per-agent output locus pinned for the CURRENT logical turn (see
213
- * resolveTurnLocus in driveStream). Lives here — not in driveStream
214
- * locals — so the pin survives a context-budget stream restart, which
215
- * continues the same logical turn in a fresh driveStream. A mid-turn
216
- * injected channel message replaces the pin for the next conversational
217
- * round; otherwise it remains stable. Cleared at the start of every
218
- * non-restart turn. */
310
+ /** Per-agent output locus FROZEN for the CURRENT logical turn. Resolved
311
+ * eagerly in startAgentStream (home → addressed trigger → global default)
312
+ * and never moved until the next turn: mid-turn injected messages — ambient
313
+ * chatter, reactions, system markers — must not hijack where the agent's
314
+ * plain prose lands (2026-07-21 Cairn lounge misroute). Lives here — not in
315
+ * driveStream locals — so the pin survives a context-budget stream restart,
316
+ * which continues the same logical turn in a fresh driveStream. Cleared or
317
+ * re-set at the start of every non-restart turn. */
219
318
  turnLocusPins = new Map();
220
- /** A tool boundary injected fresh external input into the live stream.
221
- * Presence (including a null value) tells driveStream to clear sticky
222
- * explicit-send suppression before handling the next model round. A
223
- * string also moves that round's reply locus to the newest injected
224
- * channel. */
225
- midTurnRoutingResets = new Map();
319
+ /** A tool boundary injected fresh CONVERSATIONAL input (a real message —
320
+ * not a reaction or a system marker) into the live stream. Tells
321
+ * driveStream to clear sticky explicit-send suppression before handling
322
+ * the next model round. Never affects routing: the turn locus is frozen. */
323
+ midTurnInputSignals = new Set();
324
+ /** Last outbound locus announced to each agent as a durable `[routing]`
325
+ * window message. Announce-on-change only: steady state emits nothing
326
+ * (KV-safe, no per-turn chatter). In-memory — after a process restart the
327
+ * first turn's locus is announced once to re-establish the baseline. */
328
+ lastAnnouncedLocus = new Map();
329
+ // ---- Explicit prose routing (docs/explicit-prose-routing.md) ----------
330
+ /** Latest bounced (undelivered) prose per agent — the `{{unsent}}` source. */
331
+ proseClipboards = new Map();
332
+ /** Sticky per-TURN delivery target set by the turn's first `>>` prefix.
333
+ * Cleared at every non-restart turn start; survives context restarts. */
334
+ proseTargetPins = new Map();
335
+ /** Agents whose current turn requested `!` continuation — re-woken when the
336
+ * turn completes instead of pausing until the next external event. */
337
+ proseContinuations = new Set();
338
+ /** Consecutive bounce-wakes per agent; capped so an agent that keeps
339
+ * emitting unprefixed prose can't wake-loop (notices still append). */
340
+ proseBounceStreaks = new Map();
226
341
  pendingAssistantBlocks = new Map();
227
342
  /** Streams the FRAMEWORK cancelled for non-terminal reasons, keyed
228
343
  * `${agentName}:${streamId}`: an endTurn tool result or a context-budget
@@ -312,6 +427,17 @@ export class AgentFramework {
312
427
  /** Forks whose TTL closure turn has been queued — disposed (removed from
313
428
  * agents/agentConfigs/conversationAgentHomes) when their stream ends. */
314
429
  closingConversationAgents = new Set();
430
+ // Client-side programmatic tool calling (`code_execution`). Null unless
431
+ // config.codeExecution.enabled. One PyRunner per agent (interpreter state
432
+ // is per-agent, like a per-agent container); waiters resolve script-inner
433
+ // tool calls dispatched through the normal dispatchToolCall path.
434
+ codeExecutionConfig = null;
435
+ codeExecutionRunners = new Map();
436
+ scriptToolWaiters = new Map();
437
+ /** Agents whose running script hit an endTurn-carrying inner result —
438
+ * deferred and applied to the final code_execution result instead of
439
+ * cancelling the stream mid-script (which would wedge the turn). */
440
+ scriptDeferredEndTurn = new Set();
315
441
  mcplTools = [];
316
442
  mcplToolRefreshInFlight = false;
317
443
  mcplToolRefreshPending = false;
@@ -529,6 +655,9 @@ export class AgentFramework {
529
655
  // Stored for lazy MCPL initialization (connectMcplServer on a framework
530
656
  // that started with zero configured servers).
531
657
  framework.mcplInferenceRoutingConfig = config.inferenceRouting ?? null;
658
+ // Client-side programmatic tool calling (code_execution). Config is only
659
+ // retained when enabled — everything downstream gates on the field.
660
+ framework.codeExecutionConfig = config.codeExecution?.enabled ? config.codeExecution : null;
532
661
  // Initialize MCPL subsystems if configured
533
662
  if (config.mcplServers && config.mcplServers.length > 0) {
534
663
  // Validate tool prefixes: no collisions with module names or between servers
@@ -609,6 +738,13 @@ export class AgentFramework {
609
738
  async stop() {
610
739
  this.running = false;
611
740
  this.queue.close();
741
+ // Kill running code_execution scripts before cancelling streams: a
742
+ // zombie script must not keep firing side-effectful tool calls into a
743
+ // framework that is shutting down.
744
+ for (const runner of this.codeExecutionRunners.values()) {
745
+ runner.dispose();
746
+ }
747
+ this.codeExecutionRunners.clear();
612
748
  // Cancel all active streams
613
749
  for (const agent of this.agents.values()) {
614
750
  if (agent.state.status === 'streaming' ||
@@ -874,6 +1010,9 @@ export class AgentFramework {
874
1010
  ...(this.getWorkspaceModule()
875
1011
  ? [AgentFramework.SAVE_IMAGE_TOOL, AgentFramework.READ_IMAGE_TOOL]
876
1012
  : []),
1013
+ ...(this.codeExecutionConfig
1014
+ ? [buildCodeExecutionToolDefinition(this.codeExecutionConfig)]
1015
+ : []),
877
1016
  ];
878
1017
  }
879
1018
  /** The registered workspace module, if any (used by save_image). */
@@ -988,20 +1127,48 @@ export class AgentFramework {
988
1127
  throw new Error(`Unknown agent: ${agentName}`);
989
1128
  return agent.getRuntimeSettings();
990
1129
  }
991
- updateAgentRuntimeSettings(agentName, patch) {
1130
+ /**
1131
+ * Apply runtime settings.
1132
+ *
1133
+ * `opts.persist: false` makes the change EPHEMERAL: it takes effect live but
1134
+ * is not written to the `framework/state` slot, so a restart reverts it. That
1135
+ * is the mode for operator experiments ("change it, watch, revert") — the
1136
+ * default persists, because a durable operational change should survive a
1137
+ * bounce like every other override.
1138
+ *
1139
+ * Nothing here notifies the agent. Discovery is deliberately PULL: the agent
1140
+ * can read current settings any time via the `agent_settings` tool's `get`
1141
+ * action, which costs no context perturbation. Pushing a notice injects text
1142
+ * into the very context being tuned — it invalidates the KV prefix and can
1143
+ * itself trip a classifier — so callers opt into that explicitly rather than
1144
+ * getting it by default.
1145
+ *
1146
+ * KNOWN LIMITATION of `persist: false`: the change still lands in the agent's
1147
+ * in-memory override map, and `persistAgentRuntimeSettings` writes that whole
1148
+ * map. So a LATER persisting call promotes any still-live ephemeral change to
1149
+ * durable as a side effect. Ephemeral means "not written by THIS call", not
1150
+ * "can never be written". Reset the ephemeral key before persisting something
1151
+ * else if that matters. Making it airtight needs per-key ephemeral tracking
1152
+ * inside Agent.
1153
+ */
1154
+ updateAgentRuntimeSettings(agentName, patch, opts) {
992
1155
  const agent = this.agents.get(agentName);
993
1156
  if (!agent)
994
1157
  throw new Error(`Unknown agent: ${agentName}`);
995
1158
  const result = agent.updateRuntimeSettings(patch);
996
- this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
1159
+ if (opts?.persist !== false) {
1160
+ this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
1161
+ }
997
1162
  return result;
998
1163
  }
999
- resetAgentRuntimeSettings(agentName, keys) {
1164
+ resetAgentRuntimeSettings(agentName, keys, opts) {
1000
1165
  const agent = this.agents.get(agentName);
1001
1166
  if (!agent)
1002
1167
  throw new Error(`Unknown agent: ${agentName}`);
1003
1168
  const result = agent.resetRuntimeSettings(keys);
1004
- this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
1169
+ if (opts?.persist !== false) {
1170
+ this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
1171
+ }
1005
1172
  return result;
1006
1173
  }
1007
1174
  cancelAgentRuntimeSettingsTransition(agentName) {
@@ -1064,10 +1231,14 @@ export class AgentFramework {
1064
1231
  throw new Error(`Agent not found: ${agentName}`);
1065
1232
  }
1066
1233
  const tools = this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name));
1234
+ // An explicit budget compiles against a HYPOTHETICAL window instead of the
1235
+ // agent's live one. That also suppresses transition-settling in
1236
+ // compileWithInjections (which only settles when no budget is passed), so
1237
+ // previewing a smaller window cannot advance a converging descent.
1067
1238
  // Default: no dynamic injection gathering → fully transparent (no
1068
1239
  // inference, no Chronicle writes, no external RPC). Opt in explicitly.
1069
1240
  if (!opts?.injections) {
1070
- return agent.buildActivationRequest(tools, undefined);
1241
+ return agent.buildActivationRequest(tools, undefined, opts?.budget);
1071
1242
  }
1072
1243
  // Full-fidelity path: mirrors startAgentStream's injection gathering.
1073
1244
  // NOT transparent — see the doc comment above.
@@ -1097,7 +1268,33 @@ export class AgentFramework {
1097
1268
  console.error('beforeInference hook error (preview):', error);
1098
1269
  }
1099
1270
  }
1100
- return agent.buildActivationRequest(tools, injections);
1271
+ return agent.buildActivationRequest(tools, injections, opts?.budget);
1272
+ }
1273
+ /**
1274
+ * Non-committing preview of the FOLD PLAN at a hypothetical budget and/or
1275
+ * context settings — the numbers behind an operator's "what happens if I set
1276
+ * the budget to X" before they apply it.
1277
+ *
1278
+ * Unlike `previewActivation` (which renders a request), this returns the
1279
+ * picker's plan: resulting tokens, whether it fits, how many summaries would
1280
+ * have to be produced first, and the per-chunk fold levels. Commits nothing:
1281
+ * no resolution persistence, no compression enqueue, no transition
1282
+ * bookkeeping — see `AutobiographicalStrategy.previewContext`.
1283
+ *
1284
+ * Returns null when the agent's strategy has no fold plan (non-adaptive).
1285
+ */
1286
+ previewContextSettings(agentName, budgetTokens, overrides, opts) {
1287
+ const agent = this.agents.get(agentName);
1288
+ if (!agent)
1289
+ throw new Error(`Unknown agent: ${agentName}`);
1290
+ const cm = agent.getContextManager();
1291
+ if (typeof cm.previewContext !== 'function')
1292
+ return null;
1293
+ // reserveForResponse mirrors the live compile so the previewed middle is
1294
+ // comparable to what the agent actually gets. `opts.render` additionally
1295
+ // returns the rendered entries — opt-in, since they are megabytes on a
1296
+ // large store, and older context-manager builds ignore the argument.
1297
+ return cm.previewContext({ maxTokens: budgetTokens, reserveForResponse: agent.maxTokens }, overrides, opts);
1101
1298
  }
1102
1299
  /**
1103
1300
  * Check if process logging is enabled.
@@ -2599,6 +2796,18 @@ export class AgentFramework {
2599
2796
  // Anthropic API. If we let modules or MCPL handlers run first they may add
2600
2797
  // messages between tool_use and tool_result, causing a 400 error.
2601
2798
  if (event.type === 'tool-result') {
2799
+ // Script-inner tool calls (client-side programmatic tool calling):
2800
+ // resolve the waiter and stop. These calls belong to a RUNNING
2801
+ // code_execution script, not to the agent's pending tool round — they
2802
+ // must not touch agent state (and must resolve even for ephemeral
2803
+ // agents that aren't in this.agents, or after the agent's own round
2804
+ // has already settled).
2805
+ const scriptWaiter = this.scriptToolWaiters.get(event.callId);
2806
+ if (scriptWaiter) {
2807
+ this.scriptToolWaiters.delete(event.callId);
2808
+ scriptWaiter(event.result);
2809
+ return;
2810
+ }
2602
2811
  const agent = this.agents.get(event.agentName);
2603
2812
  if (agent) {
2604
2813
  this.touchEphemeralRun(event.agentName, true);
@@ -2691,20 +2900,17 @@ export class AgentFramework {
2691
2900
  }
2692
2901
  }
2693
2902
  }
2694
- // A newly injected message begins a new conversational round inside
2695
- // the same provider inference. Remember that boundary for
2696
- // driveStream: an explicit send in the preceding round must not
2697
- // silence the reply, and the newest channel-bearing injection is
2698
- // now the reply locus. Map presence matters even without a channel
2699
- // (CLI/module input): it still clears the prior send suppression.
2700
- if (midTurnInjections.length > 0) {
2701
- const injectedChannelId = midTurnInjections.reduce((latest, injection) => {
2702
- const candidate = injection.metadata?.channelId;
2703
- return typeof candidate === 'string' && candidate.length > 0
2704
- ? candidate
2705
- : latest;
2706
- }, null);
2707
- this.midTurnRoutingResets.set(agent.name, injectedChannelId);
2903
+ // A newly injected CONVERSATIONAL message begins a new
2904
+ // conversational round inside the same provider inference. Remember
2905
+ // that boundary for driveStream: an explicit send in the preceding
2906
+ // round must not silence the reply. Routing is deliberately NOT
2907
+ // affected — the turn locus is frozen at turn start, so ambient
2908
+ // chatter, reactions, and system markers injected mid-turn can no
2909
+ // longer hijack where the agent's prose lands. Non-conversational
2910
+ // injections (reactions, `system: true` markers) don't even clear
2911
+ // the send suppression: nothing new was said to the agent.
2912
+ if (midTurnInjections.some((inj) => isConversationalInjection(inj.metadata))) {
2913
+ this.midTurnInputSignals.add(agent.name);
2708
2914
  }
2709
2915
  // Check if any tool result requested endTurn
2710
2916
  const shouldEndTurn = currentState.toolResults.some(tc => tc.result.endTurn);
@@ -2890,6 +3096,8 @@ export class AgentFramework {
2890
3096
  };
2891
3097
  if (event.threadId)
2892
3098
  metadata.threadId = event.threadId;
3099
+ if (event.tags)
3100
+ metadata.tags = event.tags;
2893
3101
  // Per-channel conversation routing: messages go to the channel's fork
2894
3102
  // agent (spawned from the template on first qualifying message), never
2895
3103
  // to the primary conversation.
@@ -2900,6 +3108,7 @@ export class AgentFramework {
2900
3108
  const id = this.addMessage('user', event.content, metadata);
2901
3109
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
2902
3110
  if (event.triggerInference) {
3111
+ const addressed = isAddressedMessage(event.tags, event.metadata);
2903
3112
  for (const agentName of this.agents.keys()) {
2904
3113
  this.pendingRequests.push({
2905
3114
  agentName,
@@ -2909,6 +3118,9 @@ export class AgentFramework {
2909
3118
  // Route this turn's auto-published speech back to THIS channel, not
2910
3119
  // the global most-recent-inbound locus (item-3 redux, trunk agents).
2911
3120
  channelId: event.channelId,
3121
+ // Addressed messages outrank ambient chatter when a batched wake
3122
+ // picks the turn's frozen speech locus.
3123
+ addressed,
2912
3124
  });
2913
3125
  }
2914
3126
  }
@@ -2990,6 +3202,7 @@ export class AgentFramework {
2990
3202
  // A fork's home channel wins in routeSpeech regardless, but carry the
2991
3203
  // triggering channel too so the trunk/active path stays consistent.
2992
3204
  channelId: event.channelId,
3205
+ addressed: isAddressedMessage(event.tags, event.metadata),
2993
3206
  });
2994
3207
  }
2995
3208
  }
@@ -3145,7 +3358,7 @@ export class AgentFramework {
3145
3358
  handleMcplPushEvent(event) {
3146
3359
  const triggerChannel = this.derivePushEventChannel(event.origin);
3147
3360
  if (triggerChannel && this.channelRegistry) {
3148
- this.channelRegistry.ensureChannelRegistered(event.serverId, triggerChannel.channelId, triggerChannel.label);
3361
+ this.channelRegistry.ensureChannelRegistered(event.serverId, triggerChannel.channelId, triggerChannel.label, triggerChannel.metadata);
3149
3362
  }
3150
3363
  const metadata = {
3151
3364
  ...event.origin,
@@ -3153,7 +3366,17 @@ export class AgentFramework {
3153
3366
  featureSet: event.featureSet,
3154
3367
  eventId: event.eventId,
3155
3368
  triggered: event.triggerInference ?? false,
3369
+ ...(event.tags ? { tags: event.tags } : {}),
3156
3370
  };
3371
+ // `origin.channelId` is the server-internal raw id (a bare Discord
3372
+ // snowflake) — unroutable as a locus and unresolvable by the agent. Store
3373
+ // the composite MCPL id instead wherever we can derive it, so anything
3374
+ // downstream that reads `metadata.channelId` (routing, provenance, the
3375
+ // agent itself) sees the qualified form. The raw value stays available on
3376
+ // servers that also send `rawChannelId`-style origin fields.
3377
+ if (triggerChannel) {
3378
+ metadata.channelId = triggerChannel.channelId;
3379
+ }
3157
3380
  const content = [...event.content];
3158
3381
  const addressedWhileClosed = triggerChannel &&
3159
3382
  event.tags?.includes('chat:addressed') &&
@@ -3207,6 +3430,10 @@ export class AgentFramework {
3207
3430
  source: event.serverId,
3208
3431
  timestamp: Date.now(),
3209
3432
  channelId: triggerChannel?.channelId,
3433
+ // DMs and addressed-while-closed messages arrive as push events;
3434
+ // they must outrank ambient chatter in a batched wake's locus
3435
+ // selection just like their channels/incoming counterparts.
3436
+ addressed: isAddressedMessage(event.tags, event.origin),
3210
3437
  });
3211
3438
  }
3212
3439
  }
@@ -3230,16 +3457,35 @@ export class AgentFramework {
3230
3457
  derivePushEventChannel(origin) {
3231
3458
  if (!origin)
3232
3459
  return undefined;
3233
- const label = typeof origin.channelName === 'string' ? origin.channelName : undefined;
3460
+ let label = typeof origin.channelName === 'string' && origin.channelName ? origin.channelName : undefined;
3461
+ let metadata;
3462
+ // A DM has no channelName — label it by the PERSON and carry their
3463
+ // identity, so DM registrations are people-first (`DM: antra`, matchable
3464
+ // by `>>@name` / `<@id>` mention tokens) instead of bare snowflakes.
3465
+ const isDM = origin.isDM === true || origin.guildId === null;
3466
+ if (isDM && typeof origin.authorName === 'string' && origin.authorName) {
3467
+ label = label ?? `DM: ${origin.authorName}`;
3468
+ metadata = {
3469
+ channelType: 'dm',
3470
+ recipientName: origin.authorName,
3471
+ ...(typeof origin.authorId === 'string' && origin.authorId
3472
+ ? { recipientId: origin.authorId }
3473
+ : {}),
3474
+ };
3475
+ }
3234
3476
  const explicit = origin.mcplChannelId;
3235
3477
  if (typeof explicit === 'string' && explicit) {
3236
- return { channelId: explicit, label };
3478
+ return { channelId: explicit, ...(label ? { label } : {}), ...(metadata ? { metadata } : {}) };
3237
3479
  }
3238
3480
  // Discord fallback: reconstruct the composite from origin parts. `guildId`
3239
3481
  // is null for a DM (→ 'dm'); a real guild id for a non-open guild channel.
3240
3482
  if (origin.source === 'discord' && typeof origin.channelId === 'string' && origin.channelId) {
3241
3483
  const guild = typeof origin.guildId === 'string' && origin.guildId ? origin.guildId : 'dm';
3242
- return { channelId: `discord:${guild}:${origin.channelId}`, label };
3484
+ return {
3485
+ channelId: `discord:${guild}:${origin.channelId}`,
3486
+ ...(label ? { label } : {}),
3487
+ ...(metadata ? { metadata } : {}),
3488
+ };
3243
3489
  }
3244
3490
  return undefined;
3245
3491
  }
@@ -3312,14 +3558,287 @@ export class AgentFramework {
3312
3558
  const trigger = requests[0];
3313
3559
  // Route this turn's auto-published speech to the channel that triggered
3314
3560
  // it (item-3 redux). A batched wake may carry several triggering channels
3315
- // (messages arrived in >1 channel while the agent was busy/idle) — reply
3316
- // in the MOST-RECENT one: it matches the legacy last-inbound semantics and
3317
- // is the message the agent is most likely answering. The common case is
3318
- // 1 message → 1 channel (unambiguous). Non-channel wakes carry no
3319
- // channelId, leaving the field undefined → global fallback. `reduce`
3320
- // keeps the last defined channelId across the (FIFO-ordered) batch.
3321
- const triggerChannel = requests.reduce((acc, r) => r.channelId ?? acc, undefined);
3322
- await this.startAgentStream(agent, { ...trigger, channelId: triggerChannel });
3561
+ // (messages arrived in >1 channel while the agent was busy/idle):
3562
+ // 1. the most recent ADDRESSED channel wins (mention / reply / DM —
3563
+ // someone explicitly spoke TO the agent);
3564
+ // 2. else the most recent channel-bearing request (ambient message in
3565
+ // an open channel — legacy last-inbound semantics).
3566
+ // Ambient chatter must not outrank an addressed message just by being
3567
+ // newest (2026-07-21 Cairn lounge misroute, turn-start variant).
3568
+ // Non-channel wakes (heartbeats, module events, reactions — which never
3569
+ // carry channelId) leave both undefined → global fallback.
3570
+ let ambientChannel;
3571
+ let addressedChannel;
3572
+ for (const r of requests) {
3573
+ if (!r.channelId)
3574
+ continue;
3575
+ ambientChannel = r.channelId;
3576
+ if (r.addressed)
3577
+ addressedChannel = r.channelId;
3578
+ }
3579
+ const triggerChannel = addressedChannel ?? ambientChannel;
3580
+ const triggerAddressed = addressedChannel !== undefined;
3581
+ await this.startAgentStream(agent, {
3582
+ ...trigger,
3583
+ channelId: triggerChannel,
3584
+ addressed: triggerAddressed,
3585
+ });
3586
+ }
3587
+ }
3588
+ /**
3589
+ * Announce-on-change locus notice (turn-frozen routing). Appends a durable
3590
+ * `[routing]` window message when the turn's effective outbound locus
3591
+ * differs from the last one announced — the agent must never have to guess
3592
+ * where its plain prose lands. Durable append (never retracted) keeps the
3593
+ * KV prefix stable; change-only keeps it out of steady-state turns. The
3594
+ * first resolution after boot announces only a non-null locus ("prose has
3595
+ * no destination" is the unremarkable boot default). Never triggers
3596
+ * inference; never locus-eligible itself (system + no channel metadata).
3597
+ */
3598
+ announceLocusIfChanged(agentName, locus) {
3599
+ const hasBaseline = this.lastAnnouncedLocus.has(agentName);
3600
+ const prev = this.lastAnnouncedLocus.get(agentName) ?? null;
3601
+ if (hasBaseline ? prev === locus : locus === null) {
3602
+ if (!hasBaseline)
3603
+ this.lastAnnouncedLocus.set(agentName, locus);
3604
+ return;
3605
+ }
3606
+ this.lastAnnouncedLocus.set(agentName, locus);
3607
+ const agent = this.agents.get(agentName);
3608
+ if (!agent)
3609
+ return;
3610
+ let text;
3611
+ if (locus === null) {
3612
+ text =
3613
+ '[routing] Your plain speech currently has no channel — it stays in ' +
3614
+ 'your archive. Use an explicit send tool to reach a channel.';
3615
+ }
3616
+ else {
3617
+ const label = this.channelRegistry?.getDescriptor(locus)?.label;
3618
+ const shown = label && label !== locus
3619
+ ? `${label.startsWith('#') ? label : `#${label}`} (${locus})`
3620
+ : locus;
3621
+ text =
3622
+ `[routing] Your plain speech now lands in ${shown}. ` +
3623
+ 'Other channels need an explicit send tool.';
3624
+ }
3625
+ try {
3626
+ const id = agent.getContextManager().addMessage('user', [{ type: 'text', text }], { system: true, kind: 'routing-notice' });
3627
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
3628
+ console.error(`[routing] ${agentName}: locus ${prev ?? '(none)'} -> ${locus ?? '(none)'} (announced in window)`);
3629
+ }
3630
+ catch (err) {
3631
+ console.error('announceLocusIfChanged: failed to record routing notice:', err);
3632
+ }
3633
+ }
3634
+ /**
3635
+ * Durable window notice for a channel the agent did not ask to open —
3636
+ * subscription-policy admission, or an open forced by the agent's own
3637
+ * delivery into a closed channel. The agent must always learn that new
3638
+ * traffic will start flowing, and that `channel_close` opts out (their
3639
+ * decision outranks policy). System message: never triggers inference,
3640
+ * never conversational, never locus-eligible.
3641
+ */
3642
+ recordChannelAutoOpenNotice(agentName, channels, cause) {
3643
+ if (channels.length === 0)
3644
+ return;
3645
+ const shown = channels
3646
+ .map((c) => (c.label && c.label !== c.channelId ? `${c.label} (${c.channelId})` : c.channelId))
3647
+ .join(', ');
3648
+ const plural = channels.length > 1;
3649
+ const causeText = cause === 'subscription-policy'
3650
+ ? 'your subscription policy just admitted ' + (plural ? 'them' : 'it')
3651
+ : 'your own message into the closed channel engaged it';
3652
+ const text = `[channels] Now open: ${shown} — ${causeText}. Ongoing traffic from ` +
3653
+ (plural ? 'these channels' : 'this channel') +
3654
+ ` will reach you. If you don't want ${plural ? 'one of them' : 'it'}, use channel_close — ` +
3655
+ 'your choice sticks; neither policy nor a later send will silently reopen it.';
3656
+ try {
3657
+ // Through framework.addMessage for mid-turn safety (deferral +
3658
+ // hear-while-acting injection) — a delivery-forced open happens inside
3659
+ // a live turn, and a direct context-manager append there would store
3660
+ // the notice out of order. Targets the primary agent; connectome-host
3661
+ // runs trunk-only, so this is the delivering agent in practice.
3662
+ this.addMessage('user', [{ type: 'text', text }], { system: true, kind: 'channel-notice' });
3663
+ console.error(`[channel] auto-open notice (${cause}) -> ${agentName ?? 'primary'}: ${shown}`);
3664
+ }
3665
+ catch (err) {
3666
+ console.error('recordChannelAutoOpenNotice failed:', err);
3667
+ }
3668
+ }
3669
+ /**
3670
+ * Explicit-prose-routing delivery gateway (docs/explicit-prose-routing.md).
3671
+ * Every prose segment of an `explicit`-mode agent flows through here:
3672
+ * parse the `>>` prefix, resolve the target, deliver — or bounce to the
3673
+ * clipboard with a resend notice. Misdelivery is structurally impossible:
3674
+ * nothing is ever sent to a destination the model did not name (directly
3675
+ * or via the turn's sticky target).
3676
+ */
3677
+ async deliverProse(agent, rawText) {
3678
+ // Multi-envelope: a single prose block may address SEVERAL destinations —
3679
+ // every line beginning with `>>` opens a new envelope, routed
3680
+ // independently (first live use: Tilde 2026-07-24, one block carrying a
3681
+ // letter to #tilde-mythos AND a `>>#tilde` aside; the single-envelope
3682
+ // parser delivered the aside literally into the first destination —
3683
+ // exactly the leak class this mode exists to kill). Text before the
3684
+ // first `>>` is its own unprefixed envelope (sticky/bounce as usual).
3685
+ const lines = rawText.split('\n');
3686
+ const envelopes = [];
3687
+ let current = [];
3688
+ for (const line of lines) {
3689
+ // Only a REAL prefix line opens an envelope: `>>` immediately followed
3690
+ // by a target token. A quoted `>> arrow` (space after) stays body text.
3691
+ if (/^>>\S/.test(line.trimStart()) && current.length > 0) {
3692
+ envelopes.push(current.join('\n'));
3693
+ current = [];
3694
+ }
3695
+ current.push(line);
3696
+ }
3697
+ if (current.length > 0)
3698
+ envelopes.push(current.join('\n'));
3699
+ for (const envelope of envelopes) {
3700
+ if (!envelope.trim())
3701
+ continue;
3702
+ await this.deliverProseEnvelope(agent, envelope.replace(/^\s+(?=>>)/, ''));
3703
+ }
3704
+ }
3705
+ async deliverProseEnvelope(agent, rawText) {
3706
+ const name = agent.name;
3707
+ const parsed = parseProsePrefix(rawText);
3708
+ if (parsed.continueTurn)
3709
+ this.proseContinuations.add(name);
3710
+ if (parsed.kind === 'private') {
3711
+ // The text already lives in the assistant message (window/chronicle);
3712
+ // "delivery" is deliberately a no-op. Consumes the retained text if the
3713
+ // segment embedded it.
3714
+ if (parsed.body.includes('{{unsent}}'))
3715
+ this.proseClipboards.delete(name);
3716
+ console.error(`[prose] ${name}: >>skip_reply — ${parsed.body.length} chars kept in context (not sent)`);
3717
+ return;
3718
+ }
3719
+ let targetChannel;
3720
+ let body;
3721
+ if (parsed.kind === 'target') {
3722
+ const res = this.channelRegistry.resolveProseTarget(parsed.target);
3723
+ if ('error' in res) {
3724
+ this.bounceProse(agent, parsed.body, res.error, res.candidates);
3725
+ return;
3726
+ }
3727
+ targetChannel = res.channelId;
3728
+ // Sticky within the turn: later unprefixed segments follow this target.
3729
+ this.proseTargetPins.set(name, targetChannel);
3730
+ body = parsed.body;
3731
+ }
3732
+ else {
3733
+ const sticky = this.proseTargetPins.get(name);
3734
+ if (!sticky) {
3735
+ this.bounceProse(agent, rawText, 'no destination prefix and no destination set yet this turn');
3736
+ return;
3737
+ }
3738
+ targetChannel = sticky;
3739
+ body = rawText;
3740
+ }
3741
+ const usedClipboard = body.includes('{{unsent}}');
3742
+ if (usedClipboard) {
3743
+ body = body.replaceAll('{{unsent}}', this.proseClipboards.get(name) ?? '');
3744
+ }
3745
+ if (!body.trim()) {
3746
+ console.error(`[prose] ${name}: empty body after prefix/substitution — nothing to send`);
3747
+ return;
3748
+ }
3749
+ try {
3750
+ const result = await this.channelRegistry.routeSpeech(name, body, targetChannel);
3751
+ if (result?.delivered) {
3752
+ this.proseBounceStreaks.delete(name);
3753
+ if (usedClipboard)
3754
+ this.proseClipboards.delete(name);
3755
+ }
3756
+ }
3757
+ catch (err) {
3758
+ console.error(`[prose] ${name}: delivery to ${targetChannel} failed:`, err);
3759
+ }
3760
+ }
3761
+ /** Cap on consecutive bounce-triggered wakes (notices still append after). */
3762
+ static PROSE_BOUNCE_WAKE_CAP = 2;
3763
+ bounceProse(agent, text, reason, candidates) {
3764
+ const name = agent.name;
3765
+ this.proseClipboards.set(name, text);
3766
+ const streak = (this.proseBounceStreaks.get(name) ?? 0) + 1;
3767
+ this.proseBounceStreaks.set(name, streak);
3768
+ const cand = candidates?.length ? ` Known channels it could mean: ${candidates.join(', ')}.` : '';
3769
+ const notice = `[prose-routing] Your text (${text.length} chars) was not delivered — ${reason}.${cand} ` +
3770
+ 'The text is retained; nothing is lost. To deliver it unchanged, reply with a ' +
3771
+ 'destination plus the token {{unsent}}, e.g. ">>#channel {{unsent}}" or ">>@person {{unsent}}". ' +
3772
+ '">>skip_reply {{unsent}}" keeps it in context only. The prose_help tool shows the full syntax.';
3773
+ try {
3774
+ // Through framework.addMessage, NOT the context manager directly: while
3775
+ // the turn is still streaming this defers the notice to the next tool
3776
+ // boundary, where it is BOTH stored in order and injected into the live
3777
+ // stream (hear-while-acting) — the model sees the error and can correct
3778
+ // the prefix within the same turn. A direct append here would land the
3779
+ // notice before the turn's assistant blocks (cache bust, invisible
3780
+ // until the next compile).
3781
+ const id = this.addMessage('user', [{ type: 'text', text: notice }], { system: true, kind: 'prose-bounce' });
3782
+ if (id)
3783
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'prose-bounce' });
3784
+ else
3785
+ this.emitTrace({ type: 'message:added', messageId: 'deferred', source: 'prose-bounce' });
3786
+ }
3787
+ catch (err) {
3788
+ console.error('bounceProse: failed to record bounce notice:', err);
3789
+ }
3790
+ const capped = streak > AgentFramework.PROSE_BOUNCE_WAKE_CAP;
3791
+ console.error(`[prose] ${name}: BOUNCED ${text.length} chars — ${reason}` +
3792
+ (capped ? ' (bounce-wake cap reached; notice appended without wake)' : ''));
3793
+ if (!capped) {
3794
+ // Wake so the resend can happen immediately — an unsent DM reply must
3795
+ // not sit invisible until the next unrelated event.
3796
+ this.pendingRequests.push({
3797
+ agentName: name,
3798
+ reason: 'prose-bounce',
3799
+ source: 'framework',
3800
+ timestamp: Date.now(),
3801
+ });
3802
+ }
3803
+ }
3804
+ /**
3805
+ * One-time mode primer (docs/explicit-prose-routing.md): when an agent's
3806
+ * configured proseRouting differs from the last mode we primed them for,
3807
+ * append the teaching notice and persist the new mode. A brand-new agent
3808
+ * in default locus mode is recorded silently — no primer for the status quo.
3809
+ */
3810
+ maybePrimeProseMode(agent) {
3811
+ const mode = agent.proseRouting;
3812
+ try {
3813
+ const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
3814
+ const state = (data && typeof data === 'object' ? data : {});
3815
+ const primed = { ...(state.proseRoutingPrimed ?? {}) };
3816
+ const stored = primed[agent.name];
3817
+ if (stored === mode)
3818
+ return;
3819
+ // Absence means "always default (locus)": a locus-mode agent that was
3820
+ // never explicit needs no entry and no primer — and skipping the write
3821
+ // keeps framework/state from growing a record per agent (scaling gate).
3822
+ if (mode === 'locus' && stored === undefined)
3823
+ return;
3824
+ if (mode === 'locus')
3825
+ delete primed[agent.name];
3826
+ else
3827
+ primed[agent.name] = mode;
3828
+ state.proseRoutingPrimed = primed;
3829
+ this.store.setStateJson(FRAMEWORK_STATE_ID, state);
3830
+ }
3831
+ catch (err) {
3832
+ console.error('maybePrimeProseMode: state read/write failed:', err);
3833
+ return;
3834
+ }
3835
+ try {
3836
+ const id = agent.getContextManager().addMessage('user', [{ type: 'text', text: proseModePrimer(mode) }], { system: true, kind: 'prose-routing-primer' });
3837
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'prose-routing-primer' });
3838
+ console.error(`[prose] ${agent.name}: mode primer appended (${mode})`);
3839
+ }
3840
+ catch (err) {
3841
+ console.error('maybePrimeProseMode: failed to append primer:', err);
3323
3842
  }
3324
3843
  }
3325
3844
  async startAgentStream(agent, trigger, attempt = 0) {
@@ -3334,20 +3853,74 @@ export class AgentFramework {
3334
3853
  // instead falls back to the global default. A given agent runs one turn at a
3335
3854
  // time, so a set here is only read during THIS turn's routeSpeech /
3336
3855
  // buildChannelContext; retries re-run with the same trigger, re-setting it.
3337
- if (trigger?.channelId) {
3338
- this.activeTriggerChannels.set(agent.name, trigger.channelId);
3856
+ // A context-budget restart continues the SAME logical turn in a fresh
3857
+ // stream — its trigger carries no channelId, and deleting the trigger
3858
+ // channel here mid-logical-turn is exactly the hole that sent Sol's DM
3859
+ // reply to a stale guild channel (2026-07-22): any live locus resolution
3860
+ // after the restart fell through to the hours-old defaultPublishChannel.
3861
+ // Keep the turn's trigger channel across restarts; only real new turns
3862
+ // reset it.
3863
+ if (trigger?.reason !== 'context_budget_restart') {
3864
+ if (trigger?.channelId) {
3865
+ this.activeTriggerChannels.set(agent.name, trigger.channelId);
3866
+ }
3867
+ else {
3868
+ this.activeTriggerChannels.delete(agent.name);
3869
+ }
3339
3870
  }
3340
- else {
3341
- this.activeTriggerChannels.delete(agent.name);
3871
+ // FREEZE this turn's outbound locus now (turn-frozen routing). Resolved
3872
+ // once — home channel for forks, else the triggering channel, else the
3873
+ // global default — and never moved for the rest of the turn: mid-turn
3874
+ // injections must not redirect the agent's plain prose (ambient chatter /
3875
+ // reactions hijacked it before, 2026-07-21). A context-budget restart
3876
+ // continues the same logical turn, so it keeps the existing pin. The
3877
+ // eager snapshot also protects heartbeat turns from a moving
3878
+ // defaultPublishChannel mid-turn. If the effective locus differs from the
3879
+ // last one announced, drop a durable `[routing]` notice into the window
3880
+ // BEFORE this turn compiles, so the agent always knows where its voice
3881
+ // goes (announce-on-change only — no per-turn chatter, append-only for
3882
+ // KV stability).
3883
+ if (trigger?.reason !== 'context_budget_restart') {
3884
+ if (attempt === 0)
3885
+ this.maybePrimeProseMode(agent);
3886
+ if (agent.proseRouting === 'explicit') {
3887
+ // Explicit prose routing: there is no locus. The model names every
3888
+ // destination in-band (`>>` prefixes); the only turn state is the
3889
+ // sticky target, reset for each fresh turn. No freeze, no announce.
3890
+ this.proseTargetPins.delete(agent.name);
3891
+ this.proseContinuations.delete(agent.name);
3892
+ this.midTurnInputSignals.delete(agent.name);
3893
+ this.turnLocusPins.delete(agent.name);
3894
+ }
3895
+ else {
3896
+ const locus = this.channelRegistry?.resolveLocus(agent.name) ?? null;
3897
+ if (locus !== null)
3898
+ this.turnLocusPins.set(agent.name, locus);
3899
+ else
3900
+ this.turnLocusPins.delete(agent.name);
3901
+ this.midTurnInputSignals.delete(agent.name);
3902
+ if (attempt === 0)
3903
+ this.announceLocusIfChanged(agent.name, locus);
3904
+ }
3342
3905
  }
3343
3906
  this.touchEphemeralRun(agent.name, true);
3344
- this.emitTrace({ type: 'inference:started', agentName: agent.name });
3907
+ this.emitTrace({
3908
+ type: 'inference:started',
3909
+ agentName: agent.name,
3910
+ // The turn-frozen locus was pinned just above (kept across a
3911
+ // context-budget restart, which skips the re-pin but re-emits this).
3912
+ channelId: this.turnLocusPins.get(agent.name),
3913
+ });
3345
3914
  this.eventGate?.onInferenceStarted(agent.name);
3346
3915
  this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
3347
3916
  try {
3348
3917
  const requestSnapshot = this.captureInferenceToolSnapshot(agent);
3349
3918
  const allTools = this.getToolsForAgent(agent.name, requestSnapshot);
3350
3919
  const tools = allTools.filter((t) => agent.canUseTool(t.name));
3920
+ // Explicit-mode agents get the on-demand routing reference (teach-by-
3921
+ // bounce: the grammar is never injected, only served when asked).
3922
+ if (agent.proseRouting === 'explicit')
3923
+ tools.push(PROSE_HELP_TOOL);
3351
3924
  // Gather context from modules (pull-based) and MCPL hooks (push-based)
3352
3925
  // Both produce ContextInjection[] that get merged before inference.
3353
3926
  let injections;
@@ -3428,25 +4001,14 @@ export class AgentFramework {
3428
4001
  const myStreamId = agent.streamId;
3429
4002
  let hadToolCalls = false;
3430
4003
  // ---- Present-while-acting turn state ---------------------------------
3431
- // Output locus for the current CONVERSATIONAL ROUND: resolved lazily and
3432
- // kept stable across tool-only rounds. A channel message injected at a
3433
- // tool boundary replaces the pin before the next model round, because a
3434
- // single provider inference can now contain several conversations. The
3435
- // pin lives in `turnLocusPins` so it survives a context-budget restart. A
3436
- // null resolution is not pinned.
3437
- if (trigger?.reason !== 'context_budget_restart') {
3438
- this.turnLocusPins.delete(agent.name);
3439
- this.midTurnRoutingResets.delete(agent.name);
3440
- }
3441
- const resolveTurnLocus = () => {
3442
- let locus = this.turnLocusPins.get(agent.name) ?? null;
3443
- if (locus === null && this.channelRegistry) {
3444
- locus = this.channelRegistry.resolveLocus(agent.name);
3445
- if (locus !== null)
3446
- this.turnLocusPins.set(agent.name, locus);
3447
- }
3448
- return locus;
3449
- };
4004
+ // Output locus for the WHOLE logical turn: frozen in startAgentStream
4005
+ // (home → addressed trigger → global default) before this stream began,
4006
+ // and never moved until the next turn. Mid-turn injections do not touch
4007
+ // it — an ambient message or a reaction arriving at a tool boundary must
4008
+ // not redirect the agent's task narration (2026-07-21 Cairn misroute).
4009
+ // The pin lives in `turnLocusPins` so a context-budget restart (same
4010
+ // logical turn, fresh driveStream) keeps it.
4011
+ const resolveTurnLocus = () => this.turnLocusPins.get(agent.name) ?? null;
3450
4012
  // Ordered delivery chain for live-routed prose. Links are enqueued
3451
4013
  // WITHOUT awaiting in the stream-event loop — an awaited network post
3452
4014
  // here would stall consumption of the next round's events by
@@ -3475,32 +4037,52 @@ export class AgentFramework {
3475
4037
  // historical whole-turn routing so fallback-mode prose is still
3476
4038
  // delivered exactly once, at turn end.
3477
4039
  let liveProseRouting = false;
3478
- // Typing indicator: show "<agent> is typing…" in the channel she's
3479
- // responding to, for the whole duration of this turn. Started here (paired
3480
- // with the finally below, so it can never leak) and refreshed on a 7s
3481
- // interval by the ChannelRegistry until stopped on any exit path.
3482
- let typingChannel = this.channelRegistry
3483
- ? trigger?.channelId ?? this.channelRegistry.getDefaultPublishChannel()
3484
- : null;
4040
+ // Typing indicator: show "<agent> is typing…" for the whole duration of
4041
+ // this turn. Started here (paired with the finally below, so it can never
4042
+ // leak) and refreshed on a 7s interval by the ChannelRegistry until
4043
+ // stopped on any exit path. Never moves mid-turn.
4044
+ // - locus mode: the frozen routing pin (where bare prose will land).
4045
+ // - explicit mode: there is no locus — indicate on the TRIGGER channel
4046
+ // (where the wake came from). Typing is presence, not delivery, so
4047
+ // this doesn't violate never-guess: it says "attending to what you
4048
+ // sent here", which is true regardless of where the reply goes.
4049
+ // Heartbeat/no-trigger explicit turns show no indicator.
4050
+ const typingChannel = agent.proseRouting === 'explicit'
4051
+ ? trigger?.channelId ?? null
4052
+ : resolveTurnLocus();
3485
4053
  if (typingChannel)
3486
4054
  this.channelRegistry.startTyping(typingChannel);
4055
+ // MCPL Spec 14.3 outgoing streaming: route text deltas to their
4056
+ // destination server AS THEY GENERATE (voice synthesis, live message
4057
+ // rendering). Pure observer surface — suppression is fail-closed (nothing
4058
+ // streams that delivery would refuse), emission is capability-gated in
4059
+ // the registry (`channels.streaming`), and delivery via deliverProse
4060
+ // remains the sole authoritative send.
4061
+ const outgoingInferenceId = `inf_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
4062
+ let outgoingIndex = 0;
4063
+ const proseStream = this.channelRegistry
4064
+ ? new ProseStreamRouter({
4065
+ mode: agent.proseRouting === 'explicit' ? 'explicit' : 'locus',
4066
+ initialTarget: typingChannel,
4067
+ resolve: (spec) => {
4068
+ const r = this.channelRegistry.resolveProseTarget(spec);
4069
+ return 'channelId' in r ? r.channelId : null;
4070
+ },
4071
+ })
4072
+ : null;
4073
+ const emitOutgoing = (deltas) => {
4074
+ for (const rd of deltas) {
4075
+ this.channelRegistry.sendOutgoingChunk(rd.channelId, agent.name, outgoingInferenceId, outgoingIndex++, rd.delta);
4076
+ }
4077
+ };
3487
4078
  const adoptInjectedRound = () => {
3488
- if (!this.midTurnRoutingResets.has(agent.name))
4079
+ if (!this.midTurnInputSignals.has(agent.name))
3489
4080
  return;
3490
- const nextLocus = this.midTurnRoutingResets.get(agent.name) ?? null;
3491
- this.midTurnRoutingResets.delete(agent.name);
3492
- // A new human/agent message, not merely a tool result, means any earlier
3493
- // explicit delivery has completed its conversational job.
4081
+ this.midTurnInputSignals.delete(agent.name);
4082
+ // A new conversational message, not merely a tool result, means any
4083
+ // earlier explicit delivery has completed its conversational job.
4084
+ // Routing is NOT touched: the turn locus stays frozen.
3494
4085
  turnSilenced = false;
3495
- if (nextLocus) {
3496
- this.turnLocusPins.set(agent.name, nextLocus);
3497
- if (typingChannel !== nextLocus) {
3498
- if (typingChannel)
3499
- this.channelRegistry?.stopTyping(typingChannel);
3500
- typingChannel = nextLocus;
3501
- this.channelRegistry?.startTyping(nextLocus);
3502
- }
3503
- }
3504
4086
  };
3505
4087
  try {
3506
4088
  for await (const event of stream) {
@@ -3513,7 +4095,14 @@ export class AgentFramework {
3513
4095
  content: event.content,
3514
4096
  blockType: event.meta.type,
3515
4097
  blockIndex: event.meta.blockIndex,
4098
+ // Turn-frozen locus (same value as the typing indicator above):
4099
+ // lets trace consumers tag every chunk with the channel this
4100
+ // turn's prose is bound for, without re-deriving routing.
4101
+ channelId: typingChannel ?? undefined,
3516
4102
  });
4103
+ if (proseStream && event.meta.type === 'text') {
4104
+ emitOutgoing(proseStream.feed(event.content));
4105
+ }
3517
4106
  break;
3518
4107
  case 'block': {
3519
4108
  const { event: phase, index, block } = event.event;
@@ -3523,6 +4112,7 @@ export class AgentFramework {
3523
4112
  phase,
3524
4113
  blockType: block.type,
3525
4114
  blockIndex: index,
4115
+ channelId: typingChannel ?? undefined,
3526
4116
  });
3527
4117
  break;
3528
4118
  }
@@ -3547,13 +4137,30 @@ export class AgentFramework {
3547
4137
  assistantBlocks = roundContent.filter((b) => b.type !== 'tool_result');
3548
4138
  }
3549
4139
  else {
3550
- // Fallback (older membrane / XML tool mode): preamble + calls
4140
+ // Fallback (older membrane / XML tool mode): preamble + calls.
4141
+ // XML mode: persist the verbatim generation on each tool_use
4142
+ // (membrane#36 round-trip fidelity). context.rawText is the
4143
+ // parser's fullMatch — the exact <function_calls> block the
4144
+ // model wrote (plus the harness-appended close tag), shared by
4145
+ // every call in the round; the anthropic-xml formatter replays
4146
+ // it byte-identically instead of reconstructing. Guarded by
4147
+ // shape so a native-path fallback can never attach non-XML.
4148
+ const rawText = event.context.rawText;
4149
+ const rawXml = rawText && /^<(antml:)?function_calls>/.test(rawText)
4150
+ ? rawText
4151
+ : undefined;
3551
4152
  assistantBlocks = [];
3552
4153
  if (event.context.preamble) {
3553
4154
  assistantBlocks.push({ type: 'text', text: event.context.preamble });
3554
4155
  }
3555
4156
  for (const c of event.calls) {
3556
- assistantBlocks.push({ type: 'tool_use', id: c.id, name: c.name, input: c.input });
4157
+ assistantBlocks.push({
4158
+ type: 'tool_use',
4159
+ id: c.id,
4160
+ name: c.name,
4161
+ input: c.input,
4162
+ ...(rawXml ? { rawXml } : {}),
4163
+ });
3557
4164
  }
3558
4165
  }
3559
4166
  this.pendingAssistantBlocks.set(agent.name, assistantBlocks);
@@ -3588,7 +4195,20 @@ export class AgentFramework {
3588
4195
  liveProseRouting = true;
3589
4196
  const roundSegments = splitProseSegments(assistantBlocks);
3590
4197
  if (roundSegments.length > 0) {
3591
- if (turnSilenced) {
4198
+ if (agent.proseRouting === 'explicit') {
4199
+ // Explicit mode: every segment through the prose gateway.
4200
+ // Silencing does not apply — unprefixed prose bounces and
4201
+ // prefixed prose is deliberate; think-privacy still holds.
4202
+ if (!hasSameRoundPrivateThink) {
4203
+ console.error(`[prose] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> ${roundSegments.length} segment(s) via prose gateway`);
4204
+ for (const seg of roundSegments) {
4205
+ turnSpeechChain = turnSpeechChain
4206
+ .then(() => this.deliverProse(agent, seg))
4207
+ .catch((err) => console.error('mid-turn prose delivery failed:', err));
4208
+ }
4209
+ }
4210
+ }
4211
+ else if (turnSilenced) {
3592
4212
  console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`);
3593
4213
  }
3594
4214
  else if (hasSameRoundPrivateThink) {
@@ -3867,11 +4487,30 @@ export class AgentFramework {
3867
4487
  .join('\n')
3868
4488
  .trim();
3869
4489
  if (speechText) {
3870
- try {
3871
- await this.channelRegistry.routeSpeech(agent.name, speechText);
4490
+ if (agent.proseRouting === 'explicit') {
4491
+ console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`);
4492
+ try {
4493
+ await this.deliverProse(agent, speechText);
4494
+ }
4495
+ catch (err) {
4496
+ console.error('text-only prose delivery failed:', err);
4497
+ }
3872
4498
  }
3873
- catch (err) {
3874
- console.error('speech routing failed:', err);
4499
+ else {
4500
+ // Route to the TURN-FROZEN locus, like every other speech
4501
+ // path. This dispatch runs AFTER the agent is idle, so a live
4502
+ // resolution here can read the NEXT turn's trigger state (or
4503
+ // a post-restart cleared one) and land the reply in a stale
4504
+ // channel — the 2026-07-22 Sol DM-to-guild misroute. The
4505
+ // frozen pin is immune to both races.
4506
+ const locus = resolveTurnLocus();
4507
+ console.error(`[routing] ${agent.name}: text-only turn -> routing speech -> ${locus ?? '(none)'}`);
4508
+ try {
4509
+ await this.channelRegistry.routeSpeech(agent.name, speechText, locus);
4510
+ }
4511
+ catch (err) {
4512
+ console.error('speech routing failed:', err);
4513
+ }
3875
4514
  }
3876
4515
  }
3877
4516
  }
@@ -3902,7 +4541,20 @@ export class AgentFramework {
3902
4541
  // land before the trailing prose. Awaited even when silenced —
3903
4542
  // the chain may still be flushing earlier rounds' posts.
3904
4543
  await turnSpeechChain;
3905
- if (silenced || segments.length === 0) {
4544
+ if (agent.proseRouting === 'explicit') {
4545
+ if (segments.length > 0) {
4546
+ console.error(`[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`);
4547
+ for (const seg of segments) {
4548
+ try {
4549
+ await this.deliverProse(agent, seg);
4550
+ }
4551
+ catch (err) {
4552
+ console.error('trailing prose delivery failed:', err);
4553
+ }
4554
+ }
4555
+ }
4556
+ }
4557
+ else if (silenced || segments.length === 0) {
3906
4558
  console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> trailing prose NOT routed ` +
3907
4559
  `(${silenced ? 'silencing tool / explicit send' : 'no trailing prose'})`);
3908
4560
  }
@@ -3930,6 +4582,20 @@ export class AgentFramework {
3930
4582
  // NOTE: agent.reset() + onInferenceEnded() already ran above,
3931
4583
  // BEFORE dispatchSpeech. Locus routing is speech dispatch and
3932
4584
  // doesn't depend on the status field, so it correctly runs after.
4585
+ // Explicit-prose `!` continuation: a prose segment this turn asked
4586
+ // to keep going (`>>#x !` / `>>private !`) — start another turn
4587
+ // now instead of pausing until the next external event. This gives
4588
+ // prose the same continuation ability tool rounds have (wanted for
4589
+ // robotics-style loops where an end-of-turn pause is harmful).
4590
+ if (this.proseContinuations.delete(agent.name)) {
4591
+ console.error(`[prose] ${agent.name}: '!' continuation — requesting immediate next turn`);
4592
+ this.pendingRequests.push({
4593
+ agentName: agent.name,
4594
+ reason: 'prose-continuation',
4595
+ source: 'framework',
4596
+ timestamp: Date.now(),
4597
+ });
4598
+ }
3933
4599
  break;
3934
4600
  }
3935
4601
  case 'error': {
@@ -3953,6 +4619,7 @@ export class AgentFramework {
3953
4619
  // Only reset + retry if this is still the active stream
3954
4620
  if (agent.streamId !== myStreamId)
3955
4621
  break;
4622
+ this.abortAgentScript(agent.name, 'stream error');
3956
4623
  agent.reset();
3957
4624
  const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
3958
4625
  if (action.retry) {
@@ -3999,6 +4666,7 @@ export class AgentFramework {
3999
4666
  // may have already started a new stream, bumping streamId)
4000
4667
  if (agent.streamId === myStreamId) {
4001
4668
  const durationMs = Date.now() - startTime;
4669
+ this.abortAgentScript(agent.name, `stream aborted (${reason})`);
4002
4670
  agent.reset();
4003
4671
  this.settleAgent(agent.name, {
4004
4672
  stopReason: 'exhausted',
@@ -4097,6 +4765,7 @@ export class AgentFramework {
4097
4765
  request: compiledRequest ?? { note: 'streaming request threw' },
4098
4766
  durationMs,
4099
4767
  });
4768
+ this.abortAgentScript(agent.name, 'stream threw');
4100
4769
  agent.reset();
4101
4770
  this.eventGate?.onInferenceEnded(agent.name);
4102
4771
  }
@@ -4113,6 +4782,15 @@ export class AgentFramework {
4113
4782
  // Stop the typing indicator on every exit path (complete, error,
4114
4783
  // exhausted, abort) so it never sticks after the turn ends.
4115
4784
  this.channelRegistry?.stopTyping();
4785
+ // Spec 14.3: flush any held line-start text, then close each streamed
4786
+ // channel with its final moderated content — the consumer's signal to
4787
+ // finalize (end the TTS utterance, settle the rendered message).
4788
+ if (proseStream) {
4789
+ emitOutgoing(proseStream.finish());
4790
+ for (const [channelId, text] of proseStream.byChannel()) {
4791
+ this.channelRegistry.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text);
4792
+ }
4793
+ }
4116
4794
  this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
4117
4795
  this.activeStreams.delete(agent.name);
4118
4796
  this.pendingAssistantBlocks.delete(agent.name);
@@ -4136,6 +4814,12 @@ export class AgentFramework {
4136
4814
  * Used by SubagentModule to dispatch tool calls for ephemeral agents.
4137
4815
  */
4138
4816
  async executeToolCall(call) {
4817
+ // Client-side programmatic tool calling for promise-based callers
4818
+ // (SubagentModule ephemerals). Keyed by callerAgentName so each ephemeral
4819
+ // gets its own interpreter state.
4820
+ if (call.name === CODE_EXECUTION_TOOL_NAME && this.codeExecutionConfig) {
4821
+ return this.runCodeExecution(call.callerAgentName ?? '__ephemeral__', call);
4822
+ }
4139
4823
  // MCPL tools are dispatched via the MCPL subsystem
4140
4824
  if (this.mcplServerRegistry) {
4141
4825
  // Check if this is an MCPL-prefixed tool
@@ -4183,6 +4867,180 @@ export class AgentFramework {
4183
4867
  };
4184
4868
  }
4185
4869
  }
4870
+ // ---------------------------------------------------------------------------
4871
+ // Client-side programmatic tool calling (code_execution)
4872
+ // ---------------------------------------------------------------------------
4873
+ /** Event-pushing wrapper for the model-driven dispatch path. */
4874
+ dispatchCodeExecutionToolCall(agentName, call) {
4875
+ this.emitTrace({
4876
+ type: 'tool:started',
4877
+ module: 'code_execution',
4878
+ tool: call.name,
4879
+ callId: call.id,
4880
+ input: call.input,
4881
+ });
4882
+ const startTime = Date.now();
4883
+ this.runCodeExecution(agentName, call)
4884
+ .catch((error) => {
4885
+ // runCodeExecution is designed not to reject; this is the last-resort
4886
+ // guard so a bug here can never strand the agent in waiting_for_tools.
4887
+ const err = error instanceof Error ? error : new Error(String(error));
4888
+ return { success: false, error: `code_execution failed: ${err.message}`, isError: true };
4889
+ })
4890
+ .then((result) => {
4891
+ if (result.isError) {
4892
+ this.emitTrace({
4893
+ type: 'tool:failed',
4894
+ module: 'code_execution',
4895
+ tool: call.name,
4896
+ callId: call.id,
4897
+ error: result.error ?? 'unknown error',
4898
+ });
4899
+ }
4900
+ else {
4901
+ this.emitTrace({
4902
+ type: 'tool:completed',
4903
+ module: 'code_execution',
4904
+ tool: call.name,
4905
+ callId: call.id,
4906
+ durationMs: Date.now() - startTime,
4907
+ });
4908
+ }
4909
+ this.pushEvent({
4910
+ type: 'tool-result',
4911
+ callId: call.id,
4912
+ agentName,
4913
+ moduleName: 'code_execution',
4914
+ result,
4915
+ });
4916
+ });
4917
+ }
4918
+ /**
4919
+ * Run one code_execution script for an agent. Never rejects.
4920
+ *
4921
+ * The script runs in the agent's persistent python interpreter with every
4922
+ * tool on the agent's CURRENT surface injected (minus code_execution
4923
+ * itself). Inner tool calls are dispatched through dispatchToolCall — the
4924
+ * exact model path, including MCPL state/checkpoint handling, channel
4925
+ * routing, and traces — and resolved via scriptToolWaiters, so their
4926
+ * results reach the running script and never the model context.
4927
+ */
4928
+ async runCodeExecution(agentName, call) {
4929
+ const input = (call.input ?? {});
4930
+ if (typeof input.code !== 'string' || input.code.trim() === '') {
4931
+ return {
4932
+ success: false,
4933
+ error: 'code_execution requires a non-empty `code` string',
4934
+ isError: true,
4935
+ };
4936
+ }
4937
+ const agent = this.agents.get(agentName);
4938
+ const surface = agent
4939
+ ? this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name))
4940
+ : this.getAllTools(); // ephemeral agents: full surface, matching executeToolCall
4941
+ const injected = buildInjectedTools(surface.map((t) => t.name).filter((name) => name !== CODE_EXECUTION_TOOL_NAME));
4942
+ const runner = this.getOrCreateScriptRunner(agentName);
4943
+ this.scriptDeferredEndTurn.delete(agentName);
4944
+ const exec = await runner.exec(input.code, injected);
4945
+ const endTurn = this.scriptDeferredEndTurn.delete(agentName);
4946
+ return {
4947
+ success: true,
4948
+ data: { stdout: exec.stdout, stderr: exec.stderr, return_code: exec.returnCode },
4949
+ isError: false,
4950
+ ...(endTurn ? { endTurn: true } : {}),
4951
+ };
4952
+ }
4953
+ getOrCreateScriptRunner(agentName) {
4954
+ let runner = this.codeExecutionRunners.get(agentName);
4955
+ if (!runner) {
4956
+ const cfg = this.codeExecutionConfig;
4957
+ runner = new PyRunner({
4958
+ pythonPath: cfg?.pythonPath,
4959
+ toolCallTimeoutMs: cfg?.toolCallTimeoutMs,
4960
+ scriptTimeoutMs: cfg?.scriptTimeoutMs,
4961
+ idleReclaimMs: cfg?.idleReclaimMs,
4962
+ label: agentName,
4963
+ onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args),
4964
+ });
4965
+ this.codeExecutionRunners.set(agentName, runner);
4966
+ }
4967
+ return runner;
4968
+ }
4969
+ /**
4970
+ * Resolve one script-inner tool call to the string the script's tool
4971
+ * function returns. Never rejects — errors become "Error: ..." strings,
4972
+ * matching the managed PTC runtime ("Claude's code receives this error").
4973
+ *
4974
+ * Results are serialized with the HISTORY serializer (images become
4975
+ * placeholders): programmatic tool results are text-only by contract, and
4976
+ * a script variable holding megabytes of base64 helps nobody.
4977
+ */
4978
+ async handleScriptToolCall(agentName, toolName, args) {
4979
+ if (toolName === CODE_EXECUTION_TOOL_NAME) {
4980
+ return 'Error: code_execution cannot be called from within a script';
4981
+ }
4982
+ const result = await this.dispatchScriptToolCall(agentName, toolName, args);
4983
+ if (result.endTurn) {
4984
+ // Deferred: applied to the final code_execution result (see
4985
+ // scriptDeferredEndTurn) — ending the turn mid-script would cancel the
4986
+ // stream while the script still runs and wedge the tool round.
4987
+ this.scriptDeferredEndTurn.add(agentName);
4988
+ }
4989
+ if (result.isError) {
4990
+ return `Error: ${result.error ?? 'tool call failed'}`;
4991
+ }
4992
+ if (result.data === undefined)
4993
+ return '';
4994
+ const agent = this.agents.get(agentName);
4995
+ const maxChars = agent ? this.getMaxToolResultChars(agent) : undefined;
4996
+ return toolResultDataToHistoryString(result.data, maxChars);
4997
+ }
4998
+ /**
4999
+ * Dispatch a script-inner tool call through the NORMAL dispatch path and
5000
+ * resolve with its result. The waiter intercept in handleProcessEvent
5001
+ * (keyed by the pytc- call ID) routes the tool-result event here instead
5002
+ * of into the agent's pending tool round.
5003
+ */
5004
+ dispatchScriptToolCall(agentName, toolName, args) {
5005
+ return new Promise((resolve) => {
5006
+ const callId = `pytc-${randomUUID()}`;
5007
+ // Host-side leak guard: the python runtime already raises TimeoutError
5008
+ // at toolCallTimeoutMs; this only reclaims the waiter if a dispatch path
5009
+ // loses the result entirely (so the map cannot grow unboundedly).
5010
+ const safetyMs = (this.codeExecutionConfig?.toolCallTimeoutMs ?? 270_000) + 30_000;
5011
+ const safety = setTimeout(() => {
5012
+ if (this.scriptToolWaiters.delete(callId)) {
5013
+ resolve({
5014
+ success: false,
5015
+ error: `tool '${toolName}' produced no result within ${Math.round(safetyMs / 1000)}s`,
5016
+ isError: true,
5017
+ });
5018
+ }
5019
+ }, safetyMs);
5020
+ safety.unref?.();
5021
+ this.scriptToolWaiters.set(callId, (result) => {
5022
+ clearTimeout(safety);
5023
+ resolve(result);
5024
+ });
5025
+ try {
5026
+ this.dispatchToolCall(agentName, { id: callId, name: toolName, input: args });
5027
+ }
5028
+ catch (error) {
5029
+ if (this.scriptToolWaiters.delete(callId)) {
5030
+ clearTimeout(safety);
5031
+ const err = error instanceof Error ? error : new Error(String(error));
5032
+ resolve({ success: false, error: err.message, isError: true });
5033
+ }
5034
+ }
5035
+ });
5036
+ }
5037
+ /** Abort a running script when the agent's turn dies underneath it. */
5038
+ abortAgentScript(agentName, reason) {
5039
+ const runner = this.codeExecutionRunners.get(agentName);
5040
+ if (runner?.busy) {
5041
+ runner.abort(reason);
5042
+ }
5043
+ }
4186
5044
  toMembraneToolResult(callId, afResult, maxChars) {
4187
5045
  if (afResult.isError) {
4188
5046
  return { toolUseId: callId, content: afResult.error ?? 'Unknown error', isError: true };
@@ -4401,6 +5259,27 @@ export class AgentFramework {
4401
5259
  this.dispatchChannelToolCall(agentName, enrichedCall);
4402
5260
  return;
4403
5261
  }
5262
+ // Client-side programmatic tool calling: run a python script that calls
5263
+ // the agent's other tools in-process (script-inner calls come back through
5264
+ // this very dispatcher with waiter-tracked call IDs).
5265
+ if (enrichedCall.name === CODE_EXECUTION_TOOL_NAME && this.codeExecutionConfig) {
5266
+ this.dispatchCodeExecutionToolCall(agentName, enrichedCall);
5267
+ return;
5268
+ }
5269
+ // prose_help: on-demand routing reference for explicit-prose agents.
5270
+ // Answered inline — the grammar is a tool RESULT (model-requested), never
5271
+ // ambient context (see PROSE_ROUTING_HELP / teach-by-bounce).
5272
+ if (enrichedCall.name === 'prose_help') {
5273
+ this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'prose_help', callId: enrichedCall.id, durationMs: 0 });
5274
+ this.pushEvent({
5275
+ type: 'tool-result',
5276
+ callId: enrichedCall.id,
5277
+ agentName,
5278
+ moduleName: 'framework',
5279
+ result: { success: true, data: PROSE_ROUTING_HELP },
5280
+ });
5281
+ return;
5282
+ }
4404
5283
  const sepIndex = enrichedCall.name.indexOf('--');
4405
5284
  const moduleName = sepIndex >= 0 ? enrichedCall.name.substring(0, sepIndex) : 'unknown';
4406
5285
  const toolName = sepIndex >= 0 ? call.name.substring(sepIndex + 2) : call.name;
@@ -4723,6 +5602,13 @@ export class AgentFramework {
4723
5602
  if (err.name === 'OverBudgetError') {
4724
5603
  return { errorType: 'over_budget' };
4725
5604
  }
5605
+ // context-manager's fatal coverage invariant (fix/coverage-invariant-fatal,
5606
+ // 2026-07-26): a compile REFUSES rather than shipping a context with
5607
+ // silently-dropped messages. Recovery is identical to over_budget — kick
5608
+ // the compression drain so summaries cover the un-represented span.
5609
+ if (err.name === 'UncoveredDropError') {
5610
+ return { errorType: 'context_refusal' };
5611
+ }
4726
5612
  return {};
4727
5613
  }
4728
5614
  /**
@@ -4777,7 +5663,19 @@ export class AgentFramework {
4777
5663
  // (e.g. a reason string that crossed a serialization boundary). It matches
4778
5664
  // CM's current OverBudgetError wording and MAY rot if CM rewords it — the
4779
5665
  // errorType gate is the one that's load-bearing.
4780
- const overBudget = errorType === 'over_budget' || /exceed hard budget/i.test(reason);
5666
+ const overBudget = errorType === 'over_budget' ||
5667
+ errorType === 'context_refusal' ||
5668
+ /exceed hard budget|no summary covers/i.test(reason);
5669
+ // Observability: a refused compile means the agent cannot think AT ALL
5670
+ // this turn — surface it on the ops-alert pipeline (ops:alert trace +
5671
+ // webhook → fleet-watch) immediately, not only at the hard-down streak.
5672
+ // The per-(agent,kind) cooldown throttles repeats; failures.log already
5673
+ // has the per-exhaustion record from logFailure above.
5674
+ if (overBudget) {
5675
+ this.opsAlert('context-refusal', agentName, `compile refused (${errorType ?? 'unclassified'}): ${reason.slice(0, 300)} — ` +
5676
+ `drain breaker ${agent ? 'kicking' : 'unavailable (no agent handle)'}; ` +
5677
+ `if this persists, raise contextBudgetTokens or overBudgetGraceRatio.`, { skipLog: true });
5678
+ }
4781
5679
  if (agent && overBudget && !this.overBudgetDrainInFlight.has(agentName)) {
4782
5680
  this.overBudgetDrainInFlight.add(agentName);
4783
5681
  void (async () => {
@@ -4924,15 +5822,35 @@ export class AgentFramework {
4924
5822
  // yes, wake no).
4925
5823
  onRouteFailure: ({ channelId, reason, textLen }) => {
4926
5824
  try {
5825
+ // Render a human-readable channel name when we can — a bare
5826
+ // snowflake in the marker is unresolvable for the agent (the
5827
+ // 2026-07-21 incident read as "a stale artifact", not a live
5828
+ // failure). The marker is `system: true`, so it is never
5829
+ // conversational and never influences routing.
5830
+ const label = channelId
5831
+ ? this.channelRegistry?.getDescriptor(channelId)?.label
5832
+ : undefined;
5833
+ const where = channelId
5834
+ ? label && label !== channelId
5835
+ ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})`
5836
+ : channelId
5837
+ : 'the channel';
4927
5838
  this.addMessage('user', [{
4928
5839
  type: 'text',
4929
- text: `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${channelId ?? 'the channel'} (${reason}). It was saved to your archive but the human did not receive it.`,
5840
+ text: `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${where} (${reason}). It was saved to your archive but the human did not receive it.`,
4930
5841
  }], { system: true, kind: 'discord-send-failed', channelId: channelId ?? '', reason });
4931
5842
  }
4932
5843
  catch (err) {
4933
5844
  console.error('onRouteFailure: failed to record send-failure marker:', err);
4934
5845
  }
4935
5846
  },
5847
+ // A channel opened without the agent asking (policy admission or an
5848
+ // open forced by delivery) must be announced in the window, with the
5849
+ // opt-out named. Routed here so registry-driven opens and framework-
5850
+ // driven opens produce the same durable notice.
5851
+ onChannelAutoOpened: ({ conversationId, source, channels }) => {
5852
+ this.recordChannelAutoOpenNotice(conversationId, channels, source);
5853
+ },
4936
5854
  });
4937
5855
  // Host capabilities advertised during the MCP handshake — stored so
4938
5856
  // servers can also be connected later at runtime (connectMcplServer).
@@ -5785,6 +6703,33 @@ export class AgentFramework {
5785
6703
  this.checkpointManager.recordCheckpoint(serverId, fs, result.state);
5786
6704
  }
5787
6705
  }
6706
+ // INVARIANT: engaging a channel opens it. A successful explicit send
6707
+ // into a channel the registry knows as closed drives the same open
6708
+ // flow as channel_open (durable desired state, server subscribe), so
6709
+ // there is no such thing as posting into a channel that stays
6710
+ // half-alive (no typing, no reactions, no inbound forwarding).
6711
+ // Fire-and-forget: the send already succeeded; the open must not
6712
+ // delay or fail the tool result.
6713
+ if (this.channelRegistry &&
6714
+ SEND_ENGAGEMENT_TOOLS.has(toolName) &&
6715
+ result.isError !== true) {
6716
+ const target = extractChannelIdFromToolResult(result.content) ??
6717
+ (typeof args.channelId === 'string' ? args.channelId : undefined);
6718
+ if (target) {
6719
+ this.channelRegistry
6720
+ .openIfClosedForSend(target, serverId)
6721
+ .then(({ status, channelId, label }) => {
6722
+ if (status === 'opened') {
6723
+ console.error(`[channel] ${agentName}: explicit ${toolName} into closed channel ${target} — opened (send implies engagement)`);
6724
+ this.recordChannelAutoOpenNotice(agentName, [{ channelId: channelId ?? target, label }], 'opened-by-reply');
6725
+ }
6726
+ else if (status === 'open-failed') {
6727
+ console.error(`[channel] ${agentName}: ${toolName} delivered to ${target} but the open-on-send failed — channel remains half-alive, will reconcile`);
6728
+ }
6729
+ })
6730
+ .catch((err) => console.error('[channel] open-on-send error:', err));
6731
+ }
6732
+ }
5788
6733
  // Convert MCP tool result to framework ToolResult.
5789
6734
  // When the result contains non-text blocks (e.g. images from an MCP
5790
6735
  // tool like zulip-mcp's fetch_attachment), pass the full content array
@@ -6014,12 +6959,31 @@ export class AgentFramework {
6014
6959
  const human = seconds >= 3600 ? `${(seconds / 3600).toFixed(1)}h`
6015
6960
  : seconds >= 60 ? `${Math.round(seconds / 60)}m`
6016
6961
  : `${Math.round(seconds)}s`;
6017
- // Announce in the sticky channel (best-effort; never blocks the result).
6962
+ // Announce in the TURN's frozen locus (best-effort; never blocks the
6963
+ // result). The sleep tool runs mid-turn, so the pin is live — same
6964
+ // authority as every other speech path, no live re-resolution.
6018
6965
  if (announce && this.channelRegistry) {
6019
6966
  const text = input.message ?? `💤 Going quiet for ${human}. I'll still see messages, but won't respond until I wake.`;
6020
- this.channelRegistry.routeSpeech(agentName, text).catch((err) => {
6021
- console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
6022
- });
6967
+ const agent = this.agents.get(agentName);
6968
+ if (agent?.proseRouting === 'explicit') {
6969
+ // Explicit mode: announce to the turn's sticky prose target if the
6970
+ // model has set one; otherwise stay quiet — never guess a channel.
6971
+ const target = this.proseTargetPins.get(agentName);
6972
+ if (target) {
6973
+ this.channelRegistry.routeSpeech(agentName, text, target).catch((err) => {
6974
+ console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
6975
+ });
6976
+ }
6977
+ else {
6978
+ console.error(`[sleep] ${agentName}: no prose target this turn — sleep announcement not posted (explicit mode never guesses)`);
6979
+ }
6980
+ }
6981
+ else {
6982
+ const locus = this.turnLocusPins.get(agentName) ?? null;
6983
+ this.channelRegistry.routeSpeech(agentName, text, locus).catch((err) => {
6984
+ console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
6985
+ });
6986
+ }
6023
6987
  }
6024
6988
  console.error(`[sleep] agent=${agentName} seconds=${seconds} announce=${announce} until=${new Date(until).toISOString()}`);
6025
6989
  // endTurn: the agent stops here and goes idle for the duration.