@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
@@ -17,6 +17,26 @@ import { ConversationRouter } from './mcpl/conversation-router.js';
17
17
  import { safeSlice } from './safe-slice.js';
18
18
  import { toolResultDataToHistoryString } from './tool-result-history.js';
19
19
  import { splitProseSegments } from './prose-segments.js';
20
+ /** Detect a supported image media type from magic bytes (the model API
21
+ * rejects mislabeled media types, so trust bytes over extensions).
22
+ * Returns undefined for non-image content. */
23
+ function sniffImageMediaType(data) {
24
+ if (data.length >= 4 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) {
25
+ return 'image/png';
26
+ }
27
+ if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
28
+ return 'image/jpeg';
29
+ }
30
+ if (data.length >= 3 && data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46) {
31
+ return 'image/gif';
32
+ }
33
+ if (data.length >= 12 &&
34
+ data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46 &&
35
+ data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50) {
36
+ return 'image/webp';
37
+ }
38
+ return undefined;
39
+ }
20
40
  /**
21
41
  * Tools whose presence suppresses auto-routing of the surrounding prose,
22
42
  * for two distinct reasons:
@@ -37,6 +57,119 @@ import { splitProseSegments } from './prose-segments.js';
37
57
  const SILENCING_TOOLS = new Set([
38
58
  'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
39
59
  ]);
60
+ /**
61
+ * True when an injected message is real conversational input — something the
62
+ * agent might actually be replying to — rather than ambient machinery.
63
+ * System markers (`system: true` — send-failed notices, routing notices) and
64
+ * reactions (`chat:reaction` tag, MCPL RFC-001) don't count: they must not
65
+ * clear explicit-send suppression, and they never influence routing.
66
+ */
67
+ const isConversationalInjection = (metadata) => {
68
+ if (!metadata)
69
+ return true;
70
+ const m = metadata;
71
+ if (m.system === true)
72
+ return false;
73
+ if (Array.isArray(m.tags) && m.tags.includes('chat:reaction'))
74
+ return false;
75
+ return true;
76
+ };
77
+ /**
78
+ * Explicit delivery tools whose successful use into a closed channel OPENS
79
+ * it (send implies engagement — see openIfClosedForSend). Bare tool names,
80
+ * matched after stripping the MCPL server prefix.
81
+ */
82
+ const SEND_ENGAGEMENT_TOOLS = new Set([
83
+ 'send_message', 'reply_message', 'send_dm', 'send_file', 'send_files',
84
+ ]);
85
+ /**
86
+ * Best-effort extraction of the delivered channel id from an MCPL send-tool
87
+ * result (the discord-mcpl send tools return JSON like
88
+ * `{"messageId":"…","channelId":"…"}` as a text block). Returns undefined on
89
+ * any shape mismatch — the caller falls back to the tool's input args.
90
+ */
91
+ function extractChannelIdFromToolResult(content) {
92
+ const text = content?.find((c) => c.type === 'text' && c.text)?.text;
93
+ if (!text)
94
+ return undefined;
95
+ try {
96
+ const parsed = JSON.parse(text);
97
+ return typeof parsed.channelId === 'string' && parsed.channelId.length > 0
98
+ ? parsed.channelId
99
+ : undefined;
100
+ }
101
+ catch {
102
+ return undefined;
103
+ }
104
+ }
105
+ /**
106
+ * True when an incoming channel message explicitly addressed the agent.
107
+ * Primary signal is the MCPL RFC-001 `chat:addressed` tag (mention / reply /
108
+ * DM, as classified by the server); the metadata booleans are the fallback
109
+ * for servers that predate tags. Addressed messages outrank ambient chatter
110
+ * when picking a turn's frozen speech locus.
111
+ */
112
+ const isAddressedMessage = (tags, _metadata) => Array.isArray(tags) && tags.includes('chat:addressed');
113
+ /**
114
+ * Parse a prose segment's routing prefix (explicit prose routing —
115
+ * docs/explicit-prose-routing.md). The FIRST line may be:
116
+ * `>>target [!] [body…]` — target = channel spec or `private`
117
+ * The ` !` continuation modifier must immediately follow the target,
118
+ * whitespace-separated. Body may start on the same line or the next.
119
+ */
120
+ function parseProsePrefix(text) {
121
+ const m = /^>>(\S+)([ \t]+!)?[ \t]*\n?/.exec(text);
122
+ if (!m)
123
+ return { kind: 'none', continueTurn: false, body: text };
124
+ const target = m[1];
125
+ const continueTurn = m[2] !== undefined;
126
+ const body = text.slice(m[0].length);
127
+ // `skip_reply` mirrors the tool the model already knows: text stays in
128
+ // context, nothing is delivered. (Deliberately NOT called "private"/"note" —
129
+ // that vocabulary sat adjacent to signed thinking in a live window and drew
130
+ // a reasoning_extraction classifier hit, 2026-07-24 Fable.)
131
+ if (target === 'skip_reply') {
132
+ return { kind: 'private', continueTurn, body };
133
+ }
134
+ return { kind: 'target', target, continueTurn, body };
135
+ }
136
+ /** One-time primer appended when an agent's proseRouting mode changes. */
137
+ function proseModePrimer(mode) {
138
+ if (mode === 'locus') {
139
+ return '[prose-routing] Mode change: your plain text auto-routes to the ' +
140
+ 'conversational locus again. `>>` destination prefixes are no longer needed.';
141
+ }
142
+ // Deliberately terse: a short event-style notice is classifier-safe from
143
+ // the user role (ablation D, 2026-07-24), while the full grammar as a user
144
+ // message drew a deterministic reasoning_extraction refusal (ablation A/B).
145
+ // The grammar is TAUGHT BY BOUNCE: the first undelivered text produces a
146
+ // notice with the exact resend syntax, and the full reference is available
147
+ // on demand via the prose_help tool (a tool RESULT is model-requested
148
+ // content — the safest role there is). Nothing is injected into the system
149
+ // prompt: those bytes belong to the recipe, and the deepest KV prefix must
150
+ // not vary with framework code.
151
+ return ('[prose-routing] Output mode changed: plain text is delivered only with a ' +
152
+ 'destination prefix line, e.g. ">>#channel-name your text". ' +
153
+ 'The prose_help tool shows the full syntax.');
154
+ }
155
+ /** Full `>>` routing reference, served as the prose_help TOOL RESULT (never
156
+ * injected ambiently — see proseModePrimer note). */
157
+ const PROSE_ROUTING_HELP = 'Plain-text output routing (explicit mode):\n' +
158
+ ' >>#channel-name … or >>@person … (DM) or >>service:guild:id …\n' +
159
+ ' One message may hold several sections: each line starting with ">>" begins\n' +
160
+ ' a new envelope, routed to its own destination independently.\n' +
161
+ ' The first destination in a turn applies to the rest of that turn.\n' +
162
+ ' Append " !" after the destination (e.g. ">>#ops !") to start your next turn\n' +
163
+ ' immediately when this one ends, instead of pausing until the next event.\n' +
164
+ ' >>skip_reply — text stays in your context only, like the skip_reply tool.\n' +
165
+ 'Text without a destination is not delivered: it is retained, and a notice will\n' +
166
+ 'prompt you to resend — reply e.g. ">>#channel {{unsent}}" to deliver the retained\n' +
167
+ 'text unchanged. Send tools (send_message, send_dm, …) are unaffected.';
168
+ const PROSE_HELP_TOOL = {
169
+ name: 'prose_help',
170
+ description: 'Show the plain-text output routing syntax (destination prefixes, resend token, skip).',
171
+ inputSchema: { type: 'object', properties: {} },
172
+ };
40
173
  /** Strip the `server--` MCPL prefix from a tool name. */
41
174
  const bareToolName = (n) => n.split('--').pop();
42
175
  import { CheckpointManager } from './mcpl/checkpoint-manager.js';
@@ -189,20 +322,37 @@ export class AgentFramework {
189
322
  processLoggingPersist;
190
323
  processLoggingBroadcast;
191
324
  activeStreams = new Map();
192
- /** Per-agent output locus pinned for the CURRENT logical turn (see
193
- * resolveTurnLocus in driveStream). Lives here — not in driveStream
194
- * locals — so the pin survives a context-budget stream restart, which
195
- * continues the same logical turn in a fresh driveStream. A mid-turn
196
- * injected channel message replaces the pin for the next conversational
197
- * round; otherwise it remains stable. Cleared at the start of every
198
- * non-restart turn. */
325
+ /** Per-agent output locus FROZEN for the CURRENT logical turn. Resolved
326
+ * eagerly in startAgentStream (home → addressed trigger → global default)
327
+ * and never moved until the next turn: mid-turn injected messages — ambient
328
+ * chatter, reactions, system markers — must not hijack where the agent's
329
+ * plain prose lands (2026-07-21 Cairn lounge misroute). Lives here — not in
330
+ * driveStream locals — so the pin survives a context-budget stream restart,
331
+ * which continues the same logical turn in a fresh driveStream. Cleared or
332
+ * re-set at the start of every non-restart turn. */
199
333
  turnLocusPins = new Map();
200
- /** A tool boundary injected fresh external input into the live stream.
201
- * Presence (including a null value) tells driveStream to clear sticky
202
- * explicit-send suppression before handling the next model round. A
203
- * string also moves that round's reply locus to the newest injected
204
- * channel. */
205
- midTurnRoutingResets = new Map();
334
+ /** A tool boundary injected fresh CONVERSATIONAL input (a real message —
335
+ * not a reaction or a system marker) into the live stream. Tells
336
+ * driveStream to clear sticky explicit-send suppression before handling
337
+ * the next model round. Never affects routing: the turn locus is frozen. */
338
+ midTurnInputSignals = new Set();
339
+ /** Last outbound locus announced to each agent as a durable `[routing]`
340
+ * window message. Announce-on-change only: steady state emits nothing
341
+ * (KV-safe, no per-turn chatter). In-memory — after a process restart the
342
+ * first turn's locus is announced once to re-establish the baseline. */
343
+ lastAnnouncedLocus = new Map();
344
+ // ---- Explicit prose routing (docs/explicit-prose-routing.md) ----------
345
+ /** Latest bounced (undelivered) prose per agent — the `{{unsent}}` source. */
346
+ proseClipboards = new Map();
347
+ /** Sticky per-TURN delivery target set by the turn's first `>>` prefix.
348
+ * Cleared at every non-restart turn start; survives context restarts. */
349
+ proseTargetPins = new Map();
350
+ /** Agents whose current turn requested `!` continuation — re-woken when the
351
+ * turn completes instead of pausing until the next external event. */
352
+ proseContinuations = new Set();
353
+ /** Consecutive bounce-wakes per agent; capped so an agent that keeps
354
+ * emitting unprefixed prose can't wake-loop (notices still append). */
355
+ proseBounceStreaks = new Map();
206
356
  pendingAssistantBlocks = new Map();
207
357
  /** Streams the FRAMEWORK cancelled for non-terminal reasons, keyed
208
358
  * `${agentName}:${streamId}`: an endTurn tool result or a context-budget
@@ -851,7 +1001,9 @@ export class AgentFramework {
851
1001
  ...channelTools,
852
1002
  ...gateTools,
853
1003
  this.buildAgentSettingsTool(),
854
- ...(this.getWorkspaceModule() ? [AgentFramework.SAVE_IMAGE_TOOL] : []),
1004
+ ...(this.getWorkspaceModule()
1005
+ ? [AgentFramework.SAVE_IMAGE_TOOL, AgentFramework.READ_IMAGE_TOOL]
1006
+ : []),
855
1007
  ];
856
1008
  }
857
1009
  /** The registered workspace module, if any (used by save_image). */
@@ -966,20 +1118,48 @@ export class AgentFramework {
966
1118
  throw new Error(`Unknown agent: ${agentName}`);
967
1119
  return agent.getRuntimeSettings();
968
1120
  }
969
- updateAgentRuntimeSettings(agentName, patch) {
1121
+ /**
1122
+ * Apply runtime settings.
1123
+ *
1124
+ * `opts.persist: false` makes the change EPHEMERAL: it takes effect live but
1125
+ * is not written to the `framework/state` slot, so a restart reverts it. That
1126
+ * is the mode for operator experiments ("change it, watch, revert") — the
1127
+ * default persists, because a durable operational change should survive a
1128
+ * bounce like every other override.
1129
+ *
1130
+ * Nothing here notifies the agent. Discovery is deliberately PULL: the agent
1131
+ * can read current settings any time via the `agent_settings` tool's `get`
1132
+ * action, which costs no context perturbation. Pushing a notice injects text
1133
+ * into the very context being tuned — it invalidates the KV prefix and can
1134
+ * itself trip a classifier — so callers opt into that explicitly rather than
1135
+ * getting it by default.
1136
+ *
1137
+ * KNOWN LIMITATION of `persist: false`: the change still lands in the agent's
1138
+ * in-memory override map, and `persistAgentRuntimeSettings` writes that whole
1139
+ * map. So a LATER persisting call promotes any still-live ephemeral change to
1140
+ * durable as a side effect. Ephemeral means "not written by THIS call", not
1141
+ * "can never be written". Reset the ephemeral key before persisting something
1142
+ * else if that matters. Making it airtight needs per-key ephemeral tracking
1143
+ * inside Agent.
1144
+ */
1145
+ updateAgentRuntimeSettings(agentName, patch, opts) {
970
1146
  const agent = this.agents.get(agentName);
971
1147
  if (!agent)
972
1148
  throw new Error(`Unknown agent: ${agentName}`);
973
1149
  const result = agent.updateRuntimeSettings(patch);
974
- this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
1150
+ if (opts?.persist !== false) {
1151
+ this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
1152
+ }
975
1153
  return result;
976
1154
  }
977
- resetAgentRuntimeSettings(agentName, keys) {
1155
+ resetAgentRuntimeSettings(agentName, keys, opts) {
978
1156
  const agent = this.agents.get(agentName);
979
1157
  if (!agent)
980
1158
  throw new Error(`Unknown agent: ${agentName}`);
981
1159
  const result = agent.resetRuntimeSettings(keys);
982
- this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
1160
+ if (opts?.persist !== false) {
1161
+ this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
1162
+ }
983
1163
  return result;
984
1164
  }
985
1165
  cancelAgentRuntimeSettingsTransition(agentName) {
@@ -1042,10 +1222,14 @@ export class AgentFramework {
1042
1222
  throw new Error(`Agent not found: ${agentName}`);
1043
1223
  }
1044
1224
  const tools = this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name));
1225
+ // An explicit budget compiles against a HYPOTHETICAL window instead of the
1226
+ // agent's live one. That also suppresses transition-settling in
1227
+ // compileWithInjections (which only settles when no budget is passed), so
1228
+ // previewing a smaller window cannot advance a converging descent.
1045
1229
  // Default: no dynamic injection gathering → fully transparent (no
1046
1230
  // inference, no Chronicle writes, no external RPC). Opt in explicitly.
1047
1231
  if (!opts?.injections) {
1048
- return agent.buildActivationRequest(tools, undefined);
1232
+ return agent.buildActivationRequest(tools, undefined, opts?.budget);
1049
1233
  }
1050
1234
  // Full-fidelity path: mirrors startAgentStream's injection gathering.
1051
1235
  // NOT transparent — see the doc comment above.
@@ -1075,7 +1259,31 @@ export class AgentFramework {
1075
1259
  console.error('beforeInference hook error (preview):', error);
1076
1260
  }
1077
1261
  }
1078
- return agent.buildActivationRequest(tools, injections);
1262
+ return agent.buildActivationRequest(tools, injections, opts?.budget);
1263
+ }
1264
+ /**
1265
+ * Non-committing preview of the FOLD PLAN at a hypothetical budget and/or
1266
+ * context settings — the numbers behind an operator's "what happens if I set
1267
+ * the budget to X" before they apply it.
1268
+ *
1269
+ * Unlike `previewActivation` (which renders a request), this returns the
1270
+ * picker's plan: resulting tokens, whether it fits, how many summaries would
1271
+ * have to be produced first, and the per-chunk fold levels. Commits nothing:
1272
+ * no resolution persistence, no compression enqueue, no transition
1273
+ * bookkeeping — see `AutobiographicalStrategy.previewContext`.
1274
+ *
1275
+ * Returns null when the agent's strategy has no fold plan (non-adaptive).
1276
+ */
1277
+ previewContextSettings(agentName, budgetTokens, overrides) {
1278
+ const agent = this.agents.get(agentName);
1279
+ if (!agent)
1280
+ throw new Error(`Unknown agent: ${agentName}`);
1281
+ const cm = agent.getContextManager();
1282
+ if (typeof cm.previewContext !== 'function')
1283
+ return null;
1284
+ // reserveForResponse mirrors the live compile so the previewed middle is
1285
+ // comparable to what the agent actually gets.
1286
+ return cm.previewContext({ maxTokens: budgetTokens, reserveForResponse: agent.maxTokens }, overrides);
1079
1287
  }
1080
1288
  /**
1081
1289
  * Check if process logging is enabled.
@@ -1533,6 +1741,27 @@ export class AgentFramework {
1533
1741
  required: ['path'],
1534
1742
  },
1535
1743
  };
1744
+ /** Synthesized read_image tool — present when a workspace module is
1745
+ * registered. Returns the image as a native image block in the tool
1746
+ * result, so the agent SEES it in the live turn (history keeps a compact
1747
+ * placeholder via the standard tool-result serializer). */
1748
+ static READ_IMAGE_TOOL = {
1749
+ name: 'read_image',
1750
+ description: 'View an image file from a workspace mount. The image is returned into ' +
1751
+ 'your context so you can actually see it (e.g. after save_recent_image, ' +
1752
+ 'or for images placed in the workspace by other means). Path is ' +
1753
+ 'mount-prefixed, e.g. "project/photos/cat.png". Supports png/jpeg/gif/webp.',
1754
+ inputSchema: {
1755
+ type: 'object',
1756
+ properties: {
1757
+ path: {
1758
+ type: 'string',
1759
+ description: 'Mount-prefixed image path, e.g. "project/photos/name.png".',
1760
+ },
1761
+ },
1762
+ required: ['path'],
1763
+ },
1764
+ };
1536
1765
  static AGENT_SETTINGS_TOOL = {
1537
1766
  name: 'agent_settings',
1538
1767
  description: 'Read or change your hot runtime settings. This intentionally exposes only ' +
@@ -2648,20 +2877,17 @@ export class AgentFramework {
2648
2877
  }
2649
2878
  }
2650
2879
  }
2651
- // A newly injected message begins a new conversational round inside
2652
- // the same provider inference. Remember that boundary for
2653
- // driveStream: an explicit send in the preceding round must not
2654
- // silence the reply, and the newest channel-bearing injection is
2655
- // now the reply locus. Map presence matters even without a channel
2656
- // (CLI/module input): it still clears the prior send suppression.
2657
- if (midTurnInjections.length > 0) {
2658
- const injectedChannelId = midTurnInjections.reduce((latest, injection) => {
2659
- const candidate = injection.metadata?.channelId;
2660
- return typeof candidate === 'string' && candidate.length > 0
2661
- ? candidate
2662
- : latest;
2663
- }, null);
2664
- this.midTurnRoutingResets.set(agent.name, injectedChannelId);
2880
+ // A newly injected CONVERSATIONAL message begins a new
2881
+ // conversational round inside the same provider inference. Remember
2882
+ // that boundary for driveStream: an explicit send in the preceding
2883
+ // round must not silence the reply. Routing is deliberately NOT
2884
+ // affected — the turn locus is frozen at turn start, so ambient
2885
+ // chatter, reactions, and system markers injected mid-turn can no
2886
+ // longer hijack where the agent's prose lands. Non-conversational
2887
+ // injections (reactions, `system: true` markers) don't even clear
2888
+ // the send suppression: nothing new was said to the agent.
2889
+ if (midTurnInjections.some((inj) => isConversationalInjection(inj.metadata))) {
2890
+ this.midTurnInputSignals.add(agent.name);
2665
2891
  }
2666
2892
  // Check if any tool result requested endTurn
2667
2893
  const shouldEndTurn = currentState.toolResults.some(tc => tc.result.endTurn);
@@ -2847,6 +3073,8 @@ export class AgentFramework {
2847
3073
  };
2848
3074
  if (event.threadId)
2849
3075
  metadata.threadId = event.threadId;
3076
+ if (event.tags)
3077
+ metadata.tags = event.tags;
2850
3078
  // Per-channel conversation routing: messages go to the channel's fork
2851
3079
  // agent (spawned from the template on first qualifying message), never
2852
3080
  // to the primary conversation.
@@ -2857,6 +3085,7 @@ export class AgentFramework {
2857
3085
  const id = this.addMessage('user', event.content, metadata);
2858
3086
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
2859
3087
  if (event.triggerInference) {
3088
+ const addressed = isAddressedMessage(event.tags, event.metadata);
2860
3089
  for (const agentName of this.agents.keys()) {
2861
3090
  this.pendingRequests.push({
2862
3091
  agentName,
@@ -2866,6 +3095,9 @@ export class AgentFramework {
2866
3095
  // Route this turn's auto-published speech back to THIS channel, not
2867
3096
  // the global most-recent-inbound locus (item-3 redux, trunk agents).
2868
3097
  channelId: event.channelId,
3098
+ // Addressed messages outrank ambient chatter when a batched wake
3099
+ // picks the turn's frozen speech locus.
3100
+ addressed,
2869
3101
  });
2870
3102
  }
2871
3103
  }
@@ -2947,6 +3179,7 @@ export class AgentFramework {
2947
3179
  // A fork's home channel wins in routeSpeech regardless, but carry the
2948
3180
  // triggering channel too so the trunk/active path stays consistent.
2949
3181
  channelId: event.channelId,
3182
+ addressed: isAddressedMessage(event.tags, event.metadata),
2950
3183
  });
2951
3184
  }
2952
3185
  }
@@ -3102,7 +3335,7 @@ export class AgentFramework {
3102
3335
  handleMcplPushEvent(event) {
3103
3336
  const triggerChannel = this.derivePushEventChannel(event.origin);
3104
3337
  if (triggerChannel && this.channelRegistry) {
3105
- this.channelRegistry.ensureChannelRegistered(event.serverId, triggerChannel.channelId, triggerChannel.label);
3338
+ this.channelRegistry.ensureChannelRegistered(event.serverId, triggerChannel.channelId, triggerChannel.label, triggerChannel.metadata);
3106
3339
  }
3107
3340
  const metadata = {
3108
3341
  ...event.origin,
@@ -3110,7 +3343,17 @@ export class AgentFramework {
3110
3343
  featureSet: event.featureSet,
3111
3344
  eventId: event.eventId,
3112
3345
  triggered: event.triggerInference ?? false,
3346
+ ...(event.tags ? { tags: event.tags } : {}),
3113
3347
  };
3348
+ // `origin.channelId` is the server-internal raw id (a bare Discord
3349
+ // snowflake) — unroutable as a locus and unresolvable by the agent. Store
3350
+ // the composite MCPL id instead wherever we can derive it, so anything
3351
+ // downstream that reads `metadata.channelId` (routing, provenance, the
3352
+ // agent itself) sees the qualified form. The raw value stays available on
3353
+ // servers that also send `rawChannelId`-style origin fields.
3354
+ if (triggerChannel) {
3355
+ metadata.channelId = triggerChannel.channelId;
3356
+ }
3114
3357
  const content = [...event.content];
3115
3358
  const addressedWhileClosed = triggerChannel &&
3116
3359
  event.tags?.includes('chat:addressed') &&
@@ -3122,17 +3365,29 @@ export class AgentFramework {
3122
3365
  ? event.origin.messageId
3123
3366
  : event.eventId;
3124
3367
  const maxBackscroll = descriptor?.capabilities?.history?.maxMessages ?? 0;
3368
+ // Resolve human-readable names from the event origin wherever the
3369
+ // surface provided them — a mind reading this should see WHO addressed
3370
+ // it WHERE, not a wall of snowflake ids. Raw ids remain only inside the
3371
+ // tool-argument instructions, where they are the literal values to pass.
3372
+ const origin = (event.origin ?? {});
3373
+ const authorName = typeof origin.authorName === 'string' && origin.authorName ? origin.authorName : 'Someone';
3374
+ const channelLabel = triggerChannel.label ? `#${triggerChannel.label}` : `"${triggerChannel.channelId}"`;
3375
+ const place = typeof origin.guildName === 'string' && origin.guildName
3376
+ ? `${channelLabel} in "${origin.guildName}"`
3377
+ : channelLabel;
3125
3378
  content.push({
3126
3379
  type: 'text',
3127
- text: `\n[Channel invitation] You were addressed in closed channel ` +
3128
- `"${triggerChannel.label ?? triggerChannel.channelId}" (${triggerChannel.channelId}, server ${event.serverId}). ` +
3129
- `You received this one message without subscribing. ` +
3380
+ text: `\n[Channel invitation] ${authorName} addressed you in ${place} — ` +
3381
+ `a channel you haven't joined. You're seeing this one message; you won't receive more from it unless you join. ` +
3382
+ `Your options:\n` +
3383
+ `1. Reply without joining — simply write your reply as normal text this turn; it will be delivered to ${channelLabel}.\n` +
3384
+ `2. Join the channel — call channel_open with channelId "${triggerChannel.channelId}" and serverId "${event.serverId}"` +
3130
3385
  (maxBackscroll > 0
3131
- ? `To subscribe, call channel_open with channelId, serverId "${event.serverId}", backscroll (0-${maxBackscroll}), and beforeMessageId "${messageId}". `
3132
- : `To subscribe, call channel_open with this channelId and serverId "${event.serverId}". `) +
3133
- `You may reply once without subscribing. To remain closed, call channel_decline ` +
3134
- `with channelId, serverId "${event.serverId}", and messageId "${messageId}"; ` +
3135
- `optionally set acknowledge to a surface value such as 👀.`,
3386
+ ? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${messageId}".\n`
3387
+ : `.\n`) +
3388
+ `3. Stay out — call channel_decline with channelId "${triggerChannel.channelId}", serverId "${event.serverId}", ` +
3389
+ `and messageId "${messageId}" (optionally set acknowledge to an emoji like 👀 so ${authorName} isn't left hanging).\n` +
3390
+ `Doing nothing is also fine.`,
3136
3391
  });
3137
3392
  metadata.channelInvitation = true;
3138
3393
  metadata.channelOpen = false;
@@ -3152,6 +3407,10 @@ export class AgentFramework {
3152
3407
  source: event.serverId,
3153
3408
  timestamp: Date.now(),
3154
3409
  channelId: triggerChannel?.channelId,
3410
+ // DMs and addressed-while-closed messages arrive as push events;
3411
+ // they must outrank ambient chatter in a batched wake's locus
3412
+ // selection just like their channels/incoming counterparts.
3413
+ addressed: isAddressedMessage(event.tags, event.origin),
3155
3414
  });
3156
3415
  }
3157
3416
  }
@@ -3175,16 +3434,35 @@ export class AgentFramework {
3175
3434
  derivePushEventChannel(origin) {
3176
3435
  if (!origin)
3177
3436
  return undefined;
3178
- const label = typeof origin.channelName === 'string' ? origin.channelName : undefined;
3437
+ let label = typeof origin.channelName === 'string' && origin.channelName ? origin.channelName : undefined;
3438
+ let metadata;
3439
+ // A DM has no channelName — label it by the PERSON and carry their
3440
+ // identity, so DM registrations are people-first (`DM: antra`, matchable
3441
+ // by `>>@name` / `<@id>` mention tokens) instead of bare snowflakes.
3442
+ const isDM = origin.isDM === true || origin.guildId === null;
3443
+ if (isDM && typeof origin.authorName === 'string' && origin.authorName) {
3444
+ label = label ?? `DM: ${origin.authorName}`;
3445
+ metadata = {
3446
+ channelType: 'dm',
3447
+ recipientName: origin.authorName,
3448
+ ...(typeof origin.authorId === 'string' && origin.authorId
3449
+ ? { recipientId: origin.authorId }
3450
+ : {}),
3451
+ };
3452
+ }
3179
3453
  const explicit = origin.mcplChannelId;
3180
3454
  if (typeof explicit === 'string' && explicit) {
3181
- return { channelId: explicit, label };
3455
+ return { channelId: explicit, ...(label ? { label } : {}), ...(metadata ? { metadata } : {}) };
3182
3456
  }
3183
3457
  // Discord fallback: reconstruct the composite from origin parts. `guildId`
3184
3458
  // is null for a DM (→ 'dm'); a real guild id for a non-open guild channel.
3185
3459
  if (origin.source === 'discord' && typeof origin.channelId === 'string' && origin.channelId) {
3186
3460
  const guild = typeof origin.guildId === 'string' && origin.guildId ? origin.guildId : 'dm';
3187
- return { channelId: `discord:${guild}:${origin.channelId}`, label };
3461
+ return {
3462
+ channelId: `discord:${guild}:${origin.channelId}`,
3463
+ ...(label ? { label } : {}),
3464
+ ...(metadata ? { metadata } : {}),
3465
+ };
3188
3466
  }
3189
3467
  return undefined;
3190
3468
  }
@@ -3257,14 +3535,287 @@ export class AgentFramework {
3257
3535
  const trigger = requests[0];
3258
3536
  // Route this turn's auto-published speech to the channel that triggered
3259
3537
  // it (item-3 redux). A batched wake may carry several triggering channels
3260
- // (messages arrived in >1 channel while the agent was busy/idle) — reply
3261
- // in the MOST-RECENT one: it matches the legacy last-inbound semantics and
3262
- // is the message the agent is most likely answering. The common case is
3263
- // 1 message → 1 channel (unambiguous). Non-channel wakes carry no
3264
- // channelId, leaving the field undefined → global fallback. `reduce`
3265
- // keeps the last defined channelId across the (FIFO-ordered) batch.
3266
- const triggerChannel = requests.reduce((acc, r) => r.channelId ?? acc, undefined);
3267
- await this.startAgentStream(agent, { ...trigger, channelId: triggerChannel });
3538
+ // (messages arrived in >1 channel while the agent was busy/idle):
3539
+ // 1. the most recent ADDRESSED channel wins (mention / reply / DM —
3540
+ // someone explicitly spoke TO the agent);
3541
+ // 2. else the most recent channel-bearing request (ambient message in
3542
+ // an open channel — legacy last-inbound semantics).
3543
+ // Ambient chatter must not outrank an addressed message just by being
3544
+ // newest (2026-07-21 Cairn lounge misroute, turn-start variant).
3545
+ // Non-channel wakes (heartbeats, module events, reactions — which never
3546
+ // carry channelId) leave both undefined → global fallback.
3547
+ let ambientChannel;
3548
+ let addressedChannel;
3549
+ for (const r of requests) {
3550
+ if (!r.channelId)
3551
+ continue;
3552
+ ambientChannel = r.channelId;
3553
+ if (r.addressed)
3554
+ addressedChannel = r.channelId;
3555
+ }
3556
+ const triggerChannel = addressedChannel ?? ambientChannel;
3557
+ const triggerAddressed = addressedChannel !== undefined;
3558
+ await this.startAgentStream(agent, {
3559
+ ...trigger,
3560
+ channelId: triggerChannel,
3561
+ addressed: triggerAddressed,
3562
+ });
3563
+ }
3564
+ }
3565
+ /**
3566
+ * Announce-on-change locus notice (turn-frozen routing). Appends a durable
3567
+ * `[routing]` window message when the turn's effective outbound locus
3568
+ * differs from the last one announced — the agent must never have to guess
3569
+ * where its plain prose lands. Durable append (never retracted) keeps the
3570
+ * KV prefix stable; change-only keeps it out of steady-state turns. The
3571
+ * first resolution after boot announces only a non-null locus ("prose has
3572
+ * no destination" is the unremarkable boot default). Never triggers
3573
+ * inference; never locus-eligible itself (system + no channel metadata).
3574
+ */
3575
+ announceLocusIfChanged(agentName, locus) {
3576
+ const hasBaseline = this.lastAnnouncedLocus.has(agentName);
3577
+ const prev = this.lastAnnouncedLocus.get(agentName) ?? null;
3578
+ if (hasBaseline ? prev === locus : locus === null) {
3579
+ if (!hasBaseline)
3580
+ this.lastAnnouncedLocus.set(agentName, locus);
3581
+ return;
3582
+ }
3583
+ this.lastAnnouncedLocus.set(agentName, locus);
3584
+ const agent = this.agents.get(agentName);
3585
+ if (!agent)
3586
+ return;
3587
+ let text;
3588
+ if (locus === null) {
3589
+ text =
3590
+ '[routing] Your plain speech currently has no channel — it stays in ' +
3591
+ 'your archive. Use an explicit send tool to reach a channel.';
3592
+ }
3593
+ else {
3594
+ const label = this.channelRegistry?.getDescriptor(locus)?.label;
3595
+ const shown = label && label !== locus
3596
+ ? `${label.startsWith('#') ? label : `#${label}`} (${locus})`
3597
+ : locus;
3598
+ text =
3599
+ `[routing] Your plain speech now lands in ${shown}. ` +
3600
+ 'Other channels need an explicit send tool.';
3601
+ }
3602
+ try {
3603
+ const id = agent.getContextManager().addMessage('user', [{ type: 'text', text }], { system: true, kind: 'routing-notice' });
3604
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
3605
+ console.error(`[routing] ${agentName}: locus ${prev ?? '(none)'} -> ${locus ?? '(none)'} (announced in window)`);
3606
+ }
3607
+ catch (err) {
3608
+ console.error('announceLocusIfChanged: failed to record routing notice:', err);
3609
+ }
3610
+ }
3611
+ /**
3612
+ * Durable window notice for a channel the agent did not ask to open —
3613
+ * subscription-policy admission, or an open forced by the agent's own
3614
+ * delivery into a closed channel. The agent must always learn that new
3615
+ * traffic will start flowing, and that `channel_close` opts out (their
3616
+ * decision outranks policy). System message: never triggers inference,
3617
+ * never conversational, never locus-eligible.
3618
+ */
3619
+ recordChannelAutoOpenNotice(agentName, channels, cause) {
3620
+ if (channels.length === 0)
3621
+ return;
3622
+ const shown = channels
3623
+ .map((c) => (c.label && c.label !== c.channelId ? `${c.label} (${c.channelId})` : c.channelId))
3624
+ .join(', ');
3625
+ const plural = channels.length > 1;
3626
+ const causeText = cause === 'subscription-policy'
3627
+ ? 'your subscription policy just admitted ' + (plural ? 'them' : 'it')
3628
+ : 'your own message into the closed channel engaged it';
3629
+ const text = `[channels] Now open: ${shown} — ${causeText}. Ongoing traffic from ` +
3630
+ (plural ? 'these channels' : 'this channel') +
3631
+ ` will reach you. If you don't want ${plural ? 'one of them' : 'it'}, use channel_close — ` +
3632
+ 'your choice sticks; neither policy nor a later send will silently reopen it.';
3633
+ try {
3634
+ // Through framework.addMessage for mid-turn safety (deferral +
3635
+ // hear-while-acting injection) — a delivery-forced open happens inside
3636
+ // a live turn, and a direct context-manager append there would store
3637
+ // the notice out of order. Targets the primary agent; connectome-host
3638
+ // runs trunk-only, so this is the delivering agent in practice.
3639
+ this.addMessage('user', [{ type: 'text', text }], { system: true, kind: 'channel-notice' });
3640
+ console.error(`[channel] auto-open notice (${cause}) -> ${agentName ?? 'primary'}: ${shown}`);
3641
+ }
3642
+ catch (err) {
3643
+ console.error('recordChannelAutoOpenNotice failed:', err);
3644
+ }
3645
+ }
3646
+ /**
3647
+ * Explicit-prose-routing delivery gateway (docs/explicit-prose-routing.md).
3648
+ * Every prose segment of an `explicit`-mode agent flows through here:
3649
+ * parse the `>>` prefix, resolve the target, deliver — or bounce to the
3650
+ * clipboard with a resend notice. Misdelivery is structurally impossible:
3651
+ * nothing is ever sent to a destination the model did not name (directly
3652
+ * or via the turn's sticky target).
3653
+ */
3654
+ async deliverProse(agent, rawText) {
3655
+ // Multi-envelope: a single prose block may address SEVERAL destinations —
3656
+ // every line beginning with `>>` opens a new envelope, routed
3657
+ // independently (first live use: Tilde 2026-07-24, one block carrying a
3658
+ // letter to #tilde-mythos AND a `>>#tilde` aside; the single-envelope
3659
+ // parser delivered the aside literally into the first destination —
3660
+ // exactly the leak class this mode exists to kill). Text before the
3661
+ // first `>>` is its own unprefixed envelope (sticky/bounce as usual).
3662
+ const lines = rawText.split('\n');
3663
+ const envelopes = [];
3664
+ let current = [];
3665
+ for (const line of lines) {
3666
+ // Only a REAL prefix line opens an envelope: `>>` immediately followed
3667
+ // by a target token. A quoted `>> arrow` (space after) stays body text.
3668
+ if (/^>>\S/.test(line.trimStart()) && current.length > 0) {
3669
+ envelopes.push(current.join('\n'));
3670
+ current = [];
3671
+ }
3672
+ current.push(line);
3673
+ }
3674
+ if (current.length > 0)
3675
+ envelopes.push(current.join('\n'));
3676
+ for (const envelope of envelopes) {
3677
+ if (!envelope.trim())
3678
+ continue;
3679
+ await this.deliverProseEnvelope(agent, envelope.replace(/^\s+(?=>>)/, ''));
3680
+ }
3681
+ }
3682
+ async deliverProseEnvelope(agent, rawText) {
3683
+ const name = agent.name;
3684
+ const parsed = parseProsePrefix(rawText);
3685
+ if (parsed.continueTurn)
3686
+ this.proseContinuations.add(name);
3687
+ if (parsed.kind === 'private') {
3688
+ // The text already lives in the assistant message (window/chronicle);
3689
+ // "delivery" is deliberately a no-op. Consumes the retained text if the
3690
+ // segment embedded it.
3691
+ if (parsed.body.includes('{{unsent}}'))
3692
+ this.proseClipboards.delete(name);
3693
+ console.error(`[prose] ${name}: >>skip_reply — ${parsed.body.length} chars kept in context (not sent)`);
3694
+ return;
3695
+ }
3696
+ let targetChannel;
3697
+ let body;
3698
+ if (parsed.kind === 'target') {
3699
+ const res = this.channelRegistry.resolveProseTarget(parsed.target);
3700
+ if ('error' in res) {
3701
+ this.bounceProse(agent, parsed.body, res.error, res.candidates);
3702
+ return;
3703
+ }
3704
+ targetChannel = res.channelId;
3705
+ // Sticky within the turn: later unprefixed segments follow this target.
3706
+ this.proseTargetPins.set(name, targetChannel);
3707
+ body = parsed.body;
3708
+ }
3709
+ else {
3710
+ const sticky = this.proseTargetPins.get(name);
3711
+ if (!sticky) {
3712
+ this.bounceProse(agent, rawText, 'no destination prefix and no destination set yet this turn');
3713
+ return;
3714
+ }
3715
+ targetChannel = sticky;
3716
+ body = rawText;
3717
+ }
3718
+ const usedClipboard = body.includes('{{unsent}}');
3719
+ if (usedClipboard) {
3720
+ body = body.replaceAll('{{unsent}}', this.proseClipboards.get(name) ?? '');
3721
+ }
3722
+ if (!body.trim()) {
3723
+ console.error(`[prose] ${name}: empty body after prefix/substitution — nothing to send`);
3724
+ return;
3725
+ }
3726
+ try {
3727
+ const result = await this.channelRegistry.routeSpeech(name, body, targetChannel);
3728
+ if (result?.delivered) {
3729
+ this.proseBounceStreaks.delete(name);
3730
+ if (usedClipboard)
3731
+ this.proseClipboards.delete(name);
3732
+ }
3733
+ }
3734
+ catch (err) {
3735
+ console.error(`[prose] ${name}: delivery to ${targetChannel} failed:`, err);
3736
+ }
3737
+ }
3738
+ /** Cap on consecutive bounce-triggered wakes (notices still append after). */
3739
+ static PROSE_BOUNCE_WAKE_CAP = 2;
3740
+ bounceProse(agent, text, reason, candidates) {
3741
+ const name = agent.name;
3742
+ this.proseClipboards.set(name, text);
3743
+ const streak = (this.proseBounceStreaks.get(name) ?? 0) + 1;
3744
+ this.proseBounceStreaks.set(name, streak);
3745
+ const cand = candidates?.length ? ` Known channels it could mean: ${candidates.join(', ')}.` : '';
3746
+ const notice = `[prose-routing] Your text (${text.length} chars) was not delivered — ${reason}.${cand} ` +
3747
+ 'The text is retained; nothing is lost. To deliver it unchanged, reply with a ' +
3748
+ 'destination plus the token {{unsent}}, e.g. ">>#channel {{unsent}}" or ">>@person {{unsent}}". ' +
3749
+ '">>skip_reply {{unsent}}" keeps it in context only. The prose_help tool shows the full syntax.';
3750
+ try {
3751
+ // Through framework.addMessage, NOT the context manager directly: while
3752
+ // the turn is still streaming this defers the notice to the next tool
3753
+ // boundary, where it is BOTH stored in order and injected into the live
3754
+ // stream (hear-while-acting) — the model sees the error and can correct
3755
+ // the prefix within the same turn. A direct append here would land the
3756
+ // notice before the turn's assistant blocks (cache bust, invisible
3757
+ // until the next compile).
3758
+ const id = this.addMessage('user', [{ type: 'text', text: notice }], { system: true, kind: 'prose-bounce' });
3759
+ if (id)
3760
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'prose-bounce' });
3761
+ else
3762
+ this.emitTrace({ type: 'message:added', messageId: 'deferred', source: 'prose-bounce' });
3763
+ }
3764
+ catch (err) {
3765
+ console.error('bounceProse: failed to record bounce notice:', err);
3766
+ }
3767
+ const capped = streak > AgentFramework.PROSE_BOUNCE_WAKE_CAP;
3768
+ console.error(`[prose] ${name}: BOUNCED ${text.length} chars — ${reason}` +
3769
+ (capped ? ' (bounce-wake cap reached; notice appended without wake)' : ''));
3770
+ if (!capped) {
3771
+ // Wake so the resend can happen immediately — an unsent DM reply must
3772
+ // not sit invisible until the next unrelated event.
3773
+ this.pendingRequests.push({
3774
+ agentName: name,
3775
+ reason: 'prose-bounce',
3776
+ source: 'framework',
3777
+ timestamp: Date.now(),
3778
+ });
3779
+ }
3780
+ }
3781
+ /**
3782
+ * One-time mode primer (docs/explicit-prose-routing.md): when an agent's
3783
+ * configured proseRouting differs from the last mode we primed them for,
3784
+ * append the teaching notice and persist the new mode. A brand-new agent
3785
+ * in default locus mode is recorded silently — no primer for the status quo.
3786
+ */
3787
+ maybePrimeProseMode(agent) {
3788
+ const mode = agent.proseRouting;
3789
+ try {
3790
+ const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
3791
+ const state = (data && typeof data === 'object' ? data : {});
3792
+ const primed = { ...(state.proseRoutingPrimed ?? {}) };
3793
+ const stored = primed[agent.name];
3794
+ if (stored === mode)
3795
+ return;
3796
+ // Absence means "always default (locus)": a locus-mode agent that was
3797
+ // never explicit needs no entry and no primer — and skipping the write
3798
+ // keeps framework/state from growing a record per agent (scaling gate).
3799
+ if (mode === 'locus' && stored === undefined)
3800
+ return;
3801
+ if (mode === 'locus')
3802
+ delete primed[agent.name];
3803
+ else
3804
+ primed[agent.name] = mode;
3805
+ state.proseRoutingPrimed = primed;
3806
+ this.store.setStateJson(FRAMEWORK_STATE_ID, state);
3807
+ }
3808
+ catch (err) {
3809
+ console.error('maybePrimeProseMode: state read/write failed:', err);
3810
+ return;
3811
+ }
3812
+ try {
3813
+ const id = agent.getContextManager().addMessage('user', [{ type: 'text', text: proseModePrimer(mode) }], { system: true, kind: 'prose-routing-primer' });
3814
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'prose-routing-primer' });
3815
+ console.error(`[prose] ${agent.name}: mode primer appended (${mode})`);
3816
+ }
3817
+ catch (err) {
3818
+ console.error('maybePrimeProseMode: failed to append primer:', err);
3268
3819
  }
3269
3820
  }
3270
3821
  async startAgentStream(agent, trigger, attempt = 0) {
@@ -3279,20 +3830,74 @@ export class AgentFramework {
3279
3830
  // instead falls back to the global default. A given agent runs one turn at a
3280
3831
  // time, so a set here is only read during THIS turn's routeSpeech /
3281
3832
  // buildChannelContext; retries re-run with the same trigger, re-setting it.
3282
- if (trigger?.channelId) {
3283
- this.activeTriggerChannels.set(agent.name, trigger.channelId);
3833
+ // A context-budget restart continues the SAME logical turn in a fresh
3834
+ // stream — its trigger carries no channelId, and deleting the trigger
3835
+ // channel here mid-logical-turn is exactly the hole that sent Sol's DM
3836
+ // reply to a stale guild channel (2026-07-22): any live locus resolution
3837
+ // after the restart fell through to the hours-old defaultPublishChannel.
3838
+ // Keep the turn's trigger channel across restarts; only real new turns
3839
+ // reset it.
3840
+ if (trigger?.reason !== 'context_budget_restart') {
3841
+ if (trigger?.channelId) {
3842
+ this.activeTriggerChannels.set(agent.name, trigger.channelId);
3843
+ }
3844
+ else {
3845
+ this.activeTriggerChannels.delete(agent.name);
3846
+ }
3284
3847
  }
3285
- else {
3286
- this.activeTriggerChannels.delete(agent.name);
3848
+ // FREEZE this turn's outbound locus now (turn-frozen routing). Resolved
3849
+ // once — home channel for forks, else the triggering channel, else the
3850
+ // global default — and never moved for the rest of the turn: mid-turn
3851
+ // injections must not redirect the agent's plain prose (ambient chatter /
3852
+ // reactions hijacked it before, 2026-07-21). A context-budget restart
3853
+ // continues the same logical turn, so it keeps the existing pin. The
3854
+ // eager snapshot also protects heartbeat turns from a moving
3855
+ // defaultPublishChannel mid-turn. If the effective locus differs from the
3856
+ // last one announced, drop a durable `[routing]` notice into the window
3857
+ // BEFORE this turn compiles, so the agent always knows where its voice
3858
+ // goes (announce-on-change only — no per-turn chatter, append-only for
3859
+ // KV stability).
3860
+ if (trigger?.reason !== 'context_budget_restart') {
3861
+ if (attempt === 0)
3862
+ this.maybePrimeProseMode(agent);
3863
+ if (agent.proseRouting === 'explicit') {
3864
+ // Explicit prose routing: there is no locus. The model names every
3865
+ // destination in-band (`>>` prefixes); the only turn state is the
3866
+ // sticky target, reset for each fresh turn. No freeze, no announce.
3867
+ this.proseTargetPins.delete(agent.name);
3868
+ this.proseContinuations.delete(agent.name);
3869
+ this.midTurnInputSignals.delete(agent.name);
3870
+ this.turnLocusPins.delete(agent.name);
3871
+ }
3872
+ else {
3873
+ const locus = this.channelRegistry?.resolveLocus(agent.name) ?? null;
3874
+ if (locus !== null)
3875
+ this.turnLocusPins.set(agent.name, locus);
3876
+ else
3877
+ this.turnLocusPins.delete(agent.name);
3878
+ this.midTurnInputSignals.delete(agent.name);
3879
+ if (attempt === 0)
3880
+ this.announceLocusIfChanged(agent.name, locus);
3881
+ }
3287
3882
  }
3288
3883
  this.touchEphemeralRun(agent.name, true);
3289
- this.emitTrace({ type: 'inference:started', agentName: agent.name });
3884
+ this.emitTrace({
3885
+ type: 'inference:started',
3886
+ agentName: agent.name,
3887
+ // The turn-frozen locus was pinned just above (kept across a
3888
+ // context-budget restart, which skips the re-pin but re-emits this).
3889
+ channelId: this.turnLocusPins.get(agent.name),
3890
+ });
3290
3891
  this.eventGate?.onInferenceStarted(agent.name);
3291
3892
  this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
3292
3893
  try {
3293
3894
  const requestSnapshot = this.captureInferenceToolSnapshot(agent);
3294
3895
  const allTools = this.getToolsForAgent(agent.name, requestSnapshot);
3295
3896
  const tools = allTools.filter((t) => agent.canUseTool(t.name));
3897
+ // Explicit-mode agents get the on-demand routing reference (teach-by-
3898
+ // bounce: the grammar is never injected, only served when asked).
3899
+ if (agent.proseRouting === 'explicit')
3900
+ tools.push(PROSE_HELP_TOOL);
3296
3901
  // Gather context from modules (pull-based) and MCPL hooks (push-based)
3297
3902
  // Both produce ContextInjection[] that get merged before inference.
3298
3903
  let injections;
@@ -3373,25 +3978,14 @@ export class AgentFramework {
3373
3978
  const myStreamId = agent.streamId;
3374
3979
  let hadToolCalls = false;
3375
3980
  // ---- Present-while-acting turn state ---------------------------------
3376
- // Output locus for the current CONVERSATIONAL ROUND: resolved lazily and
3377
- // kept stable across tool-only rounds. A channel message injected at a
3378
- // tool boundary replaces the pin before the next model round, because a
3379
- // single provider inference can now contain several conversations. The
3380
- // pin lives in `turnLocusPins` so it survives a context-budget restart. A
3381
- // null resolution is not pinned.
3382
- if (trigger?.reason !== 'context_budget_restart') {
3383
- this.turnLocusPins.delete(agent.name);
3384
- this.midTurnRoutingResets.delete(agent.name);
3385
- }
3386
- const resolveTurnLocus = () => {
3387
- let locus = this.turnLocusPins.get(agent.name) ?? null;
3388
- if (locus === null && this.channelRegistry) {
3389
- locus = this.channelRegistry.resolveLocus(agent.name);
3390
- if (locus !== null)
3391
- this.turnLocusPins.set(agent.name, locus);
3392
- }
3393
- return locus;
3394
- };
3981
+ // Output locus for the WHOLE logical turn: frozen in startAgentStream
3982
+ // (home → addressed trigger → global default) before this stream began,
3983
+ // and never moved until the next turn. Mid-turn injections do not touch
3984
+ // it — an ambient message or a reaction arriving at a tool boundary must
3985
+ // not redirect the agent's task narration (2026-07-21 Cairn misroute).
3986
+ // The pin lives in `turnLocusPins` so a context-budget restart (same
3987
+ // logical turn, fresh driveStream) keeps it.
3988
+ const resolveTurnLocus = () => this.turnLocusPins.get(agent.name) ?? null;
3395
3989
  // Ordered delivery chain for live-routed prose. Links are enqueued
3396
3990
  // WITHOUT awaiting in the stream-event loop — an awaited network post
3397
3991
  // here would stall consumption of the next round's events by
@@ -3420,32 +4014,29 @@ export class AgentFramework {
3420
4014
  // historical whole-turn routing so fallback-mode prose is still
3421
4015
  // delivered exactly once, at turn end.
3422
4016
  let liveProseRouting = false;
3423
- // Typing indicator: show "<agent> is typing…" in the channel she's
3424
- // responding to, for the whole duration of this turn. Started here (paired
3425
- // with the finally below, so it can never leak) and refreshed on a 7s
3426
- // interval by the ChannelRegistry until stopped on any exit path.
3427
- let typingChannel = this.channelRegistry
3428
- ? trigger?.channelId ?? this.channelRegistry.getDefaultPublishChannel()
3429
- : null;
4017
+ // Typing indicator: show "<agent> is typing…" for the whole duration of
4018
+ // this turn. Started here (paired with the finally below, so it can never
4019
+ // leak) and refreshed on a 7s interval by the ChannelRegistry until
4020
+ // stopped on any exit path. Never moves mid-turn.
4021
+ // - locus mode: the frozen routing pin (where bare prose will land).
4022
+ // - explicit mode: there is no locus — indicate on the TRIGGER channel
4023
+ // (where the wake came from). Typing is presence, not delivery, so
4024
+ // this doesn't violate never-guess: it says "attending to what you
4025
+ // sent here", which is true regardless of where the reply goes.
4026
+ // Heartbeat/no-trigger explicit turns show no indicator.
4027
+ const typingChannel = agent.proseRouting === 'explicit'
4028
+ ? trigger?.channelId ?? null
4029
+ : resolveTurnLocus();
3430
4030
  if (typingChannel)
3431
4031
  this.channelRegistry.startTyping(typingChannel);
3432
4032
  const adoptInjectedRound = () => {
3433
- if (!this.midTurnRoutingResets.has(agent.name))
4033
+ if (!this.midTurnInputSignals.has(agent.name))
3434
4034
  return;
3435
- const nextLocus = this.midTurnRoutingResets.get(agent.name) ?? null;
3436
- this.midTurnRoutingResets.delete(agent.name);
3437
- // A new human/agent message, not merely a tool result, means any earlier
3438
- // explicit delivery has completed its conversational job.
4035
+ this.midTurnInputSignals.delete(agent.name);
4036
+ // A new conversational message, not merely a tool result, means any
4037
+ // earlier explicit delivery has completed its conversational job.
4038
+ // Routing is NOT touched: the turn locus stays frozen.
3439
4039
  turnSilenced = false;
3440
- if (nextLocus) {
3441
- this.turnLocusPins.set(agent.name, nextLocus);
3442
- if (typingChannel !== nextLocus) {
3443
- if (typingChannel)
3444
- this.channelRegistry?.stopTyping(typingChannel);
3445
- typingChannel = nextLocus;
3446
- this.channelRegistry?.startTyping(nextLocus);
3447
- }
3448
- }
3449
4040
  };
3450
4041
  try {
3451
4042
  for await (const event of stream) {
@@ -3458,6 +4049,10 @@ export class AgentFramework {
3458
4049
  content: event.content,
3459
4050
  blockType: event.meta.type,
3460
4051
  blockIndex: event.meta.blockIndex,
4052
+ // Turn-frozen locus (same value as the typing indicator above):
4053
+ // lets trace consumers tag every chunk with the channel this
4054
+ // turn's prose is bound for, without re-deriving routing.
4055
+ channelId: typingChannel ?? undefined,
3461
4056
  });
3462
4057
  break;
3463
4058
  case 'block': {
@@ -3468,6 +4063,7 @@ export class AgentFramework {
3468
4063
  phase,
3469
4064
  blockType: block.type,
3470
4065
  blockIndex: index,
4066
+ channelId: typingChannel ?? undefined,
3471
4067
  });
3472
4068
  break;
3473
4069
  }
@@ -3492,13 +4088,30 @@ export class AgentFramework {
3492
4088
  assistantBlocks = roundContent.filter((b) => b.type !== 'tool_result');
3493
4089
  }
3494
4090
  else {
3495
- // Fallback (older membrane / XML tool mode): preamble + calls
4091
+ // Fallback (older membrane / XML tool mode): preamble + calls.
4092
+ // XML mode: persist the verbatim generation on each tool_use
4093
+ // (membrane#36 round-trip fidelity). context.rawText is the
4094
+ // parser's fullMatch — the exact <function_calls> block the
4095
+ // model wrote (plus the harness-appended close tag), shared by
4096
+ // every call in the round; the anthropic-xml formatter replays
4097
+ // it byte-identically instead of reconstructing. Guarded by
4098
+ // shape so a native-path fallback can never attach non-XML.
4099
+ const rawText = event.context.rawText;
4100
+ const rawXml = rawText && /^<(antml:)?function_calls>/.test(rawText)
4101
+ ? rawText
4102
+ : undefined;
3496
4103
  assistantBlocks = [];
3497
4104
  if (event.context.preamble) {
3498
4105
  assistantBlocks.push({ type: 'text', text: event.context.preamble });
3499
4106
  }
3500
4107
  for (const c of event.calls) {
3501
- assistantBlocks.push({ type: 'tool_use', id: c.id, name: c.name, input: c.input });
4108
+ assistantBlocks.push({
4109
+ type: 'tool_use',
4110
+ id: c.id,
4111
+ name: c.name,
4112
+ input: c.input,
4113
+ ...(rawXml ? { rawXml } : {}),
4114
+ });
3502
4115
  }
3503
4116
  }
3504
4117
  this.pendingAssistantBlocks.set(agent.name, assistantBlocks);
@@ -3533,7 +4146,20 @@ export class AgentFramework {
3533
4146
  liveProseRouting = true;
3534
4147
  const roundSegments = splitProseSegments(assistantBlocks);
3535
4148
  if (roundSegments.length > 0) {
3536
- if (turnSilenced) {
4149
+ if (agent.proseRouting === 'explicit') {
4150
+ // Explicit mode: every segment through the prose gateway.
4151
+ // Silencing does not apply — unprefixed prose bounces and
4152
+ // prefixed prose is deliberate; think-privacy still holds.
4153
+ if (!hasSameRoundPrivateThink) {
4154
+ console.error(`[prose] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> ${roundSegments.length} segment(s) via prose gateway`);
4155
+ for (const seg of roundSegments) {
4156
+ turnSpeechChain = turnSpeechChain
4157
+ .then(() => this.deliverProse(agent, seg))
4158
+ .catch((err) => console.error('mid-turn prose delivery failed:', err));
4159
+ }
4160
+ }
4161
+ }
4162
+ else if (turnSilenced) {
3537
4163
  console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`);
3538
4164
  }
3539
4165
  else if (hasSameRoundPrivateThink) {
@@ -3812,11 +4438,30 @@ export class AgentFramework {
3812
4438
  .join('\n')
3813
4439
  .trim();
3814
4440
  if (speechText) {
3815
- try {
3816
- await this.channelRegistry.routeSpeech(agent.name, speechText);
4441
+ if (agent.proseRouting === 'explicit') {
4442
+ console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`);
4443
+ try {
4444
+ await this.deliverProse(agent, speechText);
4445
+ }
4446
+ catch (err) {
4447
+ console.error('text-only prose delivery failed:', err);
4448
+ }
3817
4449
  }
3818
- catch (err) {
3819
- console.error('speech routing failed:', err);
4450
+ else {
4451
+ // Route to the TURN-FROZEN locus, like every other speech
4452
+ // path. This dispatch runs AFTER the agent is idle, so a live
4453
+ // resolution here can read the NEXT turn's trigger state (or
4454
+ // a post-restart cleared one) and land the reply in a stale
4455
+ // channel — the 2026-07-22 Sol DM-to-guild misroute. The
4456
+ // frozen pin is immune to both races.
4457
+ const locus = resolveTurnLocus();
4458
+ console.error(`[routing] ${agent.name}: text-only turn -> routing speech -> ${locus ?? '(none)'}`);
4459
+ try {
4460
+ await this.channelRegistry.routeSpeech(agent.name, speechText, locus);
4461
+ }
4462
+ catch (err) {
4463
+ console.error('speech routing failed:', err);
4464
+ }
3820
4465
  }
3821
4466
  }
3822
4467
  }
@@ -3847,7 +4492,20 @@ export class AgentFramework {
3847
4492
  // land before the trailing prose. Awaited even when silenced —
3848
4493
  // the chain may still be flushing earlier rounds' posts.
3849
4494
  await turnSpeechChain;
3850
- if (silenced || segments.length === 0) {
4495
+ if (agent.proseRouting === 'explicit') {
4496
+ if (segments.length > 0) {
4497
+ console.error(`[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`);
4498
+ for (const seg of segments) {
4499
+ try {
4500
+ await this.deliverProse(agent, seg);
4501
+ }
4502
+ catch (err) {
4503
+ console.error('trailing prose delivery failed:', err);
4504
+ }
4505
+ }
4506
+ }
4507
+ }
4508
+ else if (silenced || segments.length === 0) {
3851
4509
  console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> trailing prose NOT routed ` +
3852
4510
  `(${silenced ? 'silencing tool / explicit send' : 'no trailing prose'})`);
3853
4511
  }
@@ -3875,6 +4533,20 @@ export class AgentFramework {
3875
4533
  // NOTE: agent.reset() + onInferenceEnded() already ran above,
3876
4534
  // BEFORE dispatchSpeech. Locus routing is speech dispatch and
3877
4535
  // doesn't depend on the status field, so it correctly runs after.
4536
+ // Explicit-prose `!` continuation: a prose segment this turn asked
4537
+ // to keep going (`>>#x !` / `>>private !`) — start another turn
4538
+ // now instead of pausing until the next external event. This gives
4539
+ // prose the same continuation ability tool rounds have (wanted for
4540
+ // robotics-style loops where an end-of-turn pause is harmful).
4541
+ if (this.proseContinuations.delete(agent.name)) {
4542
+ console.error(`[prose] ${agent.name}: '!' continuation — requesting immediate next turn`);
4543
+ this.pendingRequests.push({
4544
+ agentName: agent.name,
4545
+ reason: 'prose-continuation',
4546
+ source: 'framework',
4547
+ timestamp: Date.now(),
4548
+ });
4549
+ }
3878
4550
  break;
3879
4551
  }
3880
4552
  case 'error': {
@@ -4314,6 +4986,10 @@ export class AgentFramework {
4314
4986
  this.dispatchSaveImageToolCall(agentName, enrichedCall);
4315
4987
  return;
4316
4988
  }
4989
+ if (enrichedCall.name === 'read_image') {
4990
+ this.dispatchReadImageToolCall(agentName, enrichedCall);
4991
+ return;
4992
+ }
4317
4993
  // Route gate_status tool
4318
4994
  if (enrichedCall.name === 'gate_status' && this.eventGate) {
4319
4995
  this.dispatchGateToolCall(agentName, enrichedCall);
@@ -4342,6 +5018,20 @@ export class AgentFramework {
4342
5018
  this.dispatchChannelToolCall(agentName, enrichedCall);
4343
5019
  return;
4344
5020
  }
5021
+ // prose_help: on-demand routing reference for explicit-prose agents.
5022
+ // Answered inline — the grammar is a tool RESULT (model-requested), never
5023
+ // ambient context (see PROSE_ROUTING_HELP / teach-by-bounce).
5024
+ if (enrichedCall.name === 'prose_help') {
5025
+ this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'prose_help', callId: enrichedCall.id, durationMs: 0 });
5026
+ this.pushEvent({
5027
+ type: 'tool-result',
5028
+ callId: enrichedCall.id,
5029
+ agentName,
5030
+ moduleName: 'framework',
5031
+ result: { success: true, data: PROSE_ROUTING_HELP },
5032
+ });
5033
+ return;
5034
+ }
4345
5035
  const sepIndex = enrichedCall.name.indexOf('--');
4346
5036
  const moduleName = sepIndex >= 0 ? enrichedCall.name.substring(0, sepIndex) : 'unknown';
4347
5037
  const toolName = sepIndex >= 0 ? call.name.substring(sepIndex + 2) : call.name;
@@ -4664,6 +5354,13 @@ export class AgentFramework {
4664
5354
  if (err.name === 'OverBudgetError') {
4665
5355
  return { errorType: 'over_budget' };
4666
5356
  }
5357
+ // context-manager's fatal coverage invariant (fix/coverage-invariant-fatal,
5358
+ // 2026-07-26): a compile REFUSES rather than shipping a context with
5359
+ // silently-dropped messages. Recovery is identical to over_budget — kick
5360
+ // the compression drain so summaries cover the un-represented span.
5361
+ if (err.name === 'UncoveredDropError') {
5362
+ return { errorType: 'context_refusal' };
5363
+ }
4667
5364
  return {};
4668
5365
  }
4669
5366
  /**
@@ -4718,7 +5415,19 @@ export class AgentFramework {
4718
5415
  // (e.g. a reason string that crossed a serialization boundary). It matches
4719
5416
  // CM's current OverBudgetError wording and MAY rot if CM rewords it — the
4720
5417
  // errorType gate is the one that's load-bearing.
4721
- const overBudget = errorType === 'over_budget' || /exceed hard budget/i.test(reason);
5418
+ const overBudget = errorType === 'over_budget' ||
5419
+ errorType === 'context_refusal' ||
5420
+ /exceed hard budget|no summary covers/i.test(reason);
5421
+ // Observability: a refused compile means the agent cannot think AT ALL
5422
+ // this turn — surface it on the ops-alert pipeline (ops:alert trace +
5423
+ // webhook → fleet-watch) immediately, not only at the hard-down streak.
5424
+ // The per-(agent,kind) cooldown throttles repeats; failures.log already
5425
+ // has the per-exhaustion record from logFailure above.
5426
+ if (overBudget) {
5427
+ this.opsAlert('context-refusal', agentName, `compile refused (${errorType ?? 'unclassified'}): ${reason.slice(0, 300)} — ` +
5428
+ `drain breaker ${agent ? 'kicking' : 'unavailable (no agent handle)'}; ` +
5429
+ `if this persists, raise contextBudgetTokens or overBudgetGraceRatio.`, { skipLog: true });
5430
+ }
4722
5431
  if (agent && overBudget && !this.overBudgetDrainInFlight.has(agentName)) {
4723
5432
  this.overBudgetDrainInFlight.add(agentName);
4724
5433
  void (async () => {
@@ -4865,15 +5574,35 @@ export class AgentFramework {
4865
5574
  // yes, wake no).
4866
5575
  onRouteFailure: ({ channelId, reason, textLen }) => {
4867
5576
  try {
5577
+ // Render a human-readable channel name when we can — a bare
5578
+ // snowflake in the marker is unresolvable for the agent (the
5579
+ // 2026-07-21 incident read as "a stale artifact", not a live
5580
+ // failure). The marker is `system: true`, so it is never
5581
+ // conversational and never influences routing.
5582
+ const label = channelId
5583
+ ? this.channelRegistry?.getDescriptor(channelId)?.label
5584
+ : undefined;
5585
+ const where = channelId
5586
+ ? label && label !== channelId
5587
+ ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})`
5588
+ : channelId
5589
+ : 'the channel';
4868
5590
  this.addMessage('user', [{
4869
5591
  type: 'text',
4870
- 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.`,
5592
+ 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.`,
4871
5593
  }], { system: true, kind: 'discord-send-failed', channelId: channelId ?? '', reason });
4872
5594
  }
4873
5595
  catch (err) {
4874
5596
  console.error('onRouteFailure: failed to record send-failure marker:', err);
4875
5597
  }
4876
5598
  },
5599
+ // A channel opened without the agent asking (policy admission or an
5600
+ // open forced by delivery) must be announced in the window, with the
5601
+ // opt-out named. Routed here so registry-driven opens and framework-
5602
+ // driven opens produce the same durable notice.
5603
+ onChannelAutoOpened: ({ conversationId, source, channels }) => {
5604
+ this.recordChannelAutoOpenNotice(conversationId, channels, source);
5605
+ },
4877
5606
  });
4878
5607
  // Host capabilities advertised during the MCP handshake — stored so
4879
5608
  // servers can also be connected later at runtime (connectMcplServer).
@@ -5726,6 +6455,33 @@ export class AgentFramework {
5726
6455
  this.checkpointManager.recordCheckpoint(serverId, fs, result.state);
5727
6456
  }
5728
6457
  }
6458
+ // INVARIANT: engaging a channel opens it. A successful explicit send
6459
+ // into a channel the registry knows as closed drives the same open
6460
+ // flow as channel_open (durable desired state, server subscribe), so
6461
+ // there is no such thing as posting into a channel that stays
6462
+ // half-alive (no typing, no reactions, no inbound forwarding).
6463
+ // Fire-and-forget: the send already succeeded; the open must not
6464
+ // delay or fail the tool result.
6465
+ if (this.channelRegistry &&
6466
+ SEND_ENGAGEMENT_TOOLS.has(toolName) &&
6467
+ result.isError !== true) {
6468
+ const target = extractChannelIdFromToolResult(result.content) ??
6469
+ (typeof args.channelId === 'string' ? args.channelId : undefined);
6470
+ if (target) {
6471
+ this.channelRegistry
6472
+ .openIfClosedForSend(target, serverId)
6473
+ .then(({ status, channelId, label }) => {
6474
+ if (status === 'opened') {
6475
+ console.error(`[channel] ${agentName}: explicit ${toolName} into closed channel ${target} — opened (send implies engagement)`);
6476
+ this.recordChannelAutoOpenNotice(agentName, [{ channelId: channelId ?? target, label }], 'opened-by-reply');
6477
+ }
6478
+ else if (status === 'open-failed') {
6479
+ console.error(`[channel] ${agentName}: ${toolName} delivered to ${target} but the open-on-send failed — channel remains half-alive, will reconcile`);
6480
+ }
6481
+ })
6482
+ .catch((err) => console.error('[channel] open-on-send error:', err));
6483
+ }
6484
+ }
5729
6485
  // Convert MCP tool result to framework ToolResult.
5730
6486
  // When the result contains non-text blocks (e.g. images from an MCP
5731
6487
  // tool like zulip-mcp's fetch_attachment), pass the full content array
@@ -5816,6 +6572,19 @@ export class AgentFramework {
5816
6572
  .then((result) => {
5817
6573
  const durationMs = Date.now() - startTime;
5818
6574
  this.emitTrace({ type: 'tool:completed', module: 'channels', tool: call.name, callId: call.id, durationMs });
6575
+ // A successful channel_open plants the agent's feet in the opened
6576
+ // channel: pin it as this turn's reply locus so plain-text speech
6577
+ // written right after opening routes THERE. Found live 2026-07-21
6578
+ // (Aria bring-up): open → plain-text reply → "no locus (no
6579
+ // home/active channel, defaultPublishChannel null)" on a turn with
6580
+ // no channel trigger (post-restart continuation). Next turn start
6581
+ // re-resolves the locus as usual (startAgentStream set/delete).
6582
+ if (call.name === 'channel_open' && result?.success) {
6583
+ const opened = result.data?.channelId
6584
+ ?? call.input?.channelId;
6585
+ if (opened)
6586
+ this.activeTriggerChannels.set(agentName, opened);
6587
+ }
5819
6588
  this.pushEvent({
5820
6589
  type: 'tool-result',
5821
6590
  callId: call.id,
@@ -5942,12 +6711,31 @@ export class AgentFramework {
5942
6711
  const human = seconds >= 3600 ? `${(seconds / 3600).toFixed(1)}h`
5943
6712
  : seconds >= 60 ? `${Math.round(seconds / 60)}m`
5944
6713
  : `${Math.round(seconds)}s`;
5945
- // Announce in the sticky channel (best-effort; never blocks the result).
6714
+ // Announce in the TURN's frozen locus (best-effort; never blocks the
6715
+ // result). The sleep tool runs mid-turn, so the pin is live — same
6716
+ // authority as every other speech path, no live re-resolution.
5946
6717
  if (announce && this.channelRegistry) {
5947
6718
  const text = input.message ?? `💤 Going quiet for ${human}. I'll still see messages, but won't respond until I wake.`;
5948
- this.channelRegistry.routeSpeech(agentName, text).catch((err) => {
5949
- console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
5950
- });
6719
+ const agent = this.agents.get(agentName);
6720
+ if (agent?.proseRouting === 'explicit') {
6721
+ // Explicit mode: announce to the turn's sticky prose target if the
6722
+ // model has set one; otherwise stay quiet — never guess a channel.
6723
+ const target = this.proseTargetPins.get(agentName);
6724
+ if (target) {
6725
+ this.channelRegistry.routeSpeech(agentName, text, target).catch((err) => {
6726
+ console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
6727
+ });
6728
+ }
6729
+ else {
6730
+ console.error(`[sleep] ${agentName}: no prose target this turn — sleep announcement not posted (explicit mode never guesses)`);
6731
+ }
6732
+ }
6733
+ else {
6734
+ const locus = this.turnLocusPins.get(agentName) ?? null;
6735
+ this.channelRegistry.routeSpeech(agentName, text, locus).catch((err) => {
6736
+ console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
6737
+ });
6738
+ }
5951
6739
  }
5952
6740
  console.error(`[sleep] agent=${agentName} seconds=${seconds} announce=${announce} until=${new Date(until).toISOString()}`);
5953
6741
  // endTurn: the agent stops here and goes idle for the duration.
@@ -5962,6 +6750,72 @@ export class AgentFramework {
5962
6750
  endTurn: true,
5963
6751
  });
5964
6752
  }
6753
+ /**
6754
+ * Handle the synthesized `read_image` tool: read image bytes from a
6755
+ * workspace mount and return them as a native MCP-shaped content array
6756
+ * (`[{type:'text'…},{type:'image', data, mimeType}]`). The live-turn
6757
+ * converter (tryNativeToolResultContent → membrane tool_result image
6758
+ * blocks) delivers the actual image to the model; history storage keeps
6759
+ * the standard compact `[image: …]` placeholder.
6760
+ */
6761
+ dispatchReadImageToolCall(agentName, call) {
6762
+ this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
6763
+ const finish = (result) => {
6764
+ this.emitTrace({
6765
+ type: result.isError ? 'tool:failed' : 'tool:completed',
6766
+ module: 'workspace',
6767
+ tool: call.name,
6768
+ callId: call.id,
6769
+ durationMs: 0,
6770
+ ...(result.isError ? { error: result.error } : {}),
6771
+ });
6772
+ this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace', result });
6773
+ };
6774
+ void (async () => {
6775
+ try {
6776
+ const workspace = this.getWorkspaceModule();
6777
+ if (!workspace || typeof workspace.readBinary !== 'function') {
6778
+ throw new Error('read_image requires a workspace module');
6779
+ }
6780
+ const input = (call.input ?? {});
6781
+ if (typeof input.path !== 'string' || input.path.length === 0) {
6782
+ throw new Error('read_image: `path` (mount-prefixed) is required');
6783
+ }
6784
+ const read = await workspace.readBinary(input.path);
6785
+ if ('error' in read)
6786
+ throw new Error(read.error);
6787
+ const mediaType = sniffImageMediaType(read.data);
6788
+ if (!mediaType) {
6789
+ throw new Error(`read_image: "${input.path}" does not look like a supported image ` +
6790
+ '(png/jpeg/gif/webp — checked by magic bytes, not extension)');
6791
+ }
6792
+ // Anthropic rejects images >5MB (and >8000px); guard the hard byte
6793
+ // limit here — resizing is out of scope without an image library.
6794
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
6795
+ if (read.data.byteLength > MAX_IMAGE_BYTES) {
6796
+ throw new Error(`read_image: "${input.path}" is ${read.data.byteLength} bytes — ` +
6797
+ `over the ${MAX_IMAGE_BYTES}-byte model limit`);
6798
+ }
6799
+ finish({
6800
+ success: true,
6801
+ data: [
6802
+ {
6803
+ type: 'text',
6804
+ text: `${input.path} (${mediaType}, ${Math.ceil(read.data.byteLength / 1024)} KB):`,
6805
+ },
6806
+ { type: 'image', data: read.data.toString('base64'), mimeType: mediaType },
6807
+ ],
6808
+ });
6809
+ }
6810
+ catch (error) {
6811
+ finish({
6812
+ success: false,
6813
+ error: error instanceof Error ? error.message : String(error),
6814
+ isError: true,
6815
+ });
6816
+ }
6817
+ })();
6818
+ }
5965
6819
  /**
5966
6820
  * Handle the synthesized `save_recent_image` tool: locate the requested
5967
6821
  * image blocks in the calling agent's context (blobs re-inlined, counted