@animalabs/agent-framework 0.6.8 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/src/agent.d.ts +6 -0
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +11 -1
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +129 -15
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +967 -113
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/mcpl/channel-registry.d.ts +87 -7
  10. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  11. package/dist/src/mcpl/channel-registry.js +329 -49
  12. package/dist/src/mcpl/channel-registry.js.map +1 -1
  13. package/dist/src/modules/workspace/index.d.ts +10 -0
  14. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  15. package/dist/src/modules/workspace/index.js +61 -6
  16. package/dist/src/modules/workspace/index.js.map +1 -1
  17. package/dist/src/modules/workspace/types.d.ts +2 -0
  18. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  19. package/dist/src/modules/workspace/types.js.map +1 -1
  20. package/dist/src/modules/workspace/watcher.d.ts +17 -0
  21. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  22. package/dist/src/modules/workspace/watcher.js +143 -7
  23. package/dist/src/modules/workspace/watcher.js.map +1 -1
  24. package/dist/src/types/agent.d.ts +32 -0
  25. package/dist/src/types/agent.d.ts.map +1 -1
  26. package/dist/src/types/trace.d.ts +32 -0
  27. package/dist/src/types/trace.d.ts.map +1 -1
  28. package/dist/test/channel-registry-routing.test.js +186 -10
  29. package/dist/test/channel-registry-routing.test.js.map +1 -1
  30. package/dist/test/explicit-prose-routing.test.d.ts +11 -0
  31. package/dist/test/explicit-prose-routing.test.d.ts.map +1 -0
  32. package/dist/test/explicit-prose-routing.test.js +247 -0
  33. package/dist/test/explicit-prose-routing.test.js.map +1 -0
  34. package/dist/test/present-while-acting.test.js +85 -8
  35. package/dist/test/present-while-acting.test.js.map +1 -1
  36. package/dist/test/trunk-channel-routing.test.js +7 -3
  37. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  38. package/dist/test/workspace-watcher.test.js +161 -9
  39. package/dist/test/workspace-watcher.test.js.map +1 -1
  40. package/dist/tsconfig.tsbuildinfo +1 -1
  41. package/package.json +3 -3
  42. package/src/agent.ts +11 -1
  43. package/src/framework.ts +1037 -116
  44. package/src/mcpl/channel-registry.ts +401 -54
  45. package/src/modules/workspace/index.ts +60 -6
  46. package/src/modules/workspace/types.ts +2 -0
  47. package/src/modules/workspace/watcher.ts +160 -7
  48. package/src/types/agent.ts +35 -0
  49. 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,
@@ -66,6 +67,29 @@ import type { WorkspaceModule } from './modules/workspace/index.js';
66
67
  import { toolResultDataToHistoryString } from './tool-result-history.js';
67
68
  import { splitProseSegments } from './prose-segments.js';
68
69
 
70
+ /** Detect a supported image media type from magic bytes (the model API
71
+ * rejects mislabeled media types, so trust bytes over extensions).
72
+ * Returns undefined for non-image content. */
73
+ function sniffImageMediaType(data: Buffer): string | undefined {
74
+ if (data.length >= 4 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) {
75
+ return 'image/png';
76
+ }
77
+ if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
78
+ return 'image/jpeg';
79
+ }
80
+ if (data.length >= 3 && data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46) {
81
+ return 'image/gif';
82
+ }
83
+ if (
84
+ data.length >= 12 &&
85
+ data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46 &&
86
+ data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50
87
+ ) {
88
+ return 'image/webp';
89
+ }
90
+ return undefined;
91
+ }
92
+
69
93
  /**
70
94
  * Tools whose presence suppresses auto-routing of the surrounding prose,
71
95
  * for two distinct reasons:
@@ -86,6 +110,134 @@ import { splitProseSegments } from './prose-segments.js';
86
110
  const SILENCING_TOOLS = new Set([
87
111
  'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
88
112
  ]);
113
+
114
+ /**
115
+ * True when an injected message is real conversational input — something the
116
+ * agent might actually be replying to — rather than ambient machinery.
117
+ * System markers (`system: true` — send-failed notices, routing notices) and
118
+ * reactions (`chat:reaction` tag, MCPL RFC-001) don't count: they must not
119
+ * clear explicit-send suppression, and they never influence routing.
120
+ */
121
+ const isConversationalInjection = (metadata?: MessageMetadata): boolean => {
122
+ if (!metadata) return true;
123
+ const m = metadata as Record<string, unknown>;
124
+ if (m.system === true) return false;
125
+ if (Array.isArray(m.tags) && m.tags.includes('chat:reaction')) return false;
126
+ return true;
127
+ };
128
+
129
+ /**
130
+ * Explicit delivery tools whose successful use into a closed channel OPENS
131
+ * it (send implies engagement — see openIfClosedForSend). Bare tool names,
132
+ * matched after stripping the MCPL server prefix.
133
+ */
134
+ const SEND_ENGAGEMENT_TOOLS = new Set([
135
+ 'send_message', 'reply_message', 'send_dm', 'send_file', 'send_files',
136
+ ]);
137
+
138
+ /**
139
+ * Best-effort extraction of the delivered channel id from an MCPL send-tool
140
+ * result (the discord-mcpl send tools return JSON like
141
+ * `{"messageId":"…","channelId":"…"}` as a text block). Returns undefined on
142
+ * any shape mismatch — the caller falls back to the tool's input args.
143
+ */
144
+ function extractChannelIdFromToolResult(
145
+ content: Array<{ type: string; text?: string }> | undefined,
146
+ ): string | undefined {
147
+ const text = content?.find((c) => c.type === 'text' && c.text)?.text;
148
+ if (!text) return undefined;
149
+ try {
150
+ const parsed = JSON.parse(text) as { channelId?: unknown };
151
+ return typeof parsed.channelId === 'string' && parsed.channelId.length > 0
152
+ ? parsed.channelId
153
+ : undefined;
154
+ } catch {
155
+ return undefined;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * True when an incoming channel message explicitly addressed the agent.
161
+ * Primary signal is the MCPL RFC-001 `chat:addressed` tag (mention / reply /
162
+ * DM, as classified by the server); the metadata booleans are the fallback
163
+ * for servers that predate tags. Addressed messages outrank ambient chatter
164
+ * when picking a turn's frozen speech locus.
165
+ */
166
+ const isAddressedMessage = (
167
+ tags?: string[],
168
+ _metadata?: Record<string, unknown>,
169
+ ): boolean => Array.isArray(tags) && tags.includes('chat:addressed');
170
+
171
+ /**
172
+ * Parse a prose segment's routing prefix (explicit prose routing —
173
+ * docs/explicit-prose-routing.md). The FIRST line may be:
174
+ * `>>target [!] [body…]` — target = channel spec or `private`
175
+ * The ` !` continuation modifier must immediately follow the target,
176
+ * whitespace-separated. Body may start on the same line or the next.
177
+ */
178
+ function parseProsePrefix(text: string): {
179
+ kind: 'target' | 'private' | 'none';
180
+ target?: string;
181
+ continueTurn: boolean;
182
+ body: string;
183
+ } {
184
+ const m = /^>>(\S+)([ \t]+!)?[ \t]*\n?/.exec(text);
185
+ if (!m) return { kind: 'none', continueTurn: false, body: text };
186
+ const target = m[1]!;
187
+ const continueTurn = m[2] !== undefined;
188
+ const body = text.slice(m[0].length);
189
+ // `skip_reply` mirrors the tool the model already knows: text stays in
190
+ // context, nothing is delivered. (Deliberately NOT called "private"/"note" —
191
+ // that vocabulary sat adjacent to signed thinking in a live window and drew
192
+ // a reasoning_extraction classifier hit, 2026-07-24 Fable.)
193
+ if (target === 'skip_reply') {
194
+ return { kind: 'private', continueTurn, body };
195
+ }
196
+ return { kind: 'target', target, continueTurn, body };
197
+ }
198
+
199
+ /** One-time primer appended when an agent's proseRouting mode changes. */
200
+ function proseModePrimer(mode: 'explicit' | 'locus'): string {
201
+ if (mode === 'locus') {
202
+ return '[prose-routing] Mode change: your plain text auto-routes to the ' +
203
+ 'conversational locus again. `>>` destination prefixes are no longer needed.';
204
+ }
205
+ // Deliberately terse: a short event-style notice is classifier-safe from
206
+ // the user role (ablation D, 2026-07-24), while the full grammar as a user
207
+ // message drew a deterministic reasoning_extraction refusal (ablation A/B).
208
+ // The grammar is TAUGHT BY BOUNCE: the first undelivered text produces a
209
+ // notice with the exact resend syntax, and the full reference is available
210
+ // on demand via the prose_help tool (a tool RESULT is model-requested
211
+ // content — the safest role there is). Nothing is injected into the system
212
+ // prompt: those bytes belong to the recipe, and the deepest KV prefix must
213
+ // not vary with framework code.
214
+ return (
215
+ '[prose-routing] Output mode changed: plain text is delivered only with a ' +
216
+ 'destination prefix line, e.g. ">>#channel-name your text". ' +
217
+ 'The prose_help tool shows the full syntax.'
218
+ );
219
+ }
220
+
221
+ /** Full `>>` routing reference, served as the prose_help TOOL RESULT (never
222
+ * injected ambiently — see proseModePrimer note). */
223
+ const PROSE_ROUTING_HELP =
224
+ 'Plain-text output routing (explicit mode):\n' +
225
+ ' >>#channel-name … or >>@person … (DM) or >>service:guild:id …\n' +
226
+ ' One message may hold several sections: each line starting with ">>" begins\n' +
227
+ ' a new envelope, routed to its own destination independently.\n' +
228
+ ' The first destination in a turn applies to the rest of that turn.\n' +
229
+ ' Append " !" after the destination (e.g. ">>#ops !") to start your next turn\n' +
230
+ ' immediately when this one ends, instead of pausing until the next event.\n' +
231
+ ' >>skip_reply — text stays in your context only, like the skip_reply tool.\n' +
232
+ 'Text without a destination is not delivered: it is retained, and a notice will\n' +
233
+ 'prompt you to resend — reply e.g. ">>#channel {{unsent}}" to deliver the retained\n' +
234
+ 'text unchanged. Send tools (send_message, send_dm, …) are unaffected.';
235
+
236
+ const PROSE_HELP_TOOL: import('./types/index.js').ToolDefinition = {
237
+ name: 'prose_help',
238
+ description: 'Show the plain-text output routing syntax (destination prefixes, resend token, skip).',
239
+ inputSchema: { type: 'object' as const, properties: {} },
240
+ };
89
241
  /** Strip the `server--` MCPL prefix from a tool name. */
90
242
  const bareToolName = (n: string): string => n.split('--').pop()!;
91
243
  import { CheckpointManager } from './mcpl/checkpoint-manager.js';
@@ -368,20 +520,38 @@ export class AgentFramework {
368
520
  private processLoggingBroadcast: boolean;
369
521
  private activeStreams: Map<string, Promise<void>> = new Map();
370
522
 
371
- /** Per-agent output locus pinned for the CURRENT logical turn (see
372
- * resolveTurnLocus in driveStream). Lives here — not in driveStream
373
- * locals — so the pin survives a context-budget stream restart, which
374
- * continues the same logical turn in a fresh driveStream. A mid-turn
375
- * injected channel message replaces the pin for the next conversational
376
- * round; otherwise it remains stable. Cleared at the start of every
377
- * non-restart turn. */
523
+ /** Per-agent output locus FROZEN for the CURRENT logical turn. Resolved
524
+ * eagerly in startAgentStream (home → addressed trigger → global default)
525
+ * and never moved until the next turn: mid-turn injected messages — ambient
526
+ * chatter, reactions, system markers — must not hijack where the agent's
527
+ * plain prose lands (2026-07-21 Cairn lounge misroute). Lives here — not in
528
+ * driveStream locals — so the pin survives a context-budget stream restart,
529
+ * which continues the same logical turn in a fresh driveStream. Cleared or
530
+ * re-set at the start of every non-restart turn. */
378
531
  private turnLocusPins: Map<string, string> = new Map();
379
- /** A tool boundary injected fresh external input into the live stream.
380
- * Presence (including a null value) tells driveStream to clear sticky
381
- * explicit-send suppression before handling the next model round. A
382
- * string also moves that round's reply locus to the newest injected
383
- * channel. */
384
- private midTurnRoutingResets: Map<string, string | null> = new Map();
532
+ /** A tool boundary injected fresh CONVERSATIONAL input (a real message —
533
+ * not a reaction or a system marker) into the live stream. Tells
534
+ * driveStream to clear sticky explicit-send suppression before handling
535
+ * the next model round. Never affects routing: the turn locus is frozen. */
536
+ private midTurnInputSignals: Set<string> = new Set();
537
+ /** Last outbound locus announced to each agent as a durable `[routing]`
538
+ * window message. Announce-on-change only: steady state emits nothing
539
+ * (KV-safe, no per-turn chatter). In-memory — after a process restart the
540
+ * first turn's locus is announced once to re-establish the baseline. */
541
+ private lastAnnouncedLocus: Map<string, string | null> = new Map();
542
+
543
+ // ---- Explicit prose routing (docs/explicit-prose-routing.md) ----------
544
+ /** Latest bounced (undelivered) prose per agent — the `{{unsent}}` source. */
545
+ private proseClipboards: Map<string, string> = new Map();
546
+ /** Sticky per-TURN delivery target set by the turn's first `>>` prefix.
547
+ * Cleared at every non-restart turn start; survives context restarts. */
548
+ private proseTargetPins: Map<string, string> = new Map();
549
+ /** Agents whose current turn requested `!` continuation — re-woken when the
550
+ * turn completes instead of pausing until the next external event. */
551
+ private proseContinuations: Set<string> = new Set();
552
+ /** Consecutive bounce-wakes per agent; capped so an agent that keeps
553
+ * emitting unprefixed prose can't wake-loop (notices still append). */
554
+ private proseBounceStreaks: Map<string, number> = new Map();
385
555
  private pendingAssistantBlocks: Map<string, ContentBlock[]> = new Map();
386
556
  /** Streams the FRAMEWORK cancelled for non-terminal reasons, keyed
387
557
  * `${agentName}:${streamId}`: an endTurn tool result or a context-budget
@@ -1134,7 +1304,9 @@ export class AgentFramework {
1134
1304
  ...channelTools,
1135
1305
  ...gateTools,
1136
1306
  this.buildAgentSettingsTool(),
1137
- ...(this.getWorkspaceModule() ? [AgentFramework.SAVE_IMAGE_TOOL] : []),
1307
+ ...(this.getWorkspaceModule()
1308
+ ? [AgentFramework.SAVE_IMAGE_TOOL, AgentFramework.READ_IMAGE_TOOL]
1309
+ : []),
1138
1310
  ];
1139
1311
  }
1140
1312
 
@@ -1271,25 +1443,55 @@ export class AgentFramework {
1271
1443
  return agent.getRuntimeSettings();
1272
1444
  }
1273
1445
 
1446
+ /**
1447
+ * Apply runtime settings.
1448
+ *
1449
+ * `opts.persist: false` makes the change EPHEMERAL: it takes effect live but
1450
+ * is not written to the `framework/state` slot, so a restart reverts it. That
1451
+ * is the mode for operator experiments ("change it, watch, revert") — the
1452
+ * default persists, because a durable operational change should survive a
1453
+ * bounce like every other override.
1454
+ *
1455
+ * Nothing here notifies the agent. Discovery is deliberately PULL: the agent
1456
+ * can read current settings any time via the `agent_settings` tool's `get`
1457
+ * action, which costs no context perturbation. Pushing a notice injects text
1458
+ * into the very context being tuned — it invalidates the KV prefix and can
1459
+ * itself trip a classifier — so callers opt into that explicitly rather than
1460
+ * getting it by default.
1461
+ *
1462
+ * KNOWN LIMITATION of `persist: false`: the change still lands in the agent's
1463
+ * in-memory override map, and `persistAgentRuntimeSettings` writes that whole
1464
+ * map. So a LATER persisting call promotes any still-live ephemeral change to
1465
+ * durable as a side effect. Ephemeral means "not written by THIS call", not
1466
+ * "can never be written". Reset the ephemeral key before persisting something
1467
+ * else if that matters. Making it airtight needs per-key ephemeral tracking
1468
+ * inside Agent.
1469
+ */
1274
1470
  updateAgentRuntimeSettings(
1275
1471
  agentName: string,
1276
1472
  patch: AgentRuntimeSettingsPatch,
1473
+ opts?: { persist?: boolean },
1277
1474
  ): AgentRuntimeSettingsSnapshot {
1278
1475
  const agent = this.agents.get(agentName);
1279
1476
  if (!agent) throw new Error(`Unknown agent: ${agentName}`);
1280
1477
  const result = agent.updateRuntimeSettings(patch);
1281
- this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
1478
+ if (opts?.persist !== false) {
1479
+ this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
1480
+ }
1282
1481
  return result;
1283
1482
  }
1284
1483
 
1285
1484
  resetAgentRuntimeSettings(
1286
1485
  agentName: string,
1287
1486
  keys?: Array<keyof AgentRuntimeSettingsPatch>,
1487
+ opts?: { persist?: boolean },
1288
1488
  ): AgentRuntimeSettingsSnapshot {
1289
1489
  const agent = this.agents.get(agentName);
1290
1490
  if (!agent) throw new Error(`Unknown agent: ${agentName}`);
1291
1491
  const result = agent.resetRuntimeSettings(keys);
1292
- this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
1492
+ if (opts?.persist !== false) {
1493
+ this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
1494
+ }
1293
1495
  return result;
1294
1496
  }
1295
1497
 
@@ -1350,7 +1552,7 @@ export class AgentFramework {
1350
1552
  */
1351
1553
  async previewActivation(
1352
1554
  agentName: string,
1353
- opts?: { injections?: boolean }
1555
+ opts?: { injections?: boolean; budget?: TokenBudget }
1354
1556
  ): Promise<NormalizedRequest> {
1355
1557
  const agent = this.agents.get(agentName);
1356
1558
  if (!agent) {
@@ -1359,10 +1561,14 @@ export class AgentFramework {
1359
1561
 
1360
1562
  const tools = this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name));
1361
1563
 
1564
+ // An explicit budget compiles against a HYPOTHETICAL window instead of the
1565
+ // agent's live one. That also suppresses transition-settling in
1566
+ // compileWithInjections (which only settles when no budget is passed), so
1567
+ // previewing a smaller window cannot advance a converging descent.
1362
1568
  // Default: no dynamic injection gathering → fully transparent (no
1363
1569
  // inference, no Chronicle writes, no external RPC). Opt in explicitly.
1364
1570
  if (!opts?.injections) {
1365
- return agent.buildActivationRequest(tools, undefined);
1571
+ return agent.buildActivationRequest(tools, undefined, opts?.budget);
1366
1572
  }
1367
1573
 
1368
1574
  // Full-fidelity path: mirrors startAgentStream's injection gathering.
@@ -1397,7 +1603,42 @@ export class AgentFramework {
1397
1603
  }
1398
1604
  }
1399
1605
 
1400
- return agent.buildActivationRequest(tools, injections);
1606
+ return agent.buildActivationRequest(tools, injections, opts?.budget);
1607
+ }
1608
+
1609
+ /**
1610
+ * Non-committing preview of the FOLD PLAN at a hypothetical budget and/or
1611
+ * context settings — the numbers behind an operator's "what happens if I set
1612
+ * the budget to X" before they apply it.
1613
+ *
1614
+ * Unlike `previewActivation` (which renders a request), this returns the
1615
+ * picker's plan: resulting tokens, whether it fits, how many summaries would
1616
+ * have to be produced first, and the per-chunk fold levels. Commits nothing:
1617
+ * no resolution persistence, no compression enqueue, no transition
1618
+ * bookkeeping — see `AutobiographicalStrategy.previewContext`.
1619
+ *
1620
+ * Returns null when the agent's strategy has no fold plan (non-adaptive).
1621
+ */
1622
+ previewContextSettings(
1623
+ agentName: string,
1624
+ budgetTokens: number,
1625
+ overrides?: Record<string, unknown>,
1626
+ ): unknown {
1627
+ const agent = this.agents.get(agentName);
1628
+ if (!agent) throw new Error(`Unknown agent: ${agentName}`);
1629
+ const cm = agent.getContextManager() as unknown as {
1630
+ previewContext?: (
1631
+ budget: TokenBudget,
1632
+ overrides?: Record<string, unknown>,
1633
+ ) => unknown;
1634
+ };
1635
+ if (typeof cm.previewContext !== 'function') return null;
1636
+ // reserveForResponse mirrors the live compile so the previewed middle is
1637
+ // comparable to what the agent actually gets.
1638
+ return cm.previewContext(
1639
+ { maxTokens: budgetTokens, reserveForResponse: agent.maxTokens },
1640
+ overrides,
1641
+ );
1401
1642
  }
1402
1643
 
1403
1644
  /**
@@ -1915,6 +2156,29 @@ export class AgentFramework {
1915
2156
  },
1916
2157
  };
1917
2158
 
2159
+ /** Synthesized read_image tool — present when a workspace module is
2160
+ * registered. Returns the image as a native image block in the tool
2161
+ * result, so the agent SEES it in the live turn (history keeps a compact
2162
+ * placeholder via the standard tool-result serializer). */
2163
+ private static readonly READ_IMAGE_TOOL: import('./types/index.js').ToolDefinition = {
2164
+ name: 'read_image',
2165
+ description:
2166
+ 'View an image file from a workspace mount. The image is returned into ' +
2167
+ 'your context so you can actually see it (e.g. after save_recent_image, ' +
2168
+ 'or for images placed in the workspace by other means). Path is ' +
2169
+ 'mount-prefixed, e.g. "project/photos/cat.png". Supports png/jpeg/gif/webp.',
2170
+ inputSchema: {
2171
+ type: 'object',
2172
+ properties: {
2173
+ path: {
2174
+ type: 'string',
2175
+ description: 'Mount-prefixed image path, e.g. "project/photos/name.png".',
2176
+ },
2177
+ },
2178
+ required: ['path'],
2179
+ },
2180
+ };
2181
+
1918
2182
  private static readonly AGENT_SETTINGS_TOOL: import('./types/index.js').ToolDefinition = {
1919
2183
  name: 'agent_settings',
1920
2184
  description:
@@ -3209,23 +3473,17 @@ export class AgentFramework {
3209
3473
  }
3210
3474
  }
3211
3475
 
3212
- // A newly injected message begins a new conversational round inside
3213
- // the same provider inference. Remember that boundary for
3214
- // driveStream: an explicit send in the preceding round must not
3215
- // silence the reply, and the newest channel-bearing injection is
3216
- // now the reply locus. Map presence matters even without a channel
3217
- // (CLI/module input): it still clears the prior send suppression.
3218
- if (midTurnInjections.length > 0) {
3219
- const injectedChannelId = midTurnInjections.reduce<string | null>(
3220
- (latest, injection) => {
3221
- const candidate = injection.metadata?.channelId;
3222
- return typeof candidate === 'string' && candidate.length > 0
3223
- ? candidate
3224
- : latest;
3225
- },
3226
- null,
3227
- );
3228
- this.midTurnRoutingResets.set(agent.name, injectedChannelId);
3476
+ // A newly injected CONVERSATIONAL message begins a new
3477
+ // conversational round inside the same provider inference. Remember
3478
+ // that boundary for driveStream: an explicit send in the preceding
3479
+ // round must not silence the reply. Routing is deliberately NOT
3480
+ // affected — the turn locus is frozen at turn start, so ambient
3481
+ // chatter, reactions, and system markers injected mid-turn can no
3482
+ // longer hijack where the agent's prose lands. Non-conversational
3483
+ // injections (reactions, `system: true` markers) don't even clear
3484
+ // the send suppression: nothing new was said to the agent.
3485
+ if (midTurnInjections.some((inj) => isConversationalInjection(inj.metadata))) {
3486
+ this.midTurnInputSignals.add(agent.name);
3229
3487
  }
3230
3488
 
3231
3489
  // Check if any tool result requested endTurn
@@ -3445,6 +3703,7 @@ export class AgentFramework {
3445
3703
  content: ContentBlock[];
3446
3704
  timestamp: string;
3447
3705
  metadata?: Record<string, unknown>;
3706
+ tags?: string[];
3448
3707
  triggerInference?: boolean;
3449
3708
  }): Promise<void> {
3450
3709
  const metadata: Record<string, unknown> = {
@@ -3456,6 +3715,7 @@ export class AgentFramework {
3456
3715
  serverId: event.serverId,
3457
3716
  };
3458
3717
  if (event.threadId) metadata.threadId = event.threadId;
3718
+ if (event.tags) metadata.tags = event.tags;
3459
3719
 
3460
3720
  // Per-channel conversation routing: messages go to the channel's fork
3461
3721
  // agent (spawned from the template on first qualifying message), never
@@ -3469,6 +3729,7 @@ export class AgentFramework {
3469
3729
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
3470
3730
 
3471
3731
  if (event.triggerInference) {
3732
+ const addressed = isAddressedMessage(event.tags, event.metadata);
3472
3733
  for (const agentName of this.agents.keys()) {
3473
3734
  this.pendingRequests.push({
3474
3735
  agentName,
@@ -3478,6 +3739,9 @@ export class AgentFramework {
3478
3739
  // Route this turn's auto-published speech back to THIS channel, not
3479
3740
  // the global most-recent-inbound locus (item-3 redux, trunk agents).
3480
3741
  channelId: event.channelId,
3742
+ // Addressed messages outrank ambient chatter when a batched wake
3743
+ // picks the turn's frozen speech locus.
3744
+ addressed,
3481
3745
  });
3482
3746
  }
3483
3747
  }
@@ -3497,6 +3761,7 @@ export class AgentFramework {
3497
3761
  author: { id: string; name: string };
3498
3762
  content: ContentBlock[];
3499
3763
  metadata?: Record<string, unknown>;
3764
+ tags?: string[];
3500
3765
  triggerInference?: boolean;
3501
3766
  },
3502
3767
  messageMetadata: Record<string, unknown>,
@@ -3575,6 +3840,7 @@ export class AgentFramework {
3575
3840
  // A fork's home channel wins in routeSpeech regardless, but carry the
3576
3841
  // triggering channel too so the trunk/active path stays consistent.
3577
3842
  channelId: event.channelId,
3843
+ addressed: isAddressedMessage(event.tags, event.metadata),
3578
3844
  });
3579
3845
  }
3580
3846
  }
@@ -3752,6 +4018,7 @@ export class AgentFramework {
3752
4018
  event.serverId,
3753
4019
  triggerChannel.channelId,
3754
4020
  triggerChannel.label,
4021
+ triggerChannel.metadata,
3755
4022
  );
3756
4023
  }
3757
4024
 
@@ -3761,7 +4028,17 @@ export class AgentFramework {
3761
4028
  featureSet: event.featureSet,
3762
4029
  eventId: event.eventId,
3763
4030
  triggered: event.triggerInference ?? false,
4031
+ ...(event.tags ? { tags: event.tags } : {}),
3764
4032
  };
4033
+ // `origin.channelId` is the server-internal raw id (a bare Discord
4034
+ // snowflake) — unroutable as a locus and unresolvable by the agent. Store
4035
+ // the composite MCPL id instead wherever we can derive it, so anything
4036
+ // downstream that reads `metadata.channelId` (routing, provenance, the
4037
+ // agent itself) sees the qualified form. The raw value stays available on
4038
+ // servers that also send `rawChannelId`-style origin fields.
4039
+ if (triggerChannel) {
4040
+ metadata.channelId = triggerChannel.channelId;
4041
+ }
3765
4042
 
3766
4043
  const content = [...event.content];
3767
4044
  const addressedWhileClosed = triggerChannel &&
@@ -3774,18 +4051,30 @@ export class AgentFramework {
3774
4051
  ? event.origin.messageId
3775
4052
  : event.eventId;
3776
4053
  const maxBackscroll = descriptor?.capabilities?.history?.maxMessages ?? 0;
4054
+ // Resolve human-readable names from the event origin wherever the
4055
+ // surface provided them — a mind reading this should see WHO addressed
4056
+ // it WHERE, not a wall of snowflake ids. Raw ids remain only inside the
4057
+ // tool-argument instructions, where they are the literal values to pass.
4058
+ const origin = (event.origin ?? {}) as Record<string, unknown>;
4059
+ const authorName = typeof origin.authorName === 'string' && origin.authorName ? origin.authorName : 'Someone';
4060
+ const channelLabel = triggerChannel.label ? `#${triggerChannel.label}` : `"${triggerChannel.channelId}"`;
4061
+ const place = typeof origin.guildName === 'string' && origin.guildName
4062
+ ? `${channelLabel} in "${origin.guildName}"`
4063
+ : channelLabel;
3777
4064
  content.push({
3778
4065
  type: 'text',
3779
4066
  text:
3780
- `\n[Channel invitation] You were addressed in closed channel ` +
3781
- `"${triggerChannel.label ?? triggerChannel.channelId}" (${triggerChannel.channelId}, server ${event.serverId}). ` +
3782
- `You received this one message without subscribing. ` +
4067
+ `\n[Channel invitation] ${authorName} addressed you in ${place} — ` +
4068
+ `a channel you haven't joined. You're seeing this one message; you won't receive more from it unless you join. ` +
4069
+ `Your options:\n` +
4070
+ `1. Reply without joining — simply write your reply as normal text this turn; it will be delivered to ${channelLabel}.\n` +
4071
+ `2. Join the channel — call channel_open with channelId "${triggerChannel.channelId}" and serverId "${event.serverId}"` +
3783
4072
  (maxBackscroll > 0
3784
- ? `To subscribe, call channel_open with channelId, serverId "${event.serverId}", backscroll (0-${maxBackscroll}), and beforeMessageId "${messageId}". `
3785
- : `To subscribe, call channel_open with this channelId and serverId "${event.serverId}". `) +
3786
- `You may reply once without subscribing. To remain closed, call channel_decline ` +
3787
- `with channelId, serverId "${event.serverId}", and messageId "${messageId}"; ` +
3788
- `optionally set acknowledge to a surface value such as 👀.`,
4073
+ ? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${messageId}".\n`
4074
+ : `.\n`) +
4075
+ `3. Stay out — call channel_decline with channelId "${triggerChannel.channelId}", serverId "${event.serverId}", ` +
4076
+ `and messageId "${messageId}" (optionally set acknowledge to an emoji like 👀 so ${authorName} isn't left hanging).\n` +
4077
+ `Doing nothing is also fine.`,
3789
4078
  });
3790
4079
  metadata.channelInvitation = true;
3791
4080
  metadata.channelOpen = false;
@@ -3807,6 +4096,10 @@ export class AgentFramework {
3807
4096
  source: event.serverId,
3808
4097
  timestamp: Date.now(),
3809
4098
  channelId: triggerChannel?.channelId,
4099
+ // DMs and addressed-while-closed messages arrive as push events;
4100
+ // they must outrank ambient chatter in a batched wake's locus
4101
+ // selection just like their channels/incoming counterparts.
4102
+ addressed: isAddressedMessage(event.tags, event.origin),
3810
4103
  });
3811
4104
  }
3812
4105
  }
@@ -3830,13 +4123,29 @@ export class AgentFramework {
3830
4123
  */
3831
4124
  private derivePushEventChannel(
3832
4125
  origin: Record<string, unknown> | undefined,
3833
- ): { channelId: string; label?: string } | undefined {
4126
+ ): { channelId: string; label?: string; metadata?: Record<string, unknown> } | undefined {
3834
4127
  if (!origin) return undefined;
3835
- const label = typeof origin.channelName === 'string' ? origin.channelName : undefined;
4128
+ let label = typeof origin.channelName === 'string' && origin.channelName ? origin.channelName : undefined;
4129
+ let metadata: Record<string, unknown> | undefined;
4130
+
4131
+ // A DM has no channelName — label it by the PERSON and carry their
4132
+ // identity, so DM registrations are people-first (`DM: antra`, matchable
4133
+ // by `>>@name` / `<@id>` mention tokens) instead of bare snowflakes.
4134
+ const isDM = origin.isDM === true || origin.guildId === null;
4135
+ if (isDM && typeof origin.authorName === 'string' && origin.authorName) {
4136
+ label = label ?? `DM: ${origin.authorName}`;
4137
+ metadata = {
4138
+ channelType: 'dm',
4139
+ recipientName: origin.authorName,
4140
+ ...(typeof origin.authorId === 'string' && origin.authorId
4141
+ ? { recipientId: origin.authorId }
4142
+ : {}),
4143
+ };
4144
+ }
3836
4145
 
3837
4146
  const explicit = origin.mcplChannelId;
3838
4147
  if (typeof explicit === 'string' && explicit) {
3839
- return { channelId: explicit, label };
4148
+ return { channelId: explicit, ...(label ? { label } : {}), ...(metadata ? { metadata } : {}) };
3840
4149
  }
3841
4150
 
3842
4151
  // Discord fallback: reconstruct the composite from origin parts. `guildId`
@@ -3844,7 +4153,11 @@ export class AgentFramework {
3844
4153
  if (origin.source === 'discord' && typeof origin.channelId === 'string' && origin.channelId) {
3845
4154
  const guild =
3846
4155
  typeof origin.guildId === 'string' && origin.guildId ? origin.guildId : 'dm';
3847
- return { channelId: `discord:${guild}:${origin.channelId}`, label };
4156
+ return {
4157
+ channelId: `discord:${guild}:${origin.channelId}`,
4158
+ ...(label ? { label } : {}),
4159
+ ...(metadata ? { metadata } : {}),
4160
+ };
3848
4161
  }
3849
4162
 
3850
4163
  return undefined;
@@ -3932,17 +4245,302 @@ export class AgentFramework {
3932
4245
  const trigger = requests[0];
3933
4246
  // Route this turn's auto-published speech to the channel that triggered
3934
4247
  // it (item-3 redux). A batched wake may carry several triggering channels
3935
- // (messages arrived in >1 channel while the agent was busy/idle) — reply
3936
- // in the MOST-RECENT one: it matches the legacy last-inbound semantics and
3937
- // is the message the agent is most likely answering. The common case is
3938
- // 1 message → 1 channel (unambiguous). Non-channel wakes carry no
3939
- // channelId, leaving the field undefined → global fallback. `reduce`
3940
- // keeps the last defined channelId across the (FIFO-ordered) batch.
3941
- const triggerChannel = requests.reduce<string | undefined>(
3942
- (acc, r) => r.channelId ?? acc,
3943
- undefined,
4248
+ // (messages arrived in >1 channel while the agent was busy/idle):
4249
+ // 1. the most recent ADDRESSED channel wins (mention / reply / DM —
4250
+ // someone explicitly spoke TO the agent);
4251
+ // 2. else the most recent channel-bearing request (ambient message in
4252
+ // an open channel — legacy last-inbound semantics).
4253
+ // Ambient chatter must not outrank an addressed message just by being
4254
+ // newest (2026-07-21 Cairn lounge misroute, turn-start variant).
4255
+ // Non-channel wakes (heartbeats, module events, reactions — which never
4256
+ // carry channelId) leave both undefined → global fallback.
4257
+ let ambientChannel: string | undefined;
4258
+ let addressedChannel: string | undefined;
4259
+ for (const r of requests) {
4260
+ if (!r.channelId) continue;
4261
+ ambientChannel = r.channelId;
4262
+ if (r.addressed) addressedChannel = r.channelId;
4263
+ }
4264
+ const triggerChannel = addressedChannel ?? ambientChannel;
4265
+ const triggerAddressed = addressedChannel !== undefined;
4266
+ await this.startAgentStream(agent, {
4267
+ ...trigger,
4268
+ channelId: triggerChannel,
4269
+ addressed: triggerAddressed,
4270
+ });
4271
+ }
4272
+ }
4273
+
4274
+ /**
4275
+ * Announce-on-change locus notice (turn-frozen routing). Appends a durable
4276
+ * `[routing]` window message when the turn's effective outbound locus
4277
+ * differs from the last one announced — the agent must never have to guess
4278
+ * where its plain prose lands. Durable append (never retracted) keeps the
4279
+ * KV prefix stable; change-only keeps it out of steady-state turns. The
4280
+ * first resolution after boot announces only a non-null locus ("prose has
4281
+ * no destination" is the unremarkable boot default). Never triggers
4282
+ * inference; never locus-eligible itself (system + no channel metadata).
4283
+ */
4284
+ private announceLocusIfChanged(agentName: string, locus: string | null): void {
4285
+ const hasBaseline = this.lastAnnouncedLocus.has(agentName);
4286
+ const prev = this.lastAnnouncedLocus.get(agentName) ?? null;
4287
+ if (hasBaseline ? prev === locus : locus === null) {
4288
+ if (!hasBaseline) this.lastAnnouncedLocus.set(agentName, locus);
4289
+ return;
4290
+ }
4291
+ this.lastAnnouncedLocus.set(agentName, locus);
4292
+
4293
+ const agent = this.agents.get(agentName);
4294
+ if (!agent) return;
4295
+
4296
+ let text: string;
4297
+ if (locus === null) {
4298
+ text =
4299
+ '[routing] Your plain speech currently has no channel — it stays in ' +
4300
+ 'your archive. Use an explicit send tool to reach a channel.';
4301
+ } else {
4302
+ const label = this.channelRegistry?.getDescriptor(locus)?.label;
4303
+ const shown =
4304
+ label && label !== locus
4305
+ ? `${label.startsWith('#') ? label : `#${label}`} (${locus})`
4306
+ : locus;
4307
+ text =
4308
+ `[routing] Your plain speech now lands in ${shown}. ` +
4309
+ 'Other channels need an explicit send tool.';
4310
+ }
4311
+
4312
+ try {
4313
+ const id = agent.getContextManager().addMessage(
4314
+ 'user',
4315
+ [{ type: 'text', text }],
4316
+ { system: true, kind: 'routing-notice' },
4317
+ );
4318
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
4319
+ console.error(
4320
+ `[routing] ${agentName}: locus ${prev ?? '(none)'} -> ${locus ?? '(none)'} (announced in window)`,
4321
+ );
4322
+ } catch (err) {
4323
+ console.error('announceLocusIfChanged: failed to record routing notice:', err);
4324
+ }
4325
+ }
4326
+
4327
+ /**
4328
+ * Durable window notice for a channel the agent did not ask to open —
4329
+ * subscription-policy admission, or an open forced by the agent's own
4330
+ * delivery into a closed channel. The agent must always learn that new
4331
+ * traffic will start flowing, and that `channel_close` opts out (their
4332
+ * decision outranks policy). System message: never triggers inference,
4333
+ * never conversational, never locus-eligible.
4334
+ */
4335
+ private recordChannelAutoOpenNotice(
4336
+ agentName: string | undefined,
4337
+ channels: Array<{ channelId: string; label?: string }>,
4338
+ cause: 'subscription-policy' | 'opened-by-delivery' | 'opened-by-reply',
4339
+ ): void {
4340
+ if (channels.length === 0) return;
4341
+ const shown = channels
4342
+ .map((c) => (c.label && c.label !== c.channelId ? `${c.label} (${c.channelId})` : c.channelId))
4343
+ .join(', ');
4344
+ const plural = channels.length > 1;
4345
+ const causeText =
4346
+ cause === 'subscription-policy'
4347
+ ? 'your subscription policy just admitted ' + (plural ? 'them' : 'it')
4348
+ : 'your own message into the closed channel engaged it';
4349
+ const text =
4350
+ `[channels] Now open: ${shown} — ${causeText}. Ongoing traffic from ` +
4351
+ (plural ? 'these channels' : 'this channel') +
4352
+ ` will reach you. If you don't want ${plural ? 'one of them' : 'it'}, use channel_close — ` +
4353
+ 'your choice sticks; neither policy nor a later send will silently reopen it.';
4354
+ try {
4355
+ // Through framework.addMessage for mid-turn safety (deferral +
4356
+ // hear-while-acting injection) — a delivery-forced open happens inside
4357
+ // a live turn, and a direct context-manager append there would store
4358
+ // the notice out of order. Targets the primary agent; connectome-host
4359
+ // runs trunk-only, so this is the delivering agent in practice.
4360
+ this.addMessage('user', [{ type: 'text', text }], { system: true, kind: 'channel-notice' });
4361
+ console.error(`[channel] auto-open notice (${cause}) -> ${agentName ?? 'primary'}: ${shown}`);
4362
+ } catch (err) {
4363
+ console.error('recordChannelAutoOpenNotice failed:', err);
4364
+ }
4365
+ }
4366
+
4367
+ /**
4368
+ * Explicit-prose-routing delivery gateway (docs/explicit-prose-routing.md).
4369
+ * Every prose segment of an `explicit`-mode agent flows through here:
4370
+ * parse the `>>` prefix, resolve the target, deliver — or bounce to the
4371
+ * clipboard with a resend notice. Misdelivery is structurally impossible:
4372
+ * nothing is ever sent to a destination the model did not name (directly
4373
+ * or via the turn's sticky target).
4374
+ */
4375
+ private async deliverProse(agent: Agent, rawText: string): Promise<void> {
4376
+ // Multi-envelope: a single prose block may address SEVERAL destinations —
4377
+ // every line beginning with `>>` opens a new envelope, routed
4378
+ // independently (first live use: Tilde 2026-07-24, one block carrying a
4379
+ // letter to #tilde-mythos AND a `>>#tilde` aside; the single-envelope
4380
+ // parser delivered the aside literally into the first destination —
4381
+ // exactly the leak class this mode exists to kill). Text before the
4382
+ // first `>>` is its own unprefixed envelope (sticky/bounce as usual).
4383
+ const lines = rawText.split('\n');
4384
+ const envelopes: string[] = [];
4385
+ let current: string[] = [];
4386
+ for (const line of lines) {
4387
+ // Only a REAL prefix line opens an envelope: `>>` immediately followed
4388
+ // by a target token. A quoted `>> arrow` (space after) stays body text.
4389
+ if (/^>>\S/.test(line.trimStart()) && current.length > 0) {
4390
+ envelopes.push(current.join('\n'));
4391
+ current = [];
4392
+ }
4393
+ current.push(line);
4394
+ }
4395
+ if (current.length > 0) envelopes.push(current.join('\n'));
4396
+
4397
+ for (const envelope of envelopes) {
4398
+ if (!envelope.trim()) continue;
4399
+ await this.deliverProseEnvelope(agent, envelope.replace(/^\s+(?=>>)/, ''));
4400
+ }
4401
+ }
4402
+
4403
+ private async deliverProseEnvelope(agent: Agent, rawText: string): Promise<void> {
4404
+ const name = agent.name;
4405
+ const parsed = parseProsePrefix(rawText);
4406
+ if (parsed.continueTurn) this.proseContinuations.add(name);
4407
+
4408
+ if (parsed.kind === 'private') {
4409
+ // The text already lives in the assistant message (window/chronicle);
4410
+ // "delivery" is deliberately a no-op. Consumes the retained text if the
4411
+ // segment embedded it.
4412
+ if (parsed.body.includes('{{unsent}}')) this.proseClipboards.delete(name);
4413
+ console.error(`[prose] ${name}: >>skip_reply — ${parsed.body.length} chars kept in context (not sent)`);
4414
+ return;
4415
+ }
4416
+
4417
+ let targetChannel: string;
4418
+ let body: string;
4419
+ if (parsed.kind === 'target') {
4420
+ const res = this.channelRegistry!.resolveProseTarget(parsed.target!);
4421
+ if ('error' in res) {
4422
+ this.bounceProse(agent, parsed.body, res.error, res.candidates);
4423
+ return;
4424
+ }
4425
+ targetChannel = res.channelId;
4426
+ // Sticky within the turn: later unprefixed segments follow this target.
4427
+ this.proseTargetPins.set(name, targetChannel);
4428
+ body = parsed.body;
4429
+ } else {
4430
+ const sticky = this.proseTargetPins.get(name);
4431
+ if (!sticky) {
4432
+ this.bounceProse(agent, rawText, 'no destination prefix and no destination set yet this turn');
4433
+ return;
4434
+ }
4435
+ targetChannel = sticky;
4436
+ body = rawText;
4437
+ }
4438
+
4439
+ const usedClipboard = body.includes('{{unsent}}');
4440
+ if (usedClipboard) {
4441
+ body = body.replaceAll('{{unsent}}', this.proseClipboards.get(name) ?? '');
4442
+ }
4443
+ if (!body.trim()) {
4444
+ console.error(`[prose] ${name}: empty body after prefix/substitution — nothing to send`);
4445
+ return;
4446
+ }
4447
+
4448
+ try {
4449
+ const result = await this.channelRegistry!.routeSpeech(name, body, targetChannel);
4450
+ if (result?.delivered) {
4451
+ this.proseBounceStreaks.delete(name);
4452
+ if (usedClipboard) this.proseClipboards.delete(name);
4453
+ }
4454
+ } catch (err) {
4455
+ console.error(`[prose] ${name}: delivery to ${targetChannel} failed:`, err);
4456
+ }
4457
+ }
4458
+
4459
+ /** Cap on consecutive bounce-triggered wakes (notices still append after). */
4460
+ private static readonly PROSE_BOUNCE_WAKE_CAP = 2;
4461
+
4462
+ private bounceProse(agent: Agent, text: string, reason: string, candidates?: string[]): void {
4463
+ const name = agent.name;
4464
+ this.proseClipboards.set(name, text);
4465
+ const streak = (this.proseBounceStreaks.get(name) ?? 0) + 1;
4466
+ this.proseBounceStreaks.set(name, streak);
4467
+ const cand = candidates?.length ? ` Known channels it could mean: ${candidates.join(', ')}.` : '';
4468
+ const notice =
4469
+ `[prose-routing] Your text (${text.length} chars) was not delivered — ${reason}.${cand} ` +
4470
+ 'The text is retained; nothing is lost. To deliver it unchanged, reply with a ' +
4471
+ 'destination plus the token {{unsent}}, e.g. ">>#channel {{unsent}}" or ">>@person {{unsent}}". ' +
4472
+ '">>skip_reply {{unsent}}" keeps it in context only. The prose_help tool shows the full syntax.';
4473
+ try {
4474
+ // Through framework.addMessage, NOT the context manager directly: while
4475
+ // the turn is still streaming this defers the notice to the next tool
4476
+ // boundary, where it is BOTH stored in order and injected into the live
4477
+ // stream (hear-while-acting) — the model sees the error and can correct
4478
+ // the prefix within the same turn. A direct append here would land the
4479
+ // notice before the turn's assistant blocks (cache bust, invisible
4480
+ // until the next compile).
4481
+ const id = this.addMessage(
4482
+ 'user',
4483
+ [{ type: 'text', text: notice }],
4484
+ { system: true, kind: 'prose-bounce' },
4485
+ );
4486
+ if (id) this.emitTrace({ type: 'message:added', messageId: id, source: 'prose-bounce' });
4487
+ else this.emitTrace({ type: 'message:added', messageId: 'deferred', source: 'prose-bounce' });
4488
+ } catch (err) {
4489
+ console.error('bounceProse: failed to record bounce notice:', err);
4490
+ }
4491
+ const capped = streak > AgentFramework.PROSE_BOUNCE_WAKE_CAP;
4492
+ console.error(
4493
+ `[prose] ${name}: BOUNCED ${text.length} chars — ${reason}` +
4494
+ (capped ? ' (bounce-wake cap reached; notice appended without wake)' : ''),
4495
+ );
4496
+ if (!capped) {
4497
+ // Wake so the resend can happen immediately — an unsent DM reply must
4498
+ // not sit invisible until the next unrelated event.
4499
+ this.pendingRequests.push({
4500
+ agentName: name,
4501
+ reason: 'prose-bounce',
4502
+ source: 'framework',
4503
+ timestamp: Date.now(),
4504
+ });
4505
+ }
4506
+ }
4507
+
4508
+ /**
4509
+ * One-time mode primer (docs/explicit-prose-routing.md): when an agent's
4510
+ * configured proseRouting differs from the last mode we primed them for,
4511
+ * append the teaching notice and persist the new mode. A brand-new agent
4512
+ * in default locus mode is recorded silently — no primer for the status quo.
4513
+ */
4514
+ private maybePrimeProseMode(agent: Agent): void {
4515
+ const mode = agent.proseRouting;
4516
+ try {
4517
+ const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
4518
+ const state = (data && typeof data === 'object' ? data : {}) as Record<string, unknown>;
4519
+ const primed = { ...((state.proseRoutingPrimed as Record<string, string> | undefined) ?? {}) };
4520
+ const stored = primed[agent.name];
4521
+ if (stored === mode) return;
4522
+ // Absence means "always default (locus)": a locus-mode agent that was
4523
+ // never explicit needs no entry and no primer — and skipping the write
4524
+ // keeps framework/state from growing a record per agent (scaling gate).
4525
+ if (mode === 'locus' && stored === undefined) return;
4526
+ if (mode === 'locus') delete primed[agent.name];
4527
+ else primed[agent.name] = mode;
4528
+ state.proseRoutingPrimed = primed;
4529
+ this.store.setStateJson(FRAMEWORK_STATE_ID, state);
4530
+ } catch (err) {
4531
+ console.error('maybePrimeProseMode: state read/write failed:', err);
4532
+ return;
4533
+ }
4534
+ try {
4535
+ const id = agent.getContextManager().addMessage(
4536
+ 'user',
4537
+ [{ type: 'text', text: proseModePrimer(mode) }],
4538
+ { system: true, kind: 'prose-routing-primer' },
3944
4539
  );
3945
- await this.startAgentStream(agent, { ...trigger, channelId: triggerChannel });
4540
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'prose-routing-primer' });
4541
+ console.error(`[prose] ${agent.name}: mode primer appended (${mode})`);
4542
+ } catch (err) {
4543
+ console.error('maybePrimeProseMode: failed to append primer:', err);
3946
4544
  }
3947
4545
  }
3948
4546
 
@@ -3959,14 +4557,60 @@ export class AgentFramework {
3959
4557
  // instead falls back to the global default. A given agent runs one turn at a
3960
4558
  // time, so a set here is only read during THIS turn's routeSpeech /
3961
4559
  // buildChannelContext; retries re-run with the same trigger, re-setting it.
3962
- if (trigger?.channelId) {
3963
- this.activeTriggerChannels.set(agent.name, trigger.channelId);
3964
- } else {
3965
- this.activeTriggerChannels.delete(agent.name);
4560
+ // A context-budget restart continues the SAME logical turn in a fresh
4561
+ // stream — its trigger carries no channelId, and deleting the trigger
4562
+ // channel here mid-logical-turn is exactly the hole that sent Sol's DM
4563
+ // reply to a stale guild channel (2026-07-22): any live locus resolution
4564
+ // after the restart fell through to the hours-old defaultPublishChannel.
4565
+ // Keep the turn's trigger channel across restarts; only real new turns
4566
+ // reset it.
4567
+ if (trigger?.reason !== 'context_budget_restart') {
4568
+ if (trigger?.channelId) {
4569
+ this.activeTriggerChannels.set(agent.name, trigger.channelId);
4570
+ } else {
4571
+ this.activeTriggerChannels.delete(agent.name);
4572
+ }
4573
+ }
4574
+
4575
+ // FREEZE this turn's outbound locus now (turn-frozen routing). Resolved
4576
+ // once — home channel for forks, else the triggering channel, else the
4577
+ // global default — and never moved for the rest of the turn: mid-turn
4578
+ // injections must not redirect the agent's plain prose (ambient chatter /
4579
+ // reactions hijacked it before, 2026-07-21). A context-budget restart
4580
+ // continues the same logical turn, so it keeps the existing pin. The
4581
+ // eager snapshot also protects heartbeat turns from a moving
4582
+ // defaultPublishChannel mid-turn. If the effective locus differs from the
4583
+ // last one announced, drop a durable `[routing]` notice into the window
4584
+ // BEFORE this turn compiles, so the agent always knows where its voice
4585
+ // goes (announce-on-change only — no per-turn chatter, append-only for
4586
+ // KV stability).
4587
+ if (trigger?.reason !== 'context_budget_restart') {
4588
+ if (attempt === 0) this.maybePrimeProseMode(agent);
4589
+ if (agent.proseRouting === 'explicit') {
4590
+ // Explicit prose routing: there is no locus. The model names every
4591
+ // destination in-band (`>>` prefixes); the only turn state is the
4592
+ // sticky target, reset for each fresh turn. No freeze, no announce.
4593
+ this.proseTargetPins.delete(agent.name);
4594
+ this.proseContinuations.delete(agent.name);
4595
+ this.midTurnInputSignals.delete(agent.name);
4596
+ this.turnLocusPins.delete(agent.name);
4597
+ } else {
4598
+ const locus = this.channelRegistry?.resolveLocus(agent.name) ?? null;
4599
+ if (locus !== null) this.turnLocusPins.set(agent.name, locus);
4600
+ else this.turnLocusPins.delete(agent.name);
4601
+ this.midTurnInputSignals.delete(agent.name);
4602
+ if (attempt === 0) this.announceLocusIfChanged(agent.name, locus);
4603
+ }
3966
4604
  }
3967
4605
 
3968
4606
  this.touchEphemeralRun(agent.name, true);
3969
- this.emitTrace({ type: 'inference:started', agentName: agent.name });
4607
+ this.emitTrace({
4608
+ type: 'inference:started',
4609
+ agentName: agent.name,
4610
+ // The turn-frozen locus was pinned just above (kept across a
4611
+ // context-budget restart, which skips the re-pin but re-emits this).
4612
+ channelId: this.turnLocusPins.get(agent.name),
4613
+ });
3970
4614
  this.eventGate?.onInferenceStarted(agent.name);
3971
4615
  this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
3972
4616
 
@@ -3974,6 +4618,9 @@ export class AgentFramework {
3974
4618
  const requestSnapshot = this.captureInferenceToolSnapshot(agent);
3975
4619
  const allTools = this.getToolsForAgent(agent.name, requestSnapshot);
3976
4620
  const tools = allTools.filter((t) => agent.canUseTool(t.name));
4621
+ // Explicit-mode agents get the on-demand routing reference (teach-by-
4622
+ // bounce: the grammar is never injected, only served when asked).
4623
+ if (agent.proseRouting === 'explicit') tools.push(PROSE_HELP_TOOL);
3977
4624
 
3978
4625
  // Gather context from modules (pull-based) and MCPL hooks (push-based)
3979
4626
  // Both produce ContextInjection[] that get merged before inference.
@@ -4078,24 +4725,15 @@ export class AgentFramework {
4078
4725
  let hadToolCalls = false;
4079
4726
 
4080
4727
  // ---- Present-while-acting turn state ---------------------------------
4081
- // Output locus for the current CONVERSATIONAL ROUND: resolved lazily and
4082
- // kept stable across tool-only rounds. A channel message injected at a
4083
- // tool boundary replaces the pin before the next model round, because a
4084
- // single provider inference can now contain several conversations. The
4085
- // pin lives in `turnLocusPins` so it survives a context-budget restart. A
4086
- // null resolution is not pinned.
4087
- if (trigger?.reason !== 'context_budget_restart') {
4088
- this.turnLocusPins.delete(agent.name);
4089
- this.midTurnRoutingResets.delete(agent.name);
4090
- }
4091
- const resolveTurnLocus = (): string | null => {
4092
- let locus = this.turnLocusPins.get(agent.name) ?? null;
4093
- if (locus === null && this.channelRegistry) {
4094
- locus = this.channelRegistry.resolveLocus(agent.name);
4095
- if (locus !== null) this.turnLocusPins.set(agent.name, locus);
4096
- }
4097
- return locus;
4098
- };
4728
+ // Output locus for the WHOLE logical turn: frozen in startAgentStream
4729
+ // (home → addressed trigger → global default) before this stream began,
4730
+ // and never moved until the next turn. Mid-turn injections do not touch
4731
+ // it — an ambient message or a reaction arriving at a tool boundary must
4732
+ // not redirect the agent's task narration (2026-07-21 Cairn misroute).
4733
+ // The pin lives in `turnLocusPins` so a context-budget restart (same
4734
+ // logical turn, fresh driveStream) keeps it.
4735
+ const resolveTurnLocus = (): string | null =>
4736
+ this.turnLocusPins.get(agent.name) ?? null;
4099
4737
 
4100
4738
  // Ordered delivery chain for live-routed prose. Links are enqueued
4101
4739
  // WITHOUT awaiting in the stream-event loop — an awaited network post
@@ -4128,32 +4766,30 @@ export class AgentFramework {
4128
4766
  // delivered exactly once, at turn end.
4129
4767
  let liveProseRouting = false;
4130
4768
 
4131
- // Typing indicator: show "<agent> is typing…" in the channel she's
4132
- // responding to, for the whole duration of this turn. Started here (paired
4133
- // with the finally below, so it can never leak) and refreshed on a 7s
4134
- // interval by the ChannelRegistry until stopped on any exit path.
4135
- let typingChannel = this.channelRegistry
4136
- ? trigger?.channelId ?? this.channelRegistry.getDefaultPublishChannel()
4137
- : null;
4769
+ // Typing indicator: show "<agent> is typing…" for the whole duration of
4770
+ // this turn. Started here (paired with the finally below, so it can never
4771
+ // leak) and refreshed on a 7s interval by the ChannelRegistry until
4772
+ // stopped on any exit path. Never moves mid-turn.
4773
+ // - locus mode: the frozen routing pin (where bare prose will land).
4774
+ // - explicit mode: there is no locus — indicate on the TRIGGER channel
4775
+ // (where the wake came from). Typing is presence, not delivery, so
4776
+ // this doesn't violate never-guess: it says "attending to what you
4777
+ // sent here", which is true regardless of where the reply goes.
4778
+ // Heartbeat/no-trigger explicit turns show no indicator.
4779
+ const typingChannel =
4780
+ agent.proseRouting === 'explicit'
4781
+ ? trigger?.channelId ?? null
4782
+ : resolveTurnLocus();
4138
4783
  if (typingChannel) this.channelRegistry!.startTyping(typingChannel);
4139
4784
 
4140
4785
  const adoptInjectedRound = (): void => {
4141
- if (!this.midTurnRoutingResets.has(agent.name)) return;
4142
- const nextLocus = this.midTurnRoutingResets.get(agent.name) ?? null;
4143
- this.midTurnRoutingResets.delete(agent.name);
4786
+ if (!this.midTurnInputSignals.has(agent.name)) return;
4787
+ this.midTurnInputSignals.delete(agent.name);
4144
4788
 
4145
- // A new human/agent message, not merely a tool result, means any earlier
4146
- // explicit delivery has completed its conversational job.
4789
+ // A new conversational message, not merely a tool result, means any
4790
+ // earlier explicit delivery has completed its conversational job.
4791
+ // Routing is NOT touched: the turn locus stays frozen.
4147
4792
  turnSilenced = false;
4148
-
4149
- if (nextLocus) {
4150
- this.turnLocusPins.set(agent.name, nextLocus);
4151
- if (typingChannel !== nextLocus) {
4152
- if (typingChannel) this.channelRegistry?.stopTyping(typingChannel);
4153
- typingChannel = nextLocus;
4154
- this.channelRegistry?.startTyping(nextLocus);
4155
- }
4156
- }
4157
4793
  };
4158
4794
 
4159
4795
  try {
@@ -4167,6 +4803,10 @@ export class AgentFramework {
4167
4803
  content: event.content,
4168
4804
  blockType: event.meta.type,
4169
4805
  blockIndex: event.meta.blockIndex,
4806
+ // Turn-frozen locus (same value as the typing indicator above):
4807
+ // lets trace consumers tag every chunk with the channel this
4808
+ // turn's prose is bound for, without re-deriving routing.
4809
+ channelId: typingChannel ?? undefined,
4170
4810
  });
4171
4811
  break;
4172
4812
 
@@ -4178,6 +4818,7 @@ export class AgentFramework {
4178
4818
  phase,
4179
4819
  blockType: block.type,
4180
4820
  blockIndex: index,
4821
+ channelId: typingChannel ?? undefined,
4181
4822
  });
4182
4823
  break;
4183
4824
  }
@@ -4205,13 +4846,30 @@ export class AgentFramework {
4205
4846
  (b) => b.type !== 'tool_result'
4206
4847
  );
4207
4848
  } else {
4208
- // Fallback (older membrane / XML tool mode): preamble + calls
4849
+ // Fallback (older membrane / XML tool mode): preamble + calls.
4850
+ // XML mode: persist the verbatim generation on each tool_use
4851
+ // (membrane#36 round-trip fidelity). context.rawText is the
4852
+ // parser's fullMatch — the exact <function_calls> block the
4853
+ // model wrote (plus the harness-appended close tag), shared by
4854
+ // every call in the round; the anthropic-xml formatter replays
4855
+ // it byte-identically instead of reconstructing. Guarded by
4856
+ // shape so a native-path fallback can never attach non-XML.
4857
+ const rawText = event.context.rawText;
4858
+ const rawXml = rawText && /^<(antml:)?function_calls>/.test(rawText)
4859
+ ? rawText
4860
+ : undefined;
4209
4861
  assistantBlocks = [];
4210
4862
  if (event.context.preamble) {
4211
4863
  assistantBlocks.push({ type: 'text', text: event.context.preamble });
4212
4864
  }
4213
4865
  for (const c of event.calls) {
4214
- assistantBlocks.push({ type: 'tool_use', id: c.id, name: c.name, input: c.input as Record<string, unknown> });
4866
+ assistantBlocks.push({
4867
+ type: 'tool_use',
4868
+ id: c.id,
4869
+ name: c.name,
4870
+ input: c.input as Record<string, unknown>,
4871
+ ...(rawXml ? { rawXml } : {}),
4872
+ });
4215
4873
  }
4216
4874
  }
4217
4875
  this.pendingAssistantBlocks.set(agent.name, assistantBlocks);
@@ -4251,7 +4909,21 @@ export class AgentFramework {
4251
4909
  liveProseRouting = true;
4252
4910
  const roundSegments = splitProseSegments(assistantBlocks);
4253
4911
  if (roundSegments.length > 0) {
4254
- if (turnSilenced) {
4912
+ if (agent.proseRouting === 'explicit') {
4913
+ // Explicit mode: every segment through the prose gateway.
4914
+ // Silencing does not apply — unprefixed prose bounces and
4915
+ // prefixed prose is deliberate; think-privacy still holds.
4916
+ if (!hasSameRoundPrivateThink) {
4917
+ console.error(
4918
+ `[prose] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> ${roundSegments.length} segment(s) via prose gateway`,
4919
+ );
4920
+ for (const seg of roundSegments) {
4921
+ turnSpeechChain = turnSpeechChain
4922
+ .then(() => this.deliverProse(agent, seg))
4923
+ .catch((err) => console.error('mid-turn prose delivery failed:', err));
4924
+ }
4925
+ }
4926
+ } else if (turnSilenced) {
4255
4927
  console.error(
4256
4928
  `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`,
4257
4929
  );
@@ -4575,10 +5247,29 @@ export class AgentFramework {
4575
5247
  .join('\n')
4576
5248
  .trim();
4577
5249
  if (speechText) {
4578
- try {
4579
- await this.channelRegistry.routeSpeech(agent.name, speechText);
4580
- } catch (err) {
4581
- console.error('speech routing failed:', err);
5250
+ if (agent.proseRouting === 'explicit') {
5251
+ console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`);
5252
+ try {
5253
+ await this.deliverProse(agent, speechText);
5254
+ } catch (err) {
5255
+ console.error('text-only prose delivery failed:', err);
5256
+ }
5257
+ } else {
5258
+ // Route to the TURN-FROZEN locus, like every other speech
5259
+ // path. This dispatch runs AFTER the agent is idle, so a live
5260
+ // resolution here can read the NEXT turn's trigger state (or
5261
+ // a post-restart cleared one) and land the reply in a stale
5262
+ // channel — the 2026-07-22 Sol DM-to-guild misroute. The
5263
+ // frozen pin is immune to both races.
5264
+ const locus = resolveTurnLocus();
5265
+ console.error(
5266
+ `[routing] ${agent.name}: text-only turn -> routing speech -> ${locus ?? '(none)'}`,
5267
+ );
5268
+ try {
5269
+ await this.channelRegistry.routeSpeech(agent.name, speechText, locus);
5270
+ } catch (err) {
5271
+ console.error('speech routing failed:', err);
5272
+ }
4582
5273
  }
4583
5274
  }
4584
5275
  } else if (this.channelRegistry && hadToolCalls && allText.length > 0) {
@@ -4611,7 +5302,20 @@ export class AgentFramework {
4611
5302
  // the chain may still be flushing earlier rounds' posts.
4612
5303
  await turnSpeechChain;
4613
5304
 
4614
- if (silenced || segments.length === 0) {
5305
+ if (agent.proseRouting === 'explicit') {
5306
+ if (segments.length > 0) {
5307
+ console.error(
5308
+ `[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`,
5309
+ );
5310
+ for (const seg of segments) {
5311
+ try {
5312
+ await this.deliverProse(agent, seg);
5313
+ } catch (err) {
5314
+ console.error('trailing prose delivery failed:', err);
5315
+ }
5316
+ }
5317
+ }
5318
+ } else if (silenced || segments.length === 0) {
4615
5319
  console.error(
4616
5320
  `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> trailing prose NOT routed ` +
4617
5321
  `(${silenced ? 'silencing tool / explicit send' : 'no trailing prose'})`,
@@ -4642,6 +5346,21 @@ export class AgentFramework {
4642
5346
  // BEFORE dispatchSpeech. Locus routing is speech dispatch and
4643
5347
  // doesn't depend on the status field, so it correctly runs after.
4644
5348
 
5349
+ // Explicit-prose `!` continuation: a prose segment this turn asked
5350
+ // to keep going (`>>#x !` / `>>private !`) — start another turn
5351
+ // now instead of pausing until the next external event. This gives
5352
+ // prose the same continuation ability tool rounds have (wanted for
5353
+ // robotics-style loops where an end-of-turn pause is harmful).
5354
+ if (this.proseContinuations.delete(agent.name)) {
5355
+ console.error(`[prose] ${agent.name}: '!' continuation — requesting immediate next turn`);
5356
+ this.pendingRequests.push({
5357
+ agentName: agent.name,
5358
+ reason: 'prose-continuation',
5359
+ source: 'framework',
5360
+ timestamp: Date.now(),
5361
+ });
5362
+ }
5363
+
4645
5364
  break;
4646
5365
  }
4647
5366
 
@@ -5111,6 +5830,11 @@ export class AgentFramework {
5111
5830
  return;
5112
5831
  }
5113
5832
 
5833
+ if (enrichedCall.name === 'read_image') {
5834
+ this.dispatchReadImageToolCall(agentName, enrichedCall);
5835
+ return;
5836
+ }
5837
+
5114
5838
  // Route gate_status tool
5115
5839
  if (enrichedCall.name === 'gate_status' && this.eventGate) {
5116
5840
  this.dispatchGateToolCall(agentName, enrichedCall);
@@ -5144,6 +5868,21 @@ export class AgentFramework {
5144
5868
  return;
5145
5869
  }
5146
5870
 
5871
+ // prose_help: on-demand routing reference for explicit-prose agents.
5872
+ // Answered inline — the grammar is a tool RESULT (model-requested), never
5873
+ // ambient context (see PROSE_ROUTING_HELP / teach-by-bounce).
5874
+ if (enrichedCall.name === 'prose_help') {
5875
+ this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'prose_help', callId: enrichedCall.id, durationMs: 0 });
5876
+ this.pushEvent({
5877
+ type: 'tool-result',
5878
+ callId: enrichedCall.id,
5879
+ agentName,
5880
+ moduleName: 'framework',
5881
+ result: { success: true, data: PROSE_ROUTING_HELP },
5882
+ });
5883
+ return;
5884
+ }
5885
+
5147
5886
  const sepIndex = enrichedCall.name.indexOf('--');
5148
5887
  const moduleName = sepIndex >= 0 ? enrichedCall.name.substring(0, sepIndex) : 'unknown';
5149
5888
  const toolName = sepIndex >= 0 ? call.name.substring(sepIndex + 2) : call.name;
@@ -5522,6 +6261,13 @@ export class AgentFramework {
5522
6261
  if (err.name === 'OverBudgetError') {
5523
6262
  return { errorType: 'over_budget' };
5524
6263
  }
6264
+ // context-manager's fatal coverage invariant (fix/coverage-invariant-fatal,
6265
+ // 2026-07-26): a compile REFUSES rather than shipping a context with
6266
+ // silently-dropped messages. Recovery is identical to over_budget — kick
6267
+ // the compression drain so summaries cover the un-represented span.
6268
+ if (err.name === 'UncoveredDropError') {
6269
+ return { errorType: 'context_refusal' };
6270
+ }
5525
6271
  return {};
5526
6272
  }
5527
6273
 
@@ -5591,7 +6337,25 @@ export class AgentFramework {
5591
6337
  // (e.g. a reason string that crossed a serialization boundary). It matches
5592
6338
  // CM's current OverBudgetError wording and MAY rot if CM rewords it — the
5593
6339
  // errorType gate is the one that's load-bearing.
5594
- const overBudget = errorType === 'over_budget' || /exceed hard budget/i.test(reason);
6340
+ const overBudget =
6341
+ errorType === 'over_budget' ||
6342
+ errorType === 'context_refusal' ||
6343
+ /exceed hard budget|no summary covers/i.test(reason);
6344
+ // Observability: a refused compile means the agent cannot think AT ALL
6345
+ // this turn — surface it on the ops-alert pipeline (ops:alert trace +
6346
+ // webhook → fleet-watch) immediately, not only at the hard-down streak.
6347
+ // The per-(agent,kind) cooldown throttles repeats; failures.log already
6348
+ // has the per-exhaustion record from logFailure above.
6349
+ if (overBudget) {
6350
+ this.opsAlert(
6351
+ 'context-refusal',
6352
+ agentName,
6353
+ `compile refused (${errorType ?? 'unclassified'}): ${reason.slice(0, 300)} — ` +
6354
+ `drain breaker ${agent ? 'kicking' : 'unavailable (no agent handle)'}; ` +
6355
+ `if this persists, raise contextBudgetTokens or overBudgetGraceRatio.`,
6356
+ { skipLog: true },
6357
+ );
6358
+ }
5595
6359
  if (agent && overBudget && !this.overBudgetDrainInFlight.has(agentName)) {
5596
6360
  this.overBudgetDrainInFlight.add(agentName);
5597
6361
  void (async () => {
@@ -5788,11 +6552,24 @@ export class AgentFramework {
5788
6552
  // yes, wake no).
5789
6553
  onRouteFailure: ({ channelId, reason, textLen }) => {
5790
6554
  try {
6555
+ // Render a human-readable channel name when we can — a bare
6556
+ // snowflake in the marker is unresolvable for the agent (the
6557
+ // 2026-07-21 incident read as "a stale artifact", not a live
6558
+ // failure). The marker is `system: true`, so it is never
6559
+ // conversational and never influences routing.
6560
+ const label = channelId
6561
+ ? this.channelRegistry?.getDescriptor(channelId)?.label
6562
+ : undefined;
6563
+ const where = channelId
6564
+ ? label && label !== channelId
6565
+ ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})`
6566
+ : channelId
6567
+ : 'the channel';
5791
6568
  this.addMessage(
5792
6569
  'user',
5793
6570
  [{
5794
6571
  type: 'text',
5795
- 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.`,
6572
+ 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.`,
5796
6573
  }],
5797
6574
  { system: true, kind: 'discord-send-failed', channelId: channelId ?? '', reason },
5798
6575
  );
@@ -5800,6 +6577,13 @@ export class AgentFramework {
5800
6577
  console.error('onRouteFailure: failed to record send-failure marker:', err);
5801
6578
  }
5802
6579
  },
6580
+ // A channel opened without the agent asking (policy admission or an
6581
+ // open forced by delivery) must be announced in the window, with the
6582
+ // opt-out named. Routed here so registry-driven opens and framework-
6583
+ // driven opens produce the same durable notice.
6584
+ onChannelAutoOpened: ({ conversationId, source, channels }) => {
6585
+ this.recordChannelAutoOpenNotice(conversationId, channels, source);
6586
+ },
5803
6587
  },
5804
6588
  );
5805
6589
 
@@ -6839,6 +7623,44 @@ export class AgentFramework {
6839
7623
  }
6840
7624
  }
6841
7625
 
7626
+ // INVARIANT: engaging a channel opens it. A successful explicit send
7627
+ // into a channel the registry knows as closed drives the same open
7628
+ // flow as channel_open (durable desired state, server subscribe), so
7629
+ // there is no such thing as posting into a channel that stays
7630
+ // half-alive (no typing, no reactions, no inbound forwarding).
7631
+ // Fire-and-forget: the send already succeeded; the open must not
7632
+ // delay or fail the tool result.
7633
+ if (
7634
+ this.channelRegistry &&
7635
+ SEND_ENGAGEMENT_TOOLS.has(toolName) &&
7636
+ result.isError !== true
7637
+ ) {
7638
+ const target =
7639
+ extractChannelIdFromToolResult(result.content) ??
7640
+ (typeof args.channelId === 'string' ? (args.channelId as string) : undefined);
7641
+ if (target) {
7642
+ this.channelRegistry
7643
+ .openIfClosedForSend(target, serverId)
7644
+ .then(({ status, channelId, label }) => {
7645
+ if (status === 'opened') {
7646
+ console.error(
7647
+ `[channel] ${agentName}: explicit ${toolName} into closed channel ${target} — opened (send implies engagement)`,
7648
+ );
7649
+ this.recordChannelAutoOpenNotice(
7650
+ agentName,
7651
+ [{ channelId: channelId ?? target, label }],
7652
+ 'opened-by-reply',
7653
+ );
7654
+ } else if (status === 'open-failed') {
7655
+ console.error(
7656
+ `[channel] ${agentName}: ${toolName} delivered to ${target} but the open-on-send failed — channel remains half-alive, will reconcile`,
7657
+ );
7658
+ }
7659
+ })
7660
+ .catch((err) => console.error('[channel] open-on-send error:', err));
7661
+ }
7662
+ }
7663
+
6842
7664
  // Convert MCP tool result to framework ToolResult.
6843
7665
  // When the result contains non-text blocks (e.g. images from an MCP
6844
7666
  // tool like zulip-mcp's fetch_attachment), pass the full content array
@@ -6934,6 +7756,19 @@ export class AgentFramework {
6934
7756
  .then((result) => {
6935
7757
  const durationMs = Date.now() - startTime;
6936
7758
  this.emitTrace({ type: 'tool:completed', module: 'channels', tool: call.name, callId: call.id, durationMs });
7759
+ // A successful channel_open plants the agent's feet in the opened
7760
+ // channel: pin it as this turn's reply locus so plain-text speech
7761
+ // written right after opening routes THERE. Found live 2026-07-21
7762
+ // (Aria bring-up): open → plain-text reply → "no locus (no
7763
+ // home/active channel, defaultPublishChannel null)" on a turn with
7764
+ // no channel trigger (post-restart continuation). Next turn start
7765
+ // re-resolves the locus as usual (startAgentStream set/delete).
7766
+ if (call.name === 'channel_open' && result?.success) {
7767
+ const opened =
7768
+ (result.data as { channelId?: string } | undefined)?.channelId
7769
+ ?? (call.input as { channelId?: string } | undefined)?.channelId;
7770
+ if (opened) this.activeTriggerChannels.set(agentName, opened);
7771
+ }
6937
7772
  this.pushEvent({
6938
7773
  type: 'tool-result',
6939
7774
  callId: call.id,
@@ -7074,12 +7909,29 @@ export class AgentFramework {
7074
7909
  : seconds >= 60 ? `${Math.round(seconds / 60)}m`
7075
7910
  : `${Math.round(seconds)}s`;
7076
7911
 
7077
- // Announce in the sticky channel (best-effort; never blocks the result).
7912
+ // Announce in the TURN's frozen locus (best-effort; never blocks the
7913
+ // result). The sleep tool runs mid-turn, so the pin is live — same
7914
+ // authority as every other speech path, no live re-resolution.
7078
7915
  if (announce && this.channelRegistry) {
7079
7916
  const text = input.message ?? `💤 Going quiet for ${human}. I'll still see messages, but won't respond until I wake.`;
7080
- this.channelRegistry.routeSpeech(agentName, text).catch((err) => {
7081
- console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
7082
- });
7917
+ const agent = this.agents.get(agentName);
7918
+ if (agent?.proseRouting === 'explicit') {
7919
+ // Explicit mode: announce to the turn's sticky prose target if the
7920
+ // model has set one; otherwise stay quiet — never guess a channel.
7921
+ const target = this.proseTargetPins.get(agentName);
7922
+ if (target) {
7923
+ this.channelRegistry.routeSpeech(agentName, text, target).catch((err) => {
7924
+ console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
7925
+ });
7926
+ } else {
7927
+ console.error(`[sleep] ${agentName}: no prose target this turn — sleep announcement not posted (explicit mode never guesses)`);
7928
+ }
7929
+ } else {
7930
+ const locus = this.turnLocusPins.get(agentName) ?? null;
7931
+ this.channelRegistry.routeSpeech(agentName, text, locus).catch((err) => {
7932
+ console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
7933
+ });
7934
+ }
7083
7935
  }
7084
7936
 
7085
7937
  console.error(`[sleep] agent=${agentName} seconds=${seconds} announce=${announce} until=${new Date(until).toISOString()}`);
@@ -7096,6 +7948,75 @@ export class AgentFramework {
7096
7948
  });
7097
7949
  }
7098
7950
 
7951
+ /**
7952
+ * Handle the synthesized `read_image` tool: read image bytes from a
7953
+ * workspace mount and return them as a native MCP-shaped content array
7954
+ * (`[{type:'text'…},{type:'image', data, mimeType}]`). The live-turn
7955
+ * converter (tryNativeToolResultContent → membrane tool_result image
7956
+ * blocks) delivers the actual image to the model; history storage keeps
7957
+ * the standard compact `[image: …]` placeholder.
7958
+ */
7959
+ private dispatchReadImageToolCall(agentName: string, call: ToolCall): void {
7960
+ this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
7961
+ const finish = (result: ToolResult): void => {
7962
+ this.emitTrace({
7963
+ type: result.isError ? 'tool:failed' : 'tool:completed',
7964
+ module: 'workspace',
7965
+ tool: call.name,
7966
+ callId: call.id,
7967
+ durationMs: 0,
7968
+ ...(result.isError ? { error: result.error } : {}),
7969
+ });
7970
+ this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace', result });
7971
+ };
7972
+ void (async (): Promise<void> => {
7973
+ try {
7974
+ const workspace = this.getWorkspaceModule();
7975
+ if (!workspace || typeof workspace.readBinary !== 'function') {
7976
+ throw new Error('read_image requires a workspace module');
7977
+ }
7978
+ const input = (call.input ?? {}) as { path?: unknown };
7979
+ if (typeof input.path !== 'string' || input.path.length === 0) {
7980
+ throw new Error('read_image: `path` (mount-prefixed) is required');
7981
+ }
7982
+ const read = await workspace.readBinary(input.path);
7983
+ if ('error' in read) throw new Error(read.error);
7984
+ const mediaType = sniffImageMediaType(read.data);
7985
+ if (!mediaType) {
7986
+ throw new Error(
7987
+ `read_image: "${input.path}" does not look like a supported image ` +
7988
+ '(png/jpeg/gif/webp — checked by magic bytes, not extension)',
7989
+ );
7990
+ }
7991
+ // Anthropic rejects images >5MB (and >8000px); guard the hard byte
7992
+ // limit here — resizing is out of scope without an image library.
7993
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
7994
+ if (read.data.byteLength > MAX_IMAGE_BYTES) {
7995
+ throw new Error(
7996
+ `read_image: "${input.path}" is ${read.data.byteLength} bytes — ` +
7997
+ `over the ${MAX_IMAGE_BYTES}-byte model limit`,
7998
+ );
7999
+ }
8000
+ finish({
8001
+ success: true,
8002
+ data: [
8003
+ {
8004
+ type: 'text',
8005
+ text: `${input.path} (${mediaType}, ${Math.ceil(read.data.byteLength / 1024)} KB):`,
8006
+ },
8007
+ { type: 'image', data: read.data.toString('base64'), mimeType: mediaType },
8008
+ ],
8009
+ });
8010
+ } catch (error) {
8011
+ finish({
8012
+ success: false,
8013
+ error: error instanceof Error ? error.message : String(error),
8014
+ isError: true,
8015
+ });
8016
+ }
8017
+ })();
8018
+ }
8019
+
7099
8020
  /**
7100
8021
  * Handle the synthesized `save_recent_image` tool: locate the requested
7101
8022
  * image blocks in the calling agent's context (blobs re-inlined, counted