@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
package/src/framework.ts CHANGED
@@ -10,6 +10,7 @@ import type {
10
10
  MessageQuery,
11
11
  MessageQueryResult,
12
12
  StoredMessage,
13
+ TokenBudget,
13
14
  } from '@animalabs/context-manager';
14
15
  import type {
15
16
  FrameworkConfig,
@@ -58,12 +59,20 @@ import { FeatureSetManager } from './mcpl/feature-set-manager.js';
58
59
  import { ScopeManager } from './mcpl/scope-manager.js';
59
60
  import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
60
61
  import { PushHandler, type McplPushEvent } from './mcpl/push-handler.js';
62
+ import { parseProsePrefix } from './mcpl/prose-grammar.js';
63
+ import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
61
64
  import { InferenceRouter } from './mcpl/inference-router.js';
62
65
  import { ChannelRegistry } from './mcpl/channel-registry.js';
63
66
  import { ConversationRouter } from './mcpl/conversation-router.js';
64
67
  import { safeSlice } from './safe-slice.js';
65
68
  import type { WorkspaceModule } from './modules/workspace/index.js';
66
69
  import { toolResultDataToHistoryString } from './tool-result-history.js';
70
+ import { randomUUID } from 'node:crypto';
71
+ import { PyRunner, buildInjectedTools } from './code-execution/py-runner.js';
72
+ import {
73
+ buildCodeExecutionToolDefinition,
74
+ CODE_EXECUTION_TOOL_NAME,
75
+ } from './code-execution/tool-definition.js';
67
76
  import { splitProseSegments } from './prose-segments.js';
68
77
 
69
78
  /** Detect a supported image media type from magic bytes (the model API
@@ -109,6 +118,110 @@ function sniffImageMediaType(data: Buffer): string | undefined {
109
118
  const SILENCING_TOOLS = new Set([
110
119
  'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
111
120
  ]);
121
+
122
+ /**
123
+ * True when an injected message is real conversational input — something the
124
+ * agent might actually be replying to — rather than ambient machinery.
125
+ * System markers (`system: true` — send-failed notices, routing notices) and
126
+ * reactions (`chat:reaction` tag, MCPL RFC-001) don't count: they must not
127
+ * clear explicit-send suppression, and they never influence routing.
128
+ */
129
+ const isConversationalInjection = (metadata?: MessageMetadata): boolean => {
130
+ if (!metadata) return true;
131
+ const m = metadata as Record<string, unknown>;
132
+ if (m.system === true) return false;
133
+ if (Array.isArray(m.tags) && m.tags.includes('chat:reaction')) return false;
134
+ return true;
135
+ };
136
+
137
+ /**
138
+ * Explicit delivery tools whose successful use into a closed channel OPENS
139
+ * it (send implies engagement — see openIfClosedForSend). Bare tool names,
140
+ * matched after stripping the MCPL server prefix.
141
+ */
142
+ const SEND_ENGAGEMENT_TOOLS = new Set([
143
+ 'send_message', 'reply_message', 'send_dm', 'send_file', 'send_files',
144
+ ]);
145
+
146
+ /**
147
+ * Best-effort extraction of the delivered channel id from an MCPL send-tool
148
+ * result (the discord-mcpl send tools return JSON like
149
+ * `{"messageId":"…","channelId":"…"}` as a text block). Returns undefined on
150
+ * any shape mismatch — the caller falls back to the tool's input args.
151
+ */
152
+ function extractChannelIdFromToolResult(
153
+ content: Array<{ type: string; text?: string }> | undefined,
154
+ ): string | undefined {
155
+ const text = content?.find((c) => c.type === 'text' && c.text)?.text;
156
+ if (!text) return undefined;
157
+ try {
158
+ const parsed = JSON.parse(text) as { channelId?: unknown };
159
+ return typeof parsed.channelId === 'string' && parsed.channelId.length > 0
160
+ ? parsed.channelId
161
+ : undefined;
162
+ } catch {
163
+ return undefined;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * True when an incoming channel message explicitly addressed the agent.
169
+ * Primary signal is the MCPL RFC-001 `chat:addressed` tag (mention / reply /
170
+ * DM, as classified by the server); the metadata booleans are the fallback
171
+ * for servers that predate tags. Addressed messages outrank ambient chatter
172
+ * when picking a turn's frozen speech locus.
173
+ */
174
+ const isAddressedMessage = (
175
+ tags?: string[],
176
+ _metadata?: Record<string, unknown>,
177
+ ): boolean => Array.isArray(tags) && tags.includes('chat:addressed');
178
+
179
+ // parseProsePrefix moved to ./mcpl/prose-grammar.ts — ONE grammar shared with
180
+ // the outgoing-stream router (Spec 14.3), so streamed chunks and delivered
181
+ // envelopes can never disagree on what is a prefix.
182
+
183
+ /** One-time primer appended when an agent's proseRouting mode changes. */
184
+ function proseModePrimer(mode: 'explicit' | 'locus'): string {
185
+ if (mode === 'locus') {
186
+ return '[prose-routing] Mode change: your plain text auto-routes to the ' +
187
+ 'conversational locus again. `>>` destination prefixes are no longer needed.';
188
+ }
189
+ // Deliberately terse: a short event-style notice is classifier-safe from
190
+ // the user role (ablation D, 2026-07-24), while the full grammar as a user
191
+ // message drew a deterministic reasoning_extraction refusal (ablation A/B).
192
+ // The grammar is TAUGHT BY BOUNCE: the first undelivered text produces a
193
+ // notice with the exact resend syntax, and the full reference is available
194
+ // on demand via the prose_help tool (a tool RESULT is model-requested
195
+ // content — the safest role there is). Nothing is injected into the system
196
+ // prompt: those bytes belong to the recipe, and the deepest KV prefix must
197
+ // not vary with framework code.
198
+ return (
199
+ '[prose-routing] Output mode changed: plain text is delivered only with a ' +
200
+ 'destination prefix line, e.g. ">>#channel-name your text". ' +
201
+ 'The prose_help tool shows the full syntax.'
202
+ );
203
+ }
204
+
205
+ /** Full `>>` routing reference, served as the prose_help TOOL RESULT (never
206
+ * injected ambiently — see proseModePrimer note). */
207
+ const PROSE_ROUTING_HELP =
208
+ 'Plain-text output routing (explicit mode):\n' +
209
+ ' >>#channel-name … or >>@person … (DM) or >>service:guild:id …\n' +
210
+ ' One message may hold several sections: each line starting with ">>" begins\n' +
211
+ ' a new envelope, routed to its own destination independently.\n' +
212
+ ' The first destination in a turn applies to the rest of that turn.\n' +
213
+ ' Append " !" after the destination (e.g. ">>#ops !") to start your next turn\n' +
214
+ ' immediately when this one ends, instead of pausing until the next event.\n' +
215
+ ' >>skip_reply — text stays in your context only, like the skip_reply tool.\n' +
216
+ 'Text without a destination is not delivered: it is retained, and a notice will\n' +
217
+ 'prompt you to resend — reply e.g. ">>#channel {{unsent}}" to deliver the retained\n' +
218
+ 'text unchanged. Send tools (send_message, send_dm, …) are unaffected.';
219
+
220
+ const PROSE_HELP_TOOL: import('./types/index.js').ToolDefinition = {
221
+ name: 'prose_help',
222
+ description: 'Show the plain-text output routing syntax (destination prefixes, resend token, skip).',
223
+ inputSchema: { type: 'object' as const, properties: {} },
224
+ };
112
225
  /** Strip the `server--` MCPL prefix from a tool name. */
113
226
  const bareToolName = (n: string): string => n.split('--').pop()!;
114
227
  import { CheckpointManager } from './mcpl/checkpoint-manager.js';
@@ -391,20 +504,38 @@ export class AgentFramework {
391
504
  private processLoggingBroadcast: boolean;
392
505
  private activeStreams: Map<string, Promise<void>> = new Map();
393
506
 
394
- /** Per-agent output locus pinned for the CURRENT logical turn (see
395
- * resolveTurnLocus in driveStream). Lives here — not in driveStream
396
- * locals — so the pin survives a context-budget stream restart, which
397
- * continues the same logical turn in a fresh driveStream. A mid-turn
398
- * injected channel message replaces the pin for the next conversational
399
- * round; otherwise it remains stable. Cleared at the start of every
400
- * non-restart turn. */
507
+ /** Per-agent output locus FROZEN for the CURRENT logical turn. Resolved
508
+ * eagerly in startAgentStream (home → addressed trigger → global default)
509
+ * and never moved until the next turn: mid-turn injected messages — ambient
510
+ * chatter, reactions, system markers — must not hijack where the agent's
511
+ * plain prose lands (2026-07-21 Cairn lounge misroute). Lives here — not in
512
+ * driveStream locals — so the pin survives a context-budget stream restart,
513
+ * which continues the same logical turn in a fresh driveStream. Cleared or
514
+ * re-set at the start of every non-restart turn. */
401
515
  private turnLocusPins: Map<string, string> = new Map();
402
- /** A tool boundary injected fresh external input into the live stream.
403
- * Presence (including a null value) tells driveStream to clear sticky
404
- * explicit-send suppression before handling the next model round. A
405
- * string also moves that round's reply locus to the newest injected
406
- * channel. */
407
- private midTurnRoutingResets: Map<string, string | null> = new Map();
516
+ /** A tool boundary injected fresh CONVERSATIONAL input (a real message —
517
+ * not a reaction or a system marker) into the live stream. Tells
518
+ * driveStream to clear sticky explicit-send suppression before handling
519
+ * the next model round. Never affects routing: the turn locus is frozen. */
520
+ private midTurnInputSignals: Set<string> = new Set();
521
+ /** Last outbound locus announced to each agent as a durable `[routing]`
522
+ * window message. Announce-on-change only: steady state emits nothing
523
+ * (KV-safe, no per-turn chatter). In-memory — after a process restart the
524
+ * first turn's locus is announced once to re-establish the baseline. */
525
+ private lastAnnouncedLocus: Map<string, string | null> = new Map();
526
+
527
+ // ---- Explicit prose routing (docs/explicit-prose-routing.md) ----------
528
+ /** Latest bounced (undelivered) prose per agent — the `{{unsent}}` source. */
529
+ private proseClipboards: Map<string, string> = new Map();
530
+ /** Sticky per-TURN delivery target set by the turn's first `>>` prefix.
531
+ * Cleared at every non-restart turn start; survives context restarts. */
532
+ private proseTargetPins: Map<string, string> = new Map();
533
+ /** Agents whose current turn requested `!` continuation — re-woken when the
534
+ * turn completes instead of pausing until the next external event. */
535
+ private proseContinuations: Set<string> = new Set();
536
+ /** Consecutive bounce-wakes per agent; capped so an agent that keeps
537
+ * emitting unprefixed prose can't wake-loop (notices still append). */
538
+ private proseBounceStreaks: Map<string, number> = new Map();
408
539
  private pendingAssistantBlocks: Map<string, ContentBlock[]> = new Map();
409
540
  /** Streams the FRAMEWORK cancelled for non-terminal reasons, keyed
410
541
  * `${agentName}:${streamId}`: an endTurn tool result or a context-budget
@@ -504,6 +635,18 @@ export class AgentFramework {
504
635
  /** Forks whose TTL closure turn has been queued — disposed (removed from
505
636
  * agents/agentConfigs/conversationAgentHomes) when their stream ends. */
506
637
  private closingConversationAgents: Set<string> = new Set();
638
+ // Client-side programmatic tool calling (`code_execution`). Null unless
639
+ // config.codeExecution.enabled. One PyRunner per agent (interpreter state
640
+ // is per-agent, like a per-agent container); waiters resolve script-inner
641
+ // tool calls dispatched through the normal dispatchToolCall path.
642
+ private codeExecutionConfig: import('./types/index.js').CodeExecutionConfig | null = null;
643
+ private codeExecutionRunners: Map<string, PyRunner> = new Map();
644
+ private scriptToolWaiters: Map<string, (result: ToolResult) => void> = new Map();
645
+ /** Agents whose running script hit an endTurn-carrying inner result —
646
+ * deferred and applied to the final code_execution result instead of
647
+ * cancelling the stream mid-script (which would wedge the turn). */
648
+ private scriptDeferredEndTurn: Set<string> = new Set();
649
+
507
650
  private mcplTools: import('./types/index.js').ToolDefinition[] = [];
508
651
  private mcplToolRefreshInFlight = false;
509
652
  private mcplToolRefreshPending = false;
@@ -771,6 +914,10 @@ export class AgentFramework {
771
914
  // that started with zero configured servers).
772
915
  framework.mcplInferenceRoutingConfig = config.inferenceRouting ?? null;
773
916
 
917
+ // Client-side programmatic tool calling (code_execution). Config is only
918
+ // retained when enabled — everything downstream gates on the field.
919
+ framework.codeExecutionConfig = config.codeExecution?.enabled ? config.codeExecution : null;
920
+
774
921
  // Initialize MCPL subsystems if configured
775
922
  if (config.mcplServers && config.mcplServers.length > 0) {
776
923
  // Validate tool prefixes: no collisions with module names or between servers
@@ -863,6 +1010,14 @@ export class AgentFramework {
863
1010
  this.running = false;
864
1011
  this.queue.close();
865
1012
 
1013
+ // Kill running code_execution scripts before cancelling streams: a
1014
+ // zombie script must not keep firing side-effectful tool calls into a
1015
+ // framework that is shutting down.
1016
+ for (const runner of this.codeExecutionRunners.values()) {
1017
+ runner.dispose();
1018
+ }
1019
+ this.codeExecutionRunners.clear();
1020
+
866
1021
  // Cancel all active streams
867
1022
  for (const agent of this.agents.values()) {
868
1023
  if (agent.state.status === 'streaming' ||
@@ -1160,6 +1315,9 @@ export class AgentFramework {
1160
1315
  ...(this.getWorkspaceModule()
1161
1316
  ? [AgentFramework.SAVE_IMAGE_TOOL, AgentFramework.READ_IMAGE_TOOL]
1162
1317
  : []),
1318
+ ...(this.codeExecutionConfig
1319
+ ? [buildCodeExecutionToolDefinition(this.codeExecutionConfig)]
1320
+ : []),
1163
1321
  ];
1164
1322
  }
1165
1323
 
@@ -1296,25 +1454,55 @@ export class AgentFramework {
1296
1454
  return agent.getRuntimeSettings();
1297
1455
  }
1298
1456
 
1457
+ /**
1458
+ * Apply runtime settings.
1459
+ *
1460
+ * `opts.persist: false` makes the change EPHEMERAL: it takes effect live but
1461
+ * is not written to the `framework/state` slot, so a restart reverts it. That
1462
+ * is the mode for operator experiments ("change it, watch, revert") — the
1463
+ * default persists, because a durable operational change should survive a
1464
+ * bounce like every other override.
1465
+ *
1466
+ * Nothing here notifies the agent. Discovery is deliberately PULL: the agent
1467
+ * can read current settings any time via the `agent_settings` tool's `get`
1468
+ * action, which costs no context perturbation. Pushing a notice injects text
1469
+ * into the very context being tuned — it invalidates the KV prefix and can
1470
+ * itself trip a classifier — so callers opt into that explicitly rather than
1471
+ * getting it by default.
1472
+ *
1473
+ * KNOWN LIMITATION of `persist: false`: the change still lands in the agent's
1474
+ * in-memory override map, and `persistAgentRuntimeSettings` writes that whole
1475
+ * map. So a LATER persisting call promotes any still-live ephemeral change to
1476
+ * durable as a side effect. Ephemeral means "not written by THIS call", not
1477
+ * "can never be written". Reset the ephemeral key before persisting something
1478
+ * else if that matters. Making it airtight needs per-key ephemeral tracking
1479
+ * inside Agent.
1480
+ */
1299
1481
  updateAgentRuntimeSettings(
1300
1482
  agentName: string,
1301
1483
  patch: AgentRuntimeSettingsPatch,
1484
+ opts?: { persist?: boolean },
1302
1485
  ): AgentRuntimeSettingsSnapshot {
1303
1486
  const agent = this.agents.get(agentName);
1304
1487
  if (!agent) throw new Error(`Unknown agent: ${agentName}`);
1305
1488
  const result = agent.updateRuntimeSettings(patch);
1306
- this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
1489
+ if (opts?.persist !== false) {
1490
+ this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
1491
+ }
1307
1492
  return result;
1308
1493
  }
1309
1494
 
1310
1495
  resetAgentRuntimeSettings(
1311
1496
  agentName: string,
1312
1497
  keys?: Array<keyof AgentRuntimeSettingsPatch>,
1498
+ opts?: { persist?: boolean },
1313
1499
  ): AgentRuntimeSettingsSnapshot {
1314
1500
  const agent = this.agents.get(agentName);
1315
1501
  if (!agent) throw new Error(`Unknown agent: ${agentName}`);
1316
1502
  const result = agent.resetRuntimeSettings(keys);
1317
- this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
1503
+ if (opts?.persist !== false) {
1504
+ this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
1505
+ }
1318
1506
  return result;
1319
1507
  }
1320
1508
 
@@ -1375,7 +1563,7 @@ export class AgentFramework {
1375
1563
  */
1376
1564
  async previewActivation(
1377
1565
  agentName: string,
1378
- opts?: { injections?: boolean }
1566
+ opts?: { injections?: boolean; budget?: TokenBudget }
1379
1567
  ): Promise<NormalizedRequest> {
1380
1568
  const agent = this.agents.get(agentName);
1381
1569
  if (!agent) {
@@ -1384,10 +1572,14 @@ export class AgentFramework {
1384
1572
 
1385
1573
  const tools = this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name));
1386
1574
 
1575
+ // An explicit budget compiles against a HYPOTHETICAL window instead of the
1576
+ // agent's live one. That also suppresses transition-settling in
1577
+ // compileWithInjections (which only settles when no budget is passed), so
1578
+ // previewing a smaller window cannot advance a converging descent.
1387
1579
  // Default: no dynamic injection gathering → fully transparent (no
1388
1580
  // inference, no Chronicle writes, no external RPC). Opt in explicitly.
1389
1581
  if (!opts?.injections) {
1390
- return agent.buildActivationRequest(tools, undefined);
1582
+ return agent.buildActivationRequest(tools, undefined, opts?.budget);
1391
1583
  }
1392
1584
 
1393
1585
  // Full-fidelity path: mirrors startAgentStream's injection gathering.
@@ -1422,7 +1614,47 @@ export class AgentFramework {
1422
1614
  }
1423
1615
  }
1424
1616
 
1425
- return agent.buildActivationRequest(tools, injections);
1617
+ return agent.buildActivationRequest(tools, injections, opts?.budget);
1618
+ }
1619
+
1620
+ /**
1621
+ * Non-committing preview of the FOLD PLAN at a hypothetical budget and/or
1622
+ * context settings — the numbers behind an operator's "what happens if I set
1623
+ * the budget to X" before they apply it.
1624
+ *
1625
+ * Unlike `previewActivation` (which renders a request), this returns the
1626
+ * picker's plan: resulting tokens, whether it fits, how many summaries would
1627
+ * have to be produced first, and the per-chunk fold levels. Commits nothing:
1628
+ * no resolution persistence, no compression enqueue, no transition
1629
+ * bookkeeping — see `AutobiographicalStrategy.previewContext`.
1630
+ *
1631
+ * Returns null when the agent's strategy has no fold plan (non-adaptive).
1632
+ */
1633
+ previewContextSettings(
1634
+ agentName: string,
1635
+ budgetTokens: number,
1636
+ overrides?: Record<string, unknown>,
1637
+ opts?: { render?: boolean },
1638
+ ): unknown {
1639
+ const agent = this.agents.get(agentName);
1640
+ if (!agent) throw new Error(`Unknown agent: ${agentName}`);
1641
+ const cm = agent.getContextManager() as unknown as {
1642
+ previewContext?: (
1643
+ budget: TokenBudget,
1644
+ overrides?: Record<string, unknown>,
1645
+ opts?: { render?: boolean },
1646
+ ) => unknown;
1647
+ };
1648
+ if (typeof cm.previewContext !== 'function') return null;
1649
+ // reserveForResponse mirrors the live compile so the previewed middle is
1650
+ // comparable to what the agent actually gets. `opts.render` additionally
1651
+ // returns the rendered entries — opt-in, since they are megabytes on a
1652
+ // large store, and older context-manager builds ignore the argument.
1653
+ return cm.previewContext(
1654
+ { maxTokens: budgetTokens, reserveForResponse: agent.maxTokens },
1655
+ overrides,
1656
+ opts,
1657
+ );
1426
1658
  }
1427
1659
 
1428
1660
  /**
@@ -3155,6 +3387,19 @@ export class AgentFramework {
3155
3387
  // Anthropic API. If we let modules or MCPL handlers run first they may add
3156
3388
  // messages between tool_use and tool_result, causing a 400 error.
3157
3389
  if (event.type === 'tool-result') {
3390
+ // Script-inner tool calls (client-side programmatic tool calling):
3391
+ // resolve the waiter and stop. These calls belong to a RUNNING
3392
+ // code_execution script, not to the agent's pending tool round — they
3393
+ // must not touch agent state (and must resolve even for ephemeral
3394
+ // agents that aren't in this.agents, or after the agent's own round
3395
+ // has already settled).
3396
+ const scriptWaiter = this.scriptToolWaiters.get(event.callId);
3397
+ if (scriptWaiter) {
3398
+ this.scriptToolWaiters.delete(event.callId);
3399
+ scriptWaiter(event.result);
3400
+ return;
3401
+ }
3402
+
3158
3403
  const agent = this.agents.get(event.agentName);
3159
3404
  if (agent) {
3160
3405
  this.touchEphemeralRun(event.agentName, true);
@@ -3257,23 +3502,17 @@ export class AgentFramework {
3257
3502
  }
3258
3503
  }
3259
3504
 
3260
- // A newly injected message begins a new conversational round inside
3261
- // the same provider inference. Remember that boundary for
3262
- // driveStream: an explicit send in the preceding round must not
3263
- // silence the reply, and the newest channel-bearing injection is
3264
- // now the reply locus. Map presence matters even without a channel
3265
- // (CLI/module input): it still clears the prior send suppression.
3266
- if (midTurnInjections.length > 0) {
3267
- const injectedChannelId = midTurnInjections.reduce<string | null>(
3268
- (latest, injection) => {
3269
- const candidate = injection.metadata?.channelId;
3270
- return typeof candidate === 'string' && candidate.length > 0
3271
- ? candidate
3272
- : latest;
3273
- },
3274
- null,
3275
- );
3276
- this.midTurnRoutingResets.set(agent.name, injectedChannelId);
3505
+ // A newly injected CONVERSATIONAL message begins a new
3506
+ // conversational round inside the same provider inference. Remember
3507
+ // that boundary for driveStream: an explicit send in the preceding
3508
+ // round must not silence the reply. Routing is deliberately NOT
3509
+ // affected — the turn locus is frozen at turn start, so ambient
3510
+ // chatter, reactions, and system markers injected mid-turn can no
3511
+ // longer hijack where the agent's prose lands. Non-conversational
3512
+ // injections (reactions, `system: true` markers) don't even clear
3513
+ // the send suppression: nothing new was said to the agent.
3514
+ if (midTurnInjections.some((inj) => isConversationalInjection(inj.metadata))) {
3515
+ this.midTurnInputSignals.add(agent.name);
3277
3516
  }
3278
3517
 
3279
3518
  // Check if any tool result requested endTurn
@@ -3493,6 +3732,7 @@ export class AgentFramework {
3493
3732
  content: ContentBlock[];
3494
3733
  timestamp: string;
3495
3734
  metadata?: Record<string, unknown>;
3735
+ tags?: string[];
3496
3736
  triggerInference?: boolean;
3497
3737
  }): Promise<void> {
3498
3738
  const metadata: Record<string, unknown> = {
@@ -3504,6 +3744,7 @@ export class AgentFramework {
3504
3744
  serverId: event.serverId,
3505
3745
  };
3506
3746
  if (event.threadId) metadata.threadId = event.threadId;
3747
+ if (event.tags) metadata.tags = event.tags;
3507
3748
 
3508
3749
  // Per-channel conversation routing: messages go to the channel's fork
3509
3750
  // agent (spawned from the template on first qualifying message), never
@@ -3517,6 +3758,7 @@ export class AgentFramework {
3517
3758
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
3518
3759
 
3519
3760
  if (event.triggerInference) {
3761
+ const addressed = isAddressedMessage(event.tags, event.metadata);
3520
3762
  for (const agentName of this.agents.keys()) {
3521
3763
  this.pendingRequests.push({
3522
3764
  agentName,
@@ -3526,6 +3768,9 @@ export class AgentFramework {
3526
3768
  // Route this turn's auto-published speech back to THIS channel, not
3527
3769
  // the global most-recent-inbound locus (item-3 redux, trunk agents).
3528
3770
  channelId: event.channelId,
3771
+ // Addressed messages outrank ambient chatter when a batched wake
3772
+ // picks the turn's frozen speech locus.
3773
+ addressed,
3529
3774
  });
3530
3775
  }
3531
3776
  }
@@ -3545,6 +3790,7 @@ export class AgentFramework {
3545
3790
  author: { id: string; name: string };
3546
3791
  content: ContentBlock[];
3547
3792
  metadata?: Record<string, unknown>;
3793
+ tags?: string[];
3548
3794
  triggerInference?: boolean;
3549
3795
  },
3550
3796
  messageMetadata: Record<string, unknown>,
@@ -3623,6 +3869,7 @@ export class AgentFramework {
3623
3869
  // A fork's home channel wins in routeSpeech regardless, but carry the
3624
3870
  // triggering channel too so the trunk/active path stays consistent.
3625
3871
  channelId: event.channelId,
3872
+ addressed: isAddressedMessage(event.tags, event.metadata),
3626
3873
  });
3627
3874
  }
3628
3875
  }
@@ -3800,6 +4047,7 @@ export class AgentFramework {
3800
4047
  event.serverId,
3801
4048
  triggerChannel.channelId,
3802
4049
  triggerChannel.label,
4050
+ triggerChannel.metadata,
3803
4051
  );
3804
4052
  }
3805
4053
 
@@ -3809,7 +4057,17 @@ export class AgentFramework {
3809
4057
  featureSet: event.featureSet,
3810
4058
  eventId: event.eventId,
3811
4059
  triggered: event.triggerInference ?? false,
4060
+ ...(event.tags ? { tags: event.tags } : {}),
3812
4061
  };
4062
+ // `origin.channelId` is the server-internal raw id (a bare Discord
4063
+ // snowflake) — unroutable as a locus and unresolvable by the agent. Store
4064
+ // the composite MCPL id instead wherever we can derive it, so anything
4065
+ // downstream that reads `metadata.channelId` (routing, provenance, the
4066
+ // agent itself) sees the qualified form. The raw value stays available on
4067
+ // servers that also send `rawChannelId`-style origin fields.
4068
+ if (triggerChannel) {
4069
+ metadata.channelId = triggerChannel.channelId;
4070
+ }
3813
4071
 
3814
4072
  const content = [...event.content];
3815
4073
  const addressedWhileClosed = triggerChannel &&
@@ -3867,6 +4125,10 @@ export class AgentFramework {
3867
4125
  source: event.serverId,
3868
4126
  timestamp: Date.now(),
3869
4127
  channelId: triggerChannel?.channelId,
4128
+ // DMs and addressed-while-closed messages arrive as push events;
4129
+ // they must outrank ambient chatter in a batched wake's locus
4130
+ // selection just like their channels/incoming counterparts.
4131
+ addressed: isAddressedMessage(event.tags, event.origin),
3870
4132
  });
3871
4133
  }
3872
4134
  }
@@ -3890,13 +4152,29 @@ export class AgentFramework {
3890
4152
  */
3891
4153
  private derivePushEventChannel(
3892
4154
  origin: Record<string, unknown> | undefined,
3893
- ): { channelId: string; label?: string } | undefined {
4155
+ ): { channelId: string; label?: string; metadata?: Record<string, unknown> } | undefined {
3894
4156
  if (!origin) return undefined;
3895
- const label = typeof origin.channelName === 'string' ? origin.channelName : undefined;
4157
+ let label = typeof origin.channelName === 'string' && origin.channelName ? origin.channelName : undefined;
4158
+ let metadata: Record<string, unknown> | undefined;
4159
+
4160
+ // A DM has no channelName — label it by the PERSON and carry their
4161
+ // identity, so DM registrations are people-first (`DM: antra`, matchable
4162
+ // by `>>@name` / `<@id>` mention tokens) instead of bare snowflakes.
4163
+ const isDM = origin.isDM === true || origin.guildId === null;
4164
+ if (isDM && typeof origin.authorName === 'string' && origin.authorName) {
4165
+ label = label ?? `DM: ${origin.authorName}`;
4166
+ metadata = {
4167
+ channelType: 'dm',
4168
+ recipientName: origin.authorName,
4169
+ ...(typeof origin.authorId === 'string' && origin.authorId
4170
+ ? { recipientId: origin.authorId }
4171
+ : {}),
4172
+ };
4173
+ }
3896
4174
 
3897
4175
  const explicit = origin.mcplChannelId;
3898
4176
  if (typeof explicit === 'string' && explicit) {
3899
- return { channelId: explicit, label };
4177
+ return { channelId: explicit, ...(label ? { label } : {}), ...(metadata ? { metadata } : {}) };
3900
4178
  }
3901
4179
 
3902
4180
  // Discord fallback: reconstruct the composite from origin parts. `guildId`
@@ -3904,7 +4182,11 @@ export class AgentFramework {
3904
4182
  if (origin.source === 'discord' && typeof origin.channelId === 'string' && origin.channelId) {
3905
4183
  const guild =
3906
4184
  typeof origin.guildId === 'string' && origin.guildId ? origin.guildId : 'dm';
3907
- return { channelId: `discord:${guild}:${origin.channelId}`, label };
4185
+ return {
4186
+ channelId: `discord:${guild}:${origin.channelId}`,
4187
+ ...(label ? { label } : {}),
4188
+ ...(metadata ? { metadata } : {}),
4189
+ };
3908
4190
  }
3909
4191
 
3910
4192
  return undefined;
@@ -3992,17 +4274,302 @@ export class AgentFramework {
3992
4274
  const trigger = requests[0];
3993
4275
  // Route this turn's auto-published speech to the channel that triggered
3994
4276
  // it (item-3 redux). A batched wake may carry several triggering channels
3995
- // (messages arrived in >1 channel while the agent was busy/idle) — reply
3996
- // in the MOST-RECENT one: it matches the legacy last-inbound semantics and
3997
- // is the message the agent is most likely answering. The common case is
3998
- // 1 message → 1 channel (unambiguous). Non-channel wakes carry no
3999
- // channelId, leaving the field undefined → global fallback. `reduce`
4000
- // keeps the last defined channelId across the (FIFO-ordered) batch.
4001
- const triggerChannel = requests.reduce<string | undefined>(
4002
- (acc, r) => r.channelId ?? acc,
4003
- undefined,
4277
+ // (messages arrived in >1 channel while the agent was busy/idle):
4278
+ // 1. the most recent ADDRESSED channel wins (mention / reply / DM —
4279
+ // someone explicitly spoke TO the agent);
4280
+ // 2. else the most recent channel-bearing request (ambient message in
4281
+ // an open channel — legacy last-inbound semantics).
4282
+ // Ambient chatter must not outrank an addressed message just by being
4283
+ // newest (2026-07-21 Cairn lounge misroute, turn-start variant).
4284
+ // Non-channel wakes (heartbeats, module events, reactions — which never
4285
+ // carry channelId) leave both undefined → global fallback.
4286
+ let ambientChannel: string | undefined;
4287
+ let addressedChannel: string | undefined;
4288
+ for (const r of requests) {
4289
+ if (!r.channelId) continue;
4290
+ ambientChannel = r.channelId;
4291
+ if (r.addressed) addressedChannel = r.channelId;
4292
+ }
4293
+ const triggerChannel = addressedChannel ?? ambientChannel;
4294
+ const triggerAddressed = addressedChannel !== undefined;
4295
+ await this.startAgentStream(agent, {
4296
+ ...trigger,
4297
+ channelId: triggerChannel,
4298
+ addressed: triggerAddressed,
4299
+ });
4300
+ }
4301
+ }
4302
+
4303
+ /**
4304
+ * Announce-on-change locus notice (turn-frozen routing). Appends a durable
4305
+ * `[routing]` window message when the turn's effective outbound locus
4306
+ * differs from the last one announced — the agent must never have to guess
4307
+ * where its plain prose lands. Durable append (never retracted) keeps the
4308
+ * KV prefix stable; change-only keeps it out of steady-state turns. The
4309
+ * first resolution after boot announces only a non-null locus ("prose has
4310
+ * no destination" is the unremarkable boot default). Never triggers
4311
+ * inference; never locus-eligible itself (system + no channel metadata).
4312
+ */
4313
+ private announceLocusIfChanged(agentName: string, locus: string | null): void {
4314
+ const hasBaseline = this.lastAnnouncedLocus.has(agentName);
4315
+ const prev = this.lastAnnouncedLocus.get(agentName) ?? null;
4316
+ if (hasBaseline ? prev === locus : locus === null) {
4317
+ if (!hasBaseline) this.lastAnnouncedLocus.set(agentName, locus);
4318
+ return;
4319
+ }
4320
+ this.lastAnnouncedLocus.set(agentName, locus);
4321
+
4322
+ const agent = this.agents.get(agentName);
4323
+ if (!agent) return;
4324
+
4325
+ let text: string;
4326
+ if (locus === null) {
4327
+ text =
4328
+ '[routing] Your plain speech currently has no channel — it stays in ' +
4329
+ 'your archive. Use an explicit send tool to reach a channel.';
4330
+ } else {
4331
+ const label = this.channelRegistry?.getDescriptor(locus)?.label;
4332
+ const shown =
4333
+ label && label !== locus
4334
+ ? `${label.startsWith('#') ? label : `#${label}`} (${locus})`
4335
+ : locus;
4336
+ text =
4337
+ `[routing] Your plain speech now lands in ${shown}. ` +
4338
+ 'Other channels need an explicit send tool.';
4339
+ }
4340
+
4341
+ try {
4342
+ const id = agent.getContextManager().addMessage(
4343
+ 'user',
4344
+ [{ type: 'text', text }],
4345
+ { system: true, kind: 'routing-notice' },
4346
+ );
4347
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
4348
+ console.error(
4349
+ `[routing] ${agentName}: locus ${prev ?? '(none)'} -> ${locus ?? '(none)'} (announced in window)`,
4350
+ );
4351
+ } catch (err) {
4352
+ console.error('announceLocusIfChanged: failed to record routing notice:', err);
4353
+ }
4354
+ }
4355
+
4356
+ /**
4357
+ * Durable window notice for a channel the agent did not ask to open —
4358
+ * subscription-policy admission, or an open forced by the agent's own
4359
+ * delivery into a closed channel. The agent must always learn that new
4360
+ * traffic will start flowing, and that `channel_close` opts out (their
4361
+ * decision outranks policy). System message: never triggers inference,
4362
+ * never conversational, never locus-eligible.
4363
+ */
4364
+ private recordChannelAutoOpenNotice(
4365
+ agentName: string | undefined,
4366
+ channels: Array<{ channelId: string; label?: string }>,
4367
+ cause: 'subscription-policy' | 'opened-by-delivery' | 'opened-by-reply',
4368
+ ): void {
4369
+ if (channels.length === 0) return;
4370
+ const shown = channels
4371
+ .map((c) => (c.label && c.label !== c.channelId ? `${c.label} (${c.channelId})` : c.channelId))
4372
+ .join(', ');
4373
+ const plural = channels.length > 1;
4374
+ const causeText =
4375
+ cause === 'subscription-policy'
4376
+ ? 'your subscription policy just admitted ' + (plural ? 'them' : 'it')
4377
+ : 'your own message into the closed channel engaged it';
4378
+ const text =
4379
+ `[channels] Now open: ${shown} — ${causeText}. Ongoing traffic from ` +
4380
+ (plural ? 'these channels' : 'this channel') +
4381
+ ` will reach you. If you don't want ${plural ? 'one of them' : 'it'}, use channel_close — ` +
4382
+ 'your choice sticks; neither policy nor a later send will silently reopen it.';
4383
+ try {
4384
+ // Through framework.addMessage for mid-turn safety (deferral +
4385
+ // hear-while-acting injection) — a delivery-forced open happens inside
4386
+ // a live turn, and a direct context-manager append there would store
4387
+ // the notice out of order. Targets the primary agent; connectome-host
4388
+ // runs trunk-only, so this is the delivering agent in practice.
4389
+ this.addMessage('user', [{ type: 'text', text }], { system: true, kind: 'channel-notice' });
4390
+ console.error(`[channel] auto-open notice (${cause}) -> ${agentName ?? 'primary'}: ${shown}`);
4391
+ } catch (err) {
4392
+ console.error('recordChannelAutoOpenNotice failed:', err);
4393
+ }
4394
+ }
4395
+
4396
+ /**
4397
+ * Explicit-prose-routing delivery gateway (docs/explicit-prose-routing.md).
4398
+ * Every prose segment of an `explicit`-mode agent flows through here:
4399
+ * parse the `>>` prefix, resolve the target, deliver — or bounce to the
4400
+ * clipboard with a resend notice. Misdelivery is structurally impossible:
4401
+ * nothing is ever sent to a destination the model did not name (directly
4402
+ * or via the turn's sticky target).
4403
+ */
4404
+ private async deliverProse(agent: Agent, rawText: string): Promise<void> {
4405
+ // Multi-envelope: a single prose block may address SEVERAL destinations —
4406
+ // every line beginning with `>>` opens a new envelope, routed
4407
+ // independently (first live use: Tilde 2026-07-24, one block carrying a
4408
+ // letter to #tilde-mythos AND a `>>#tilde` aside; the single-envelope
4409
+ // parser delivered the aside literally into the first destination —
4410
+ // exactly the leak class this mode exists to kill). Text before the
4411
+ // first `>>` is its own unprefixed envelope (sticky/bounce as usual).
4412
+ const lines = rawText.split('\n');
4413
+ const envelopes: string[] = [];
4414
+ let current: string[] = [];
4415
+ for (const line of lines) {
4416
+ // Only a REAL prefix line opens an envelope: `>>` immediately followed
4417
+ // by a target token. A quoted `>> arrow` (space after) stays body text.
4418
+ if (/^>>\S/.test(line.trimStart()) && current.length > 0) {
4419
+ envelopes.push(current.join('\n'));
4420
+ current = [];
4421
+ }
4422
+ current.push(line);
4423
+ }
4424
+ if (current.length > 0) envelopes.push(current.join('\n'));
4425
+
4426
+ for (const envelope of envelopes) {
4427
+ if (!envelope.trim()) continue;
4428
+ await this.deliverProseEnvelope(agent, envelope.replace(/^\s+(?=>>)/, ''));
4429
+ }
4430
+ }
4431
+
4432
+ private async deliverProseEnvelope(agent: Agent, rawText: string): Promise<void> {
4433
+ const name = agent.name;
4434
+ const parsed = parseProsePrefix(rawText);
4435
+ if (parsed.continueTurn) this.proseContinuations.add(name);
4436
+
4437
+ if (parsed.kind === 'private') {
4438
+ // The text already lives in the assistant message (window/chronicle);
4439
+ // "delivery" is deliberately a no-op. Consumes the retained text if the
4440
+ // segment embedded it.
4441
+ if (parsed.body.includes('{{unsent}}')) this.proseClipboards.delete(name);
4442
+ console.error(`[prose] ${name}: >>skip_reply — ${parsed.body.length} chars kept in context (not sent)`);
4443
+ return;
4444
+ }
4445
+
4446
+ let targetChannel: string;
4447
+ let body: string;
4448
+ if (parsed.kind === 'target') {
4449
+ const res = this.channelRegistry!.resolveProseTarget(parsed.target!);
4450
+ if ('error' in res) {
4451
+ this.bounceProse(agent, parsed.body, res.error, res.candidates);
4452
+ return;
4453
+ }
4454
+ targetChannel = res.channelId;
4455
+ // Sticky within the turn: later unprefixed segments follow this target.
4456
+ this.proseTargetPins.set(name, targetChannel);
4457
+ body = parsed.body;
4458
+ } else {
4459
+ const sticky = this.proseTargetPins.get(name);
4460
+ if (!sticky) {
4461
+ this.bounceProse(agent, rawText, 'no destination prefix and no destination set yet this turn');
4462
+ return;
4463
+ }
4464
+ targetChannel = sticky;
4465
+ body = rawText;
4466
+ }
4467
+
4468
+ const usedClipboard = body.includes('{{unsent}}');
4469
+ if (usedClipboard) {
4470
+ body = body.replaceAll('{{unsent}}', this.proseClipboards.get(name) ?? '');
4471
+ }
4472
+ if (!body.trim()) {
4473
+ console.error(`[prose] ${name}: empty body after prefix/substitution — nothing to send`);
4474
+ return;
4475
+ }
4476
+
4477
+ try {
4478
+ const result = await this.channelRegistry!.routeSpeech(name, body, targetChannel);
4479
+ if (result?.delivered) {
4480
+ this.proseBounceStreaks.delete(name);
4481
+ if (usedClipboard) this.proseClipboards.delete(name);
4482
+ }
4483
+ } catch (err) {
4484
+ console.error(`[prose] ${name}: delivery to ${targetChannel} failed:`, err);
4485
+ }
4486
+ }
4487
+
4488
+ /** Cap on consecutive bounce-triggered wakes (notices still append after). */
4489
+ private static readonly PROSE_BOUNCE_WAKE_CAP = 2;
4490
+
4491
+ private bounceProse(agent: Agent, text: string, reason: string, candidates?: string[]): void {
4492
+ const name = agent.name;
4493
+ this.proseClipboards.set(name, text);
4494
+ const streak = (this.proseBounceStreaks.get(name) ?? 0) + 1;
4495
+ this.proseBounceStreaks.set(name, streak);
4496
+ const cand = candidates?.length ? ` Known channels it could mean: ${candidates.join(', ')}.` : '';
4497
+ const notice =
4498
+ `[prose-routing] Your text (${text.length} chars) was not delivered — ${reason}.${cand} ` +
4499
+ 'The text is retained; nothing is lost. To deliver it unchanged, reply with a ' +
4500
+ 'destination plus the token {{unsent}}, e.g. ">>#channel {{unsent}}" or ">>@person {{unsent}}". ' +
4501
+ '">>skip_reply {{unsent}}" keeps it in context only. The prose_help tool shows the full syntax.';
4502
+ try {
4503
+ // Through framework.addMessage, NOT the context manager directly: while
4504
+ // the turn is still streaming this defers the notice to the next tool
4505
+ // boundary, where it is BOTH stored in order and injected into the live
4506
+ // stream (hear-while-acting) — the model sees the error and can correct
4507
+ // the prefix within the same turn. A direct append here would land the
4508
+ // notice before the turn's assistant blocks (cache bust, invisible
4509
+ // until the next compile).
4510
+ const id = this.addMessage(
4511
+ 'user',
4512
+ [{ type: 'text', text: notice }],
4513
+ { system: true, kind: 'prose-bounce' },
4514
+ );
4515
+ if (id) this.emitTrace({ type: 'message:added', messageId: id, source: 'prose-bounce' });
4516
+ else this.emitTrace({ type: 'message:added', messageId: 'deferred', source: 'prose-bounce' });
4517
+ } catch (err) {
4518
+ console.error('bounceProse: failed to record bounce notice:', err);
4519
+ }
4520
+ const capped = streak > AgentFramework.PROSE_BOUNCE_WAKE_CAP;
4521
+ console.error(
4522
+ `[prose] ${name}: BOUNCED ${text.length} chars — ${reason}` +
4523
+ (capped ? ' (bounce-wake cap reached; notice appended without wake)' : ''),
4524
+ );
4525
+ if (!capped) {
4526
+ // Wake so the resend can happen immediately — an unsent DM reply must
4527
+ // not sit invisible until the next unrelated event.
4528
+ this.pendingRequests.push({
4529
+ agentName: name,
4530
+ reason: 'prose-bounce',
4531
+ source: 'framework',
4532
+ timestamp: Date.now(),
4533
+ });
4534
+ }
4535
+ }
4536
+
4537
+ /**
4538
+ * One-time mode primer (docs/explicit-prose-routing.md): when an agent's
4539
+ * configured proseRouting differs from the last mode we primed them for,
4540
+ * append the teaching notice and persist the new mode. A brand-new agent
4541
+ * in default locus mode is recorded silently — no primer for the status quo.
4542
+ */
4543
+ private maybePrimeProseMode(agent: Agent): void {
4544
+ const mode = agent.proseRouting;
4545
+ try {
4546
+ const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
4547
+ const state = (data && typeof data === 'object' ? data : {}) as Record<string, unknown>;
4548
+ const primed = { ...((state.proseRoutingPrimed as Record<string, string> | undefined) ?? {}) };
4549
+ const stored = primed[agent.name];
4550
+ if (stored === mode) return;
4551
+ // Absence means "always default (locus)": a locus-mode agent that was
4552
+ // never explicit needs no entry and no primer — and skipping the write
4553
+ // keeps framework/state from growing a record per agent (scaling gate).
4554
+ if (mode === 'locus' && stored === undefined) return;
4555
+ if (mode === 'locus') delete primed[agent.name];
4556
+ else primed[agent.name] = mode;
4557
+ state.proseRoutingPrimed = primed;
4558
+ this.store.setStateJson(FRAMEWORK_STATE_ID, state);
4559
+ } catch (err) {
4560
+ console.error('maybePrimeProseMode: state read/write failed:', err);
4561
+ return;
4562
+ }
4563
+ try {
4564
+ const id = agent.getContextManager().addMessage(
4565
+ 'user',
4566
+ [{ type: 'text', text: proseModePrimer(mode) }],
4567
+ { system: true, kind: 'prose-routing-primer' },
4004
4568
  );
4005
- await this.startAgentStream(agent, { ...trigger, channelId: triggerChannel });
4569
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'prose-routing-primer' });
4570
+ console.error(`[prose] ${agent.name}: mode primer appended (${mode})`);
4571
+ } catch (err) {
4572
+ console.error('maybePrimeProseMode: failed to append primer:', err);
4006
4573
  }
4007
4574
  }
4008
4575
 
@@ -4019,14 +4586,60 @@ export class AgentFramework {
4019
4586
  // instead falls back to the global default. A given agent runs one turn at a
4020
4587
  // time, so a set here is only read during THIS turn's routeSpeech /
4021
4588
  // buildChannelContext; retries re-run with the same trigger, re-setting it.
4022
- if (trigger?.channelId) {
4023
- this.activeTriggerChannels.set(agent.name, trigger.channelId);
4024
- } else {
4025
- this.activeTriggerChannels.delete(agent.name);
4589
+ // A context-budget restart continues the SAME logical turn in a fresh
4590
+ // stream — its trigger carries no channelId, and deleting the trigger
4591
+ // channel here mid-logical-turn is exactly the hole that sent Sol's DM
4592
+ // reply to a stale guild channel (2026-07-22): any live locus resolution
4593
+ // after the restart fell through to the hours-old defaultPublishChannel.
4594
+ // Keep the turn's trigger channel across restarts; only real new turns
4595
+ // reset it.
4596
+ if (trigger?.reason !== 'context_budget_restart') {
4597
+ if (trigger?.channelId) {
4598
+ this.activeTriggerChannels.set(agent.name, trigger.channelId);
4599
+ } else {
4600
+ this.activeTriggerChannels.delete(agent.name);
4601
+ }
4602
+ }
4603
+
4604
+ // FREEZE this turn's outbound locus now (turn-frozen routing). Resolved
4605
+ // once — home channel for forks, else the triggering channel, else the
4606
+ // global default — and never moved for the rest of the turn: mid-turn
4607
+ // injections must not redirect the agent's plain prose (ambient chatter /
4608
+ // reactions hijacked it before, 2026-07-21). A context-budget restart
4609
+ // continues the same logical turn, so it keeps the existing pin. The
4610
+ // eager snapshot also protects heartbeat turns from a moving
4611
+ // defaultPublishChannel mid-turn. If the effective locus differs from the
4612
+ // last one announced, drop a durable `[routing]` notice into the window
4613
+ // BEFORE this turn compiles, so the agent always knows where its voice
4614
+ // goes (announce-on-change only — no per-turn chatter, append-only for
4615
+ // KV stability).
4616
+ if (trigger?.reason !== 'context_budget_restart') {
4617
+ if (attempt === 0) this.maybePrimeProseMode(agent);
4618
+ if (agent.proseRouting === 'explicit') {
4619
+ // Explicit prose routing: there is no locus. The model names every
4620
+ // destination in-band (`>>` prefixes); the only turn state is the
4621
+ // sticky target, reset for each fresh turn. No freeze, no announce.
4622
+ this.proseTargetPins.delete(agent.name);
4623
+ this.proseContinuations.delete(agent.name);
4624
+ this.midTurnInputSignals.delete(agent.name);
4625
+ this.turnLocusPins.delete(agent.name);
4626
+ } else {
4627
+ const locus = this.channelRegistry?.resolveLocus(agent.name) ?? null;
4628
+ if (locus !== null) this.turnLocusPins.set(agent.name, locus);
4629
+ else this.turnLocusPins.delete(agent.name);
4630
+ this.midTurnInputSignals.delete(agent.name);
4631
+ if (attempt === 0) this.announceLocusIfChanged(agent.name, locus);
4632
+ }
4026
4633
  }
4027
4634
 
4028
4635
  this.touchEphemeralRun(agent.name, true);
4029
- this.emitTrace({ type: 'inference:started', agentName: agent.name });
4636
+ this.emitTrace({
4637
+ type: 'inference:started',
4638
+ agentName: agent.name,
4639
+ // The turn-frozen locus was pinned just above (kept across a
4640
+ // context-budget restart, which skips the re-pin but re-emits this).
4641
+ channelId: this.turnLocusPins.get(agent.name),
4642
+ });
4030
4643
  this.eventGate?.onInferenceStarted(agent.name);
4031
4644
  this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
4032
4645
 
@@ -4034,6 +4647,9 @@ export class AgentFramework {
4034
4647
  const requestSnapshot = this.captureInferenceToolSnapshot(agent);
4035
4648
  const allTools = this.getToolsForAgent(agent.name, requestSnapshot);
4036
4649
  const tools = allTools.filter((t) => agent.canUseTool(t.name));
4650
+ // Explicit-mode agents get the on-demand routing reference (teach-by-
4651
+ // bounce: the grammar is never injected, only served when asked).
4652
+ if (agent.proseRouting === 'explicit') tools.push(PROSE_HELP_TOOL);
4037
4653
 
4038
4654
  // Gather context from modules (pull-based) and MCPL hooks (push-based)
4039
4655
  // Both produce ContextInjection[] that get merged before inference.
@@ -4138,24 +4754,15 @@ export class AgentFramework {
4138
4754
  let hadToolCalls = false;
4139
4755
 
4140
4756
  // ---- Present-while-acting turn state ---------------------------------
4141
- // Output locus for the current CONVERSATIONAL ROUND: resolved lazily and
4142
- // kept stable across tool-only rounds. A channel message injected at a
4143
- // tool boundary replaces the pin before the next model round, because a
4144
- // single provider inference can now contain several conversations. The
4145
- // pin lives in `turnLocusPins` so it survives a context-budget restart. A
4146
- // null resolution is not pinned.
4147
- if (trigger?.reason !== 'context_budget_restart') {
4148
- this.turnLocusPins.delete(agent.name);
4149
- this.midTurnRoutingResets.delete(agent.name);
4150
- }
4151
- const resolveTurnLocus = (): string | null => {
4152
- let locus = this.turnLocusPins.get(agent.name) ?? null;
4153
- if (locus === null && this.channelRegistry) {
4154
- locus = this.channelRegistry.resolveLocus(agent.name);
4155
- if (locus !== null) this.turnLocusPins.set(agent.name, locus);
4156
- }
4157
- return locus;
4158
- };
4757
+ // Output locus for the WHOLE logical turn: frozen in startAgentStream
4758
+ // (home → addressed trigger → global default) before this stream began,
4759
+ // and never moved until the next turn. Mid-turn injections do not touch
4760
+ // it — an ambient message or a reaction arriving at a tool boundary must
4761
+ // not redirect the agent's task narration (2026-07-21 Cairn misroute).
4762
+ // The pin lives in `turnLocusPins` so a context-budget restart (same
4763
+ // logical turn, fresh driveStream) keeps it.
4764
+ const resolveTurnLocus = (): string | null =>
4765
+ this.turnLocusPins.get(agent.name) ?? null;
4159
4766
 
4160
4767
  // Ordered delivery chain for live-routed prose. Links are enqueued
4161
4768
  // WITHOUT awaiting in the stream-event loop — an awaited network post
@@ -4188,32 +4795,56 @@ export class AgentFramework {
4188
4795
  // delivered exactly once, at turn end.
4189
4796
  let liveProseRouting = false;
4190
4797
 
4191
- // Typing indicator: show "<agent> is typing…" in the channel she's
4192
- // responding to, for the whole duration of this turn. Started here (paired
4193
- // with the finally below, so it can never leak) and refreshed on a 7s
4194
- // interval by the ChannelRegistry until stopped on any exit path.
4195
- let typingChannel = this.channelRegistry
4196
- ? trigger?.channelId ?? this.channelRegistry.getDefaultPublishChannel()
4197
- : null;
4798
+ // Typing indicator: show "<agent> is typing…" for the whole duration of
4799
+ // this turn. Started here (paired with the finally below, so it can never
4800
+ // leak) and refreshed on a 7s interval by the ChannelRegistry until
4801
+ // stopped on any exit path. Never moves mid-turn.
4802
+ // - locus mode: the frozen routing pin (where bare prose will land).
4803
+ // - explicit mode: there is no locus — indicate on the TRIGGER channel
4804
+ // (where the wake came from). Typing is presence, not delivery, so
4805
+ // this doesn't violate never-guess: it says "attending to what you
4806
+ // sent here", which is true regardless of where the reply goes.
4807
+ // Heartbeat/no-trigger explicit turns show no indicator.
4808
+ const typingChannel =
4809
+ agent.proseRouting === 'explicit'
4810
+ ? trigger?.channelId ?? null
4811
+ : resolveTurnLocus();
4198
4812
  if (typingChannel) this.channelRegistry!.startTyping(typingChannel);
4199
4813
 
4814
+ // MCPL Spec 14.3 outgoing streaming: route text deltas to their
4815
+ // destination server AS THEY GENERATE (voice synthesis, live message
4816
+ // rendering). Pure observer surface — suppression is fail-closed (nothing
4817
+ // streams that delivery would refuse), emission is capability-gated in
4818
+ // the registry (`channels.streaming`), and delivery via deliverProse
4819
+ // remains the sole authoritative send.
4820
+ const outgoingInferenceId = `inf_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
4821
+ let outgoingIndex = 0;
4822
+ const proseStream = this.channelRegistry
4823
+ ? new ProseStreamRouter({
4824
+ mode: agent.proseRouting === 'explicit' ? 'explicit' : 'locus',
4825
+ initialTarget: typingChannel,
4826
+ resolve: (spec) => {
4827
+ const r = this.channelRegistry!.resolveProseTarget(spec);
4828
+ return 'channelId' in r ? r.channelId : null;
4829
+ },
4830
+ })
4831
+ : null;
4832
+ const emitOutgoing = (deltas: { channelId: string; delta: string }[]): void => {
4833
+ for (const rd of deltas) {
4834
+ this.channelRegistry!.sendOutgoingChunk(
4835
+ rd.channelId, agent.name, outgoingInferenceId, outgoingIndex++, rd.delta,
4836
+ );
4837
+ }
4838
+ };
4839
+
4200
4840
  const adoptInjectedRound = (): void => {
4201
- if (!this.midTurnRoutingResets.has(agent.name)) return;
4202
- const nextLocus = this.midTurnRoutingResets.get(agent.name) ?? null;
4203
- this.midTurnRoutingResets.delete(agent.name);
4841
+ if (!this.midTurnInputSignals.has(agent.name)) return;
4842
+ this.midTurnInputSignals.delete(agent.name);
4204
4843
 
4205
- // A new human/agent message, not merely a tool result, means any earlier
4206
- // explicit delivery has completed its conversational job.
4844
+ // A new conversational message, not merely a tool result, means any
4845
+ // earlier explicit delivery has completed its conversational job.
4846
+ // Routing is NOT touched: the turn locus stays frozen.
4207
4847
  turnSilenced = false;
4208
-
4209
- if (nextLocus) {
4210
- this.turnLocusPins.set(agent.name, nextLocus);
4211
- if (typingChannel !== nextLocus) {
4212
- if (typingChannel) this.channelRegistry?.stopTyping(typingChannel);
4213
- typingChannel = nextLocus;
4214
- this.channelRegistry?.startTyping(nextLocus);
4215
- }
4216
- }
4217
4848
  };
4218
4849
 
4219
4850
  try {
@@ -4227,7 +4858,14 @@ export class AgentFramework {
4227
4858
  content: event.content,
4228
4859
  blockType: event.meta.type,
4229
4860
  blockIndex: event.meta.blockIndex,
4861
+ // Turn-frozen locus (same value as the typing indicator above):
4862
+ // lets trace consumers tag every chunk with the channel this
4863
+ // turn's prose is bound for, without re-deriving routing.
4864
+ channelId: typingChannel ?? undefined,
4230
4865
  });
4866
+ if (proseStream && event.meta.type === 'text') {
4867
+ emitOutgoing(proseStream.feed(event.content));
4868
+ }
4231
4869
  break;
4232
4870
 
4233
4871
  case 'block': {
@@ -4238,6 +4876,7 @@ export class AgentFramework {
4238
4876
  phase,
4239
4877
  blockType: block.type,
4240
4878
  blockIndex: index,
4879
+ channelId: typingChannel ?? undefined,
4241
4880
  });
4242
4881
  break;
4243
4882
  }
@@ -4265,13 +4904,30 @@ export class AgentFramework {
4265
4904
  (b) => b.type !== 'tool_result'
4266
4905
  );
4267
4906
  } else {
4268
- // Fallback (older membrane / XML tool mode): preamble + calls
4907
+ // Fallback (older membrane / XML tool mode): preamble + calls.
4908
+ // XML mode: persist the verbatim generation on each tool_use
4909
+ // (membrane#36 round-trip fidelity). context.rawText is the
4910
+ // parser's fullMatch — the exact <function_calls> block the
4911
+ // model wrote (plus the harness-appended close tag), shared by
4912
+ // every call in the round; the anthropic-xml formatter replays
4913
+ // it byte-identically instead of reconstructing. Guarded by
4914
+ // shape so a native-path fallback can never attach non-XML.
4915
+ const rawText = event.context.rawText;
4916
+ const rawXml = rawText && /^<(antml:)?function_calls>/.test(rawText)
4917
+ ? rawText
4918
+ : undefined;
4269
4919
  assistantBlocks = [];
4270
4920
  if (event.context.preamble) {
4271
4921
  assistantBlocks.push({ type: 'text', text: event.context.preamble });
4272
4922
  }
4273
4923
  for (const c of event.calls) {
4274
- assistantBlocks.push({ type: 'tool_use', id: c.id, name: c.name, input: c.input as Record<string, unknown> });
4924
+ assistantBlocks.push({
4925
+ type: 'tool_use',
4926
+ id: c.id,
4927
+ name: c.name,
4928
+ input: c.input as Record<string, unknown>,
4929
+ ...(rawXml ? { rawXml } : {}),
4930
+ });
4275
4931
  }
4276
4932
  }
4277
4933
  this.pendingAssistantBlocks.set(agent.name, assistantBlocks);
@@ -4311,7 +4967,21 @@ export class AgentFramework {
4311
4967
  liveProseRouting = true;
4312
4968
  const roundSegments = splitProseSegments(assistantBlocks);
4313
4969
  if (roundSegments.length > 0) {
4314
- if (turnSilenced) {
4970
+ if (agent.proseRouting === 'explicit') {
4971
+ // Explicit mode: every segment through the prose gateway.
4972
+ // Silencing does not apply — unprefixed prose bounces and
4973
+ // prefixed prose is deliberate; think-privacy still holds.
4974
+ if (!hasSameRoundPrivateThink) {
4975
+ console.error(
4976
+ `[prose] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> ${roundSegments.length} segment(s) via prose gateway`,
4977
+ );
4978
+ for (const seg of roundSegments) {
4979
+ turnSpeechChain = turnSpeechChain
4980
+ .then(() => this.deliverProse(agent, seg))
4981
+ .catch((err) => console.error('mid-turn prose delivery failed:', err));
4982
+ }
4983
+ }
4984
+ } else if (turnSilenced) {
4315
4985
  console.error(
4316
4986
  `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`,
4317
4987
  );
@@ -4635,10 +5305,29 @@ export class AgentFramework {
4635
5305
  .join('\n')
4636
5306
  .trim();
4637
5307
  if (speechText) {
4638
- try {
4639
- await this.channelRegistry.routeSpeech(agent.name, speechText);
4640
- } catch (err) {
4641
- console.error('speech routing failed:', err);
5308
+ if (agent.proseRouting === 'explicit') {
5309
+ console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`);
5310
+ try {
5311
+ await this.deliverProse(agent, speechText);
5312
+ } catch (err) {
5313
+ console.error('text-only prose delivery failed:', err);
5314
+ }
5315
+ } else {
5316
+ // Route to the TURN-FROZEN locus, like every other speech
5317
+ // path. This dispatch runs AFTER the agent is idle, so a live
5318
+ // resolution here can read the NEXT turn's trigger state (or
5319
+ // a post-restart cleared one) and land the reply in a stale
5320
+ // channel — the 2026-07-22 Sol DM-to-guild misroute. The
5321
+ // frozen pin is immune to both races.
5322
+ const locus = resolveTurnLocus();
5323
+ console.error(
5324
+ `[routing] ${agent.name}: text-only turn -> routing speech -> ${locus ?? '(none)'}`,
5325
+ );
5326
+ try {
5327
+ await this.channelRegistry.routeSpeech(agent.name, speechText, locus);
5328
+ } catch (err) {
5329
+ console.error('speech routing failed:', err);
5330
+ }
4642
5331
  }
4643
5332
  }
4644
5333
  } else if (this.channelRegistry && hadToolCalls && allText.length > 0) {
@@ -4671,7 +5360,20 @@ export class AgentFramework {
4671
5360
  // the chain may still be flushing earlier rounds' posts.
4672
5361
  await turnSpeechChain;
4673
5362
 
4674
- if (silenced || segments.length === 0) {
5363
+ if (agent.proseRouting === 'explicit') {
5364
+ if (segments.length > 0) {
5365
+ console.error(
5366
+ `[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`,
5367
+ );
5368
+ for (const seg of segments) {
5369
+ try {
5370
+ await this.deliverProse(agent, seg);
5371
+ } catch (err) {
5372
+ console.error('trailing prose delivery failed:', err);
5373
+ }
5374
+ }
5375
+ }
5376
+ } else if (silenced || segments.length === 0) {
4675
5377
  console.error(
4676
5378
  `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> trailing prose NOT routed ` +
4677
5379
  `(${silenced ? 'silencing tool / explicit send' : 'no trailing prose'})`,
@@ -4702,6 +5404,21 @@ export class AgentFramework {
4702
5404
  // BEFORE dispatchSpeech. Locus routing is speech dispatch and
4703
5405
  // doesn't depend on the status field, so it correctly runs after.
4704
5406
 
5407
+ // Explicit-prose `!` continuation: a prose segment this turn asked
5408
+ // to keep going (`>>#x !` / `>>private !`) — start another turn
5409
+ // now instead of pausing until the next external event. This gives
5410
+ // prose the same continuation ability tool rounds have (wanted for
5411
+ // robotics-style loops where an end-of-turn pause is harmful).
5412
+ if (this.proseContinuations.delete(agent.name)) {
5413
+ console.error(`[prose] ${agent.name}: '!' continuation — requesting immediate next turn`);
5414
+ this.pendingRequests.push({
5415
+ agentName: agent.name,
5416
+ reason: 'prose-continuation',
5417
+ source: 'framework',
5418
+ timestamp: Date.now(),
5419
+ });
5420
+ }
5421
+
4705
5422
  break;
4706
5423
  }
4707
5424
 
@@ -4728,6 +5445,7 @@ export class AgentFramework {
4728
5445
  // Only reset + retry if this is still the active stream
4729
5446
  if (agent.streamId !== myStreamId) break;
4730
5447
 
5448
+ this.abortAgentScript(agent.name, 'stream error');
4731
5449
  agent.reset();
4732
5450
 
4733
5451
  const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
@@ -4775,6 +5493,7 @@ export class AgentFramework {
4775
5493
  // may have already started a new stream, bumping streamId)
4776
5494
  if (agent.streamId === myStreamId) {
4777
5495
  const durationMs = Date.now() - startTime;
5496
+ this.abortAgentScript(agent.name, `stream aborted (${reason})`);
4778
5497
  agent.reset();
4779
5498
  this.settleAgent(agent.name, {
4780
5499
  stopReason: 'exhausted',
@@ -4879,6 +5598,7 @@ export class AgentFramework {
4879
5598
  request: compiledRequest ?? { note: 'streaming request threw' },
4880
5599
  durationMs,
4881
5600
  });
5601
+ this.abortAgentScript(agent.name, 'stream threw');
4882
5602
  agent.reset();
4883
5603
  this.eventGate?.onInferenceEnded(agent.name);
4884
5604
  } finally {
@@ -4895,6 +5615,16 @@ export class AgentFramework {
4895
5615
  // Stop the typing indicator on every exit path (complete, error,
4896
5616
  // exhausted, abort) so it never sticks after the turn ends.
4897
5617
  this.channelRegistry?.stopTyping();
5618
+
5619
+ // Spec 14.3: flush any held line-start text, then close each streamed
5620
+ // channel with its final moderated content — the consumer's signal to
5621
+ // finalize (end the TTS utterance, settle the rendered message).
5622
+ if (proseStream) {
5623
+ emitOutgoing(proseStream.finish());
5624
+ for (const [channelId, text] of proseStream.byChannel()) {
5625
+ this.channelRegistry!.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text);
5626
+ }
5627
+ }
4898
5628
  this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
4899
5629
  this.activeStreams.delete(agent.name);
4900
5630
  this.pendingAssistantBlocks.delete(agent.name);
@@ -4921,6 +5651,13 @@ export class AgentFramework {
4921
5651
  * Used by SubagentModule to dispatch tool calls for ephemeral agents.
4922
5652
  */
4923
5653
  async executeToolCall(call: ToolCall): Promise<ToolResult> {
5654
+ // Client-side programmatic tool calling for promise-based callers
5655
+ // (SubagentModule ephemerals). Keyed by callerAgentName so each ephemeral
5656
+ // gets its own interpreter state.
5657
+ if (call.name === CODE_EXECUTION_TOOL_NAME && this.codeExecutionConfig) {
5658
+ return this.runCodeExecution(call.callerAgentName ?? '__ephemeral__', call);
5659
+ }
5660
+
4924
5661
  // MCPL tools are dispatched via the MCPL subsystem
4925
5662
  if (this.mcplServerRegistry) {
4926
5663
  // Check if this is an MCPL-prefixed tool
@@ -4970,6 +5707,199 @@ export class AgentFramework {
4970
5707
  }
4971
5708
  }
4972
5709
 
5710
+ // ---------------------------------------------------------------------------
5711
+ // Client-side programmatic tool calling (code_execution)
5712
+ // ---------------------------------------------------------------------------
5713
+
5714
+ /** Event-pushing wrapper for the model-driven dispatch path. */
5715
+ private dispatchCodeExecutionToolCall(agentName: string, call: ToolCall): void {
5716
+ this.emitTrace({
5717
+ type: 'tool:started',
5718
+ module: 'code_execution',
5719
+ tool: call.name,
5720
+ callId: call.id,
5721
+ input: call.input,
5722
+ });
5723
+ const startTime = Date.now();
5724
+ this.runCodeExecution(agentName, call)
5725
+ .catch((error): ToolResult => {
5726
+ // runCodeExecution is designed not to reject; this is the last-resort
5727
+ // guard so a bug here can never strand the agent in waiting_for_tools.
5728
+ const err = error instanceof Error ? error : new Error(String(error));
5729
+ return { success: false, error: `code_execution failed: ${err.message}`, isError: true };
5730
+ })
5731
+ .then((result) => {
5732
+ if (result.isError) {
5733
+ this.emitTrace({
5734
+ type: 'tool:failed',
5735
+ module: 'code_execution',
5736
+ tool: call.name,
5737
+ callId: call.id,
5738
+ error: result.error ?? 'unknown error',
5739
+ });
5740
+ } else {
5741
+ this.emitTrace({
5742
+ type: 'tool:completed',
5743
+ module: 'code_execution',
5744
+ tool: call.name,
5745
+ callId: call.id,
5746
+ durationMs: Date.now() - startTime,
5747
+ });
5748
+ }
5749
+ this.pushEvent({
5750
+ type: 'tool-result',
5751
+ callId: call.id,
5752
+ agentName,
5753
+ moduleName: 'code_execution',
5754
+ result,
5755
+ });
5756
+ });
5757
+ }
5758
+
5759
+ /**
5760
+ * Run one code_execution script for an agent. Never rejects.
5761
+ *
5762
+ * The script runs in the agent's persistent python interpreter with every
5763
+ * tool on the agent's CURRENT surface injected (minus code_execution
5764
+ * itself). Inner tool calls are dispatched through dispatchToolCall — the
5765
+ * exact model path, including MCPL state/checkpoint handling, channel
5766
+ * routing, and traces — and resolved via scriptToolWaiters, so their
5767
+ * results reach the running script and never the model context.
5768
+ */
5769
+ private async runCodeExecution(agentName: string, call: ToolCall): Promise<ToolResult> {
5770
+ const input = (call.input ?? {}) as { code?: unknown };
5771
+ if (typeof input.code !== 'string' || input.code.trim() === '') {
5772
+ return {
5773
+ success: false,
5774
+ error: 'code_execution requires a non-empty `code` string',
5775
+ isError: true,
5776
+ };
5777
+ }
5778
+
5779
+ const agent = this.agents.get(agentName);
5780
+ const surface = agent
5781
+ ? this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name))
5782
+ : this.getAllTools(); // ephemeral agents: full surface, matching executeToolCall
5783
+ const injected = buildInjectedTools(
5784
+ surface.map((t) => t.name).filter((name) => name !== CODE_EXECUTION_TOOL_NAME),
5785
+ );
5786
+
5787
+ const runner = this.getOrCreateScriptRunner(agentName);
5788
+ this.scriptDeferredEndTurn.delete(agentName);
5789
+ const exec = await runner.exec(input.code, injected);
5790
+ const endTurn = this.scriptDeferredEndTurn.delete(agentName);
5791
+
5792
+ return {
5793
+ success: true,
5794
+ data: { stdout: exec.stdout, stderr: exec.stderr, return_code: exec.returnCode },
5795
+ isError: false,
5796
+ ...(endTurn ? { endTurn: true } : {}),
5797
+ };
5798
+ }
5799
+
5800
+ private getOrCreateScriptRunner(agentName: string): PyRunner {
5801
+ let runner = this.codeExecutionRunners.get(agentName);
5802
+ if (!runner) {
5803
+ const cfg = this.codeExecutionConfig;
5804
+ runner = new PyRunner({
5805
+ pythonPath: cfg?.pythonPath,
5806
+ toolCallTimeoutMs: cfg?.toolCallTimeoutMs,
5807
+ scriptTimeoutMs: cfg?.scriptTimeoutMs,
5808
+ idleReclaimMs: cfg?.idleReclaimMs,
5809
+ label: agentName,
5810
+ onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args),
5811
+ });
5812
+ this.codeExecutionRunners.set(agentName, runner);
5813
+ }
5814
+ return runner;
5815
+ }
5816
+
5817
+ /**
5818
+ * Resolve one script-inner tool call to the string the script's tool
5819
+ * function returns. Never rejects — errors become "Error: ..." strings,
5820
+ * matching the managed PTC runtime ("Claude's code receives this error").
5821
+ *
5822
+ * Results are serialized with the HISTORY serializer (images become
5823
+ * placeholders): programmatic tool results are text-only by contract, and
5824
+ * a script variable holding megabytes of base64 helps nobody.
5825
+ */
5826
+ private async handleScriptToolCall(
5827
+ agentName: string,
5828
+ toolName: string,
5829
+ args: Record<string, unknown>,
5830
+ ): Promise<string> {
5831
+ if (toolName === CODE_EXECUTION_TOOL_NAME) {
5832
+ return 'Error: code_execution cannot be called from within a script';
5833
+ }
5834
+ const result = await this.dispatchScriptToolCall(agentName, toolName, args);
5835
+ if (result.endTurn) {
5836
+ // Deferred: applied to the final code_execution result (see
5837
+ // scriptDeferredEndTurn) — ending the turn mid-script would cancel the
5838
+ // stream while the script still runs and wedge the tool round.
5839
+ this.scriptDeferredEndTurn.add(agentName);
5840
+ }
5841
+ if (result.isError) {
5842
+ return `Error: ${result.error ?? 'tool call failed'}`;
5843
+ }
5844
+ if (result.data === undefined) return '';
5845
+ const agent = this.agents.get(agentName);
5846
+ const maxChars = agent ? this.getMaxToolResultChars(agent) : undefined;
5847
+ return toolResultDataToHistoryString(result.data, maxChars);
5848
+ }
5849
+
5850
+ /**
5851
+ * Dispatch a script-inner tool call through the NORMAL dispatch path and
5852
+ * resolve with its result. The waiter intercept in handleProcessEvent
5853
+ * (keyed by the pytc- call ID) routes the tool-result event here instead
5854
+ * of into the agent's pending tool round.
5855
+ */
5856
+ private dispatchScriptToolCall(
5857
+ agentName: string,
5858
+ toolName: string,
5859
+ args: Record<string, unknown>,
5860
+ ): Promise<ToolResult> {
5861
+ return new Promise<ToolResult>((resolve) => {
5862
+ const callId = `pytc-${randomUUID()}`;
5863
+ // Host-side leak guard: the python runtime already raises TimeoutError
5864
+ // at toolCallTimeoutMs; this only reclaims the waiter if a dispatch path
5865
+ // loses the result entirely (so the map cannot grow unboundedly).
5866
+ const safetyMs = (this.codeExecutionConfig?.toolCallTimeoutMs ?? 270_000) + 30_000;
5867
+ const safety = setTimeout(() => {
5868
+ if (this.scriptToolWaiters.delete(callId)) {
5869
+ resolve({
5870
+ success: false,
5871
+ error: `tool '${toolName}' produced no result within ${Math.round(safetyMs / 1000)}s`,
5872
+ isError: true,
5873
+ });
5874
+ }
5875
+ }, safetyMs);
5876
+ safety.unref?.();
5877
+
5878
+ this.scriptToolWaiters.set(callId, (result) => {
5879
+ clearTimeout(safety);
5880
+ resolve(result);
5881
+ });
5882
+
5883
+ try {
5884
+ this.dispatchToolCall(agentName, { id: callId, name: toolName, input: args });
5885
+ } catch (error) {
5886
+ if (this.scriptToolWaiters.delete(callId)) {
5887
+ clearTimeout(safety);
5888
+ const err = error instanceof Error ? error : new Error(String(error));
5889
+ resolve({ success: false, error: err.message, isError: true });
5890
+ }
5891
+ }
5892
+ });
5893
+ }
5894
+
5895
+ /** Abort a running script when the agent's turn dies underneath it. */
5896
+ private abortAgentScript(agentName: string, reason: string): void {
5897
+ const runner = this.codeExecutionRunners.get(agentName);
5898
+ if (runner?.busy) {
5899
+ runner.abort(reason);
5900
+ }
5901
+ }
5902
+
4973
5903
  private toMembraneToolResult(callId: string, afResult: ToolResult, maxChars?: number): MembraneToolResult {
4974
5904
  if (afResult.isError) {
4975
5905
  return { toolUseId: callId, content: afResult.error ?? 'Unknown error', isError: true };
@@ -5209,6 +6139,29 @@ export class AgentFramework {
5209
6139
  return;
5210
6140
  }
5211
6141
 
6142
+ // Client-side programmatic tool calling: run a python script that calls
6143
+ // the agent's other tools in-process (script-inner calls come back through
6144
+ // this very dispatcher with waiter-tracked call IDs).
6145
+ if (enrichedCall.name === CODE_EXECUTION_TOOL_NAME && this.codeExecutionConfig) {
6146
+ this.dispatchCodeExecutionToolCall(agentName, enrichedCall);
6147
+ return;
6148
+ }
6149
+
6150
+ // prose_help: on-demand routing reference for explicit-prose agents.
6151
+ // Answered inline — the grammar is a tool RESULT (model-requested), never
6152
+ // ambient context (see PROSE_ROUTING_HELP / teach-by-bounce).
6153
+ if (enrichedCall.name === 'prose_help') {
6154
+ this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'prose_help', callId: enrichedCall.id, durationMs: 0 });
6155
+ this.pushEvent({
6156
+ type: 'tool-result',
6157
+ callId: enrichedCall.id,
6158
+ agentName,
6159
+ moduleName: 'framework',
6160
+ result: { success: true, data: PROSE_ROUTING_HELP },
6161
+ });
6162
+ return;
6163
+ }
6164
+
5212
6165
  const sepIndex = enrichedCall.name.indexOf('--');
5213
6166
  const moduleName = sepIndex >= 0 ? enrichedCall.name.substring(0, sepIndex) : 'unknown';
5214
6167
  const toolName = sepIndex >= 0 ? call.name.substring(sepIndex + 2) : call.name;
@@ -5587,6 +6540,13 @@ export class AgentFramework {
5587
6540
  if (err.name === 'OverBudgetError') {
5588
6541
  return { errorType: 'over_budget' };
5589
6542
  }
6543
+ // context-manager's fatal coverage invariant (fix/coverage-invariant-fatal,
6544
+ // 2026-07-26): a compile REFUSES rather than shipping a context with
6545
+ // silently-dropped messages. Recovery is identical to over_budget — kick
6546
+ // the compression drain so summaries cover the un-represented span.
6547
+ if (err.name === 'UncoveredDropError') {
6548
+ return { errorType: 'context_refusal' };
6549
+ }
5590
6550
  return {};
5591
6551
  }
5592
6552
 
@@ -5656,7 +6616,25 @@ export class AgentFramework {
5656
6616
  // (e.g. a reason string that crossed a serialization boundary). It matches
5657
6617
  // CM's current OverBudgetError wording and MAY rot if CM rewords it — the
5658
6618
  // errorType gate is the one that's load-bearing.
5659
- const overBudget = errorType === 'over_budget' || /exceed hard budget/i.test(reason);
6619
+ const overBudget =
6620
+ errorType === 'over_budget' ||
6621
+ errorType === 'context_refusal' ||
6622
+ /exceed hard budget|no summary covers/i.test(reason);
6623
+ // Observability: a refused compile means the agent cannot think AT ALL
6624
+ // this turn — surface it on the ops-alert pipeline (ops:alert trace +
6625
+ // webhook → fleet-watch) immediately, not only at the hard-down streak.
6626
+ // The per-(agent,kind) cooldown throttles repeats; failures.log already
6627
+ // has the per-exhaustion record from logFailure above.
6628
+ if (overBudget) {
6629
+ this.opsAlert(
6630
+ 'context-refusal',
6631
+ agentName,
6632
+ `compile refused (${errorType ?? 'unclassified'}): ${reason.slice(0, 300)} — ` +
6633
+ `drain breaker ${agent ? 'kicking' : 'unavailable (no agent handle)'}; ` +
6634
+ `if this persists, raise contextBudgetTokens or overBudgetGraceRatio.`,
6635
+ { skipLog: true },
6636
+ );
6637
+ }
5660
6638
  if (agent && overBudget && !this.overBudgetDrainInFlight.has(agentName)) {
5661
6639
  this.overBudgetDrainInFlight.add(agentName);
5662
6640
  void (async () => {
@@ -5853,11 +6831,24 @@ export class AgentFramework {
5853
6831
  // yes, wake no).
5854
6832
  onRouteFailure: ({ channelId, reason, textLen }) => {
5855
6833
  try {
6834
+ // Render a human-readable channel name when we can — a bare
6835
+ // snowflake in the marker is unresolvable for the agent (the
6836
+ // 2026-07-21 incident read as "a stale artifact", not a live
6837
+ // failure). The marker is `system: true`, so it is never
6838
+ // conversational and never influences routing.
6839
+ const label = channelId
6840
+ ? this.channelRegistry?.getDescriptor(channelId)?.label
6841
+ : undefined;
6842
+ const where = channelId
6843
+ ? label && label !== channelId
6844
+ ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})`
6845
+ : channelId
6846
+ : 'the channel';
5856
6847
  this.addMessage(
5857
6848
  'user',
5858
6849
  [{
5859
6850
  type: 'text',
5860
- 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.`,
6851
+ 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.`,
5861
6852
  }],
5862
6853
  { system: true, kind: 'discord-send-failed', channelId: channelId ?? '', reason },
5863
6854
  );
@@ -5865,6 +6856,13 @@ export class AgentFramework {
5865
6856
  console.error('onRouteFailure: failed to record send-failure marker:', err);
5866
6857
  }
5867
6858
  },
6859
+ // A channel opened without the agent asking (policy admission or an
6860
+ // open forced by delivery) must be announced in the window, with the
6861
+ // opt-out named. Routed here so registry-driven opens and framework-
6862
+ // driven opens produce the same durable notice.
6863
+ onChannelAutoOpened: ({ conversationId, source, channels }) => {
6864
+ this.recordChannelAutoOpenNotice(conversationId, channels, source);
6865
+ },
5868
6866
  },
5869
6867
  );
5870
6868
 
@@ -6904,6 +7902,44 @@ export class AgentFramework {
6904
7902
  }
6905
7903
  }
6906
7904
 
7905
+ // INVARIANT: engaging a channel opens it. A successful explicit send
7906
+ // into a channel the registry knows as closed drives the same open
7907
+ // flow as channel_open (durable desired state, server subscribe), so
7908
+ // there is no such thing as posting into a channel that stays
7909
+ // half-alive (no typing, no reactions, no inbound forwarding).
7910
+ // Fire-and-forget: the send already succeeded; the open must not
7911
+ // delay or fail the tool result.
7912
+ if (
7913
+ this.channelRegistry &&
7914
+ SEND_ENGAGEMENT_TOOLS.has(toolName) &&
7915
+ result.isError !== true
7916
+ ) {
7917
+ const target =
7918
+ extractChannelIdFromToolResult(result.content) ??
7919
+ (typeof args.channelId === 'string' ? (args.channelId as string) : undefined);
7920
+ if (target) {
7921
+ this.channelRegistry
7922
+ .openIfClosedForSend(target, serverId)
7923
+ .then(({ status, channelId, label }) => {
7924
+ if (status === 'opened') {
7925
+ console.error(
7926
+ `[channel] ${agentName}: explicit ${toolName} into closed channel ${target} — opened (send implies engagement)`,
7927
+ );
7928
+ this.recordChannelAutoOpenNotice(
7929
+ agentName,
7930
+ [{ channelId: channelId ?? target, label }],
7931
+ 'opened-by-reply',
7932
+ );
7933
+ } else if (status === 'open-failed') {
7934
+ console.error(
7935
+ `[channel] ${agentName}: ${toolName} delivered to ${target} but the open-on-send failed — channel remains half-alive, will reconcile`,
7936
+ );
7937
+ }
7938
+ })
7939
+ .catch((err) => console.error('[channel] open-on-send error:', err));
7940
+ }
7941
+ }
7942
+
6907
7943
  // Convert MCP tool result to framework ToolResult.
6908
7944
  // When the result contains non-text blocks (e.g. images from an MCP
6909
7945
  // tool like zulip-mcp's fetch_attachment), pass the full content array
@@ -7152,12 +8188,29 @@ export class AgentFramework {
7152
8188
  : seconds >= 60 ? `${Math.round(seconds / 60)}m`
7153
8189
  : `${Math.round(seconds)}s`;
7154
8190
 
7155
- // Announce in the sticky channel (best-effort; never blocks the result).
8191
+ // Announce in the TURN's frozen locus (best-effort; never blocks the
8192
+ // result). The sleep tool runs mid-turn, so the pin is live — same
8193
+ // authority as every other speech path, no live re-resolution.
7156
8194
  if (announce && this.channelRegistry) {
7157
8195
  const text = input.message ?? `💤 Going quiet for ${human}. I'll still see messages, but won't respond until I wake.`;
7158
- this.channelRegistry.routeSpeech(agentName, text).catch((err) => {
7159
- console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
7160
- });
8196
+ const agent = this.agents.get(agentName);
8197
+ if (agent?.proseRouting === 'explicit') {
8198
+ // Explicit mode: announce to the turn's sticky prose target if the
8199
+ // model has set one; otherwise stay quiet — never guess a channel.
8200
+ const target = this.proseTargetPins.get(agentName);
8201
+ if (target) {
8202
+ this.channelRegistry.routeSpeech(agentName, text, target).catch((err) => {
8203
+ console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
8204
+ });
8205
+ } else {
8206
+ console.error(`[sleep] ${agentName}: no prose target this turn — sleep announcement not posted (explicit mode never guesses)`);
8207
+ }
8208
+ } else {
8209
+ const locus = this.turnLocusPins.get(agentName) ?? null;
8210
+ this.channelRegistry.routeSpeech(agentName, text, locus).catch((err) => {
8211
+ console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
8212
+ });
8213
+ }
7161
8214
  }
7162
8215
 
7163
8216
  console.error(`[sleep] agent=${agentName} seconds=${seconds} announce=${announce} until=${new Date(until).toISOString()}`);