@animalabs/agent-framework 0.6.10 → 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.
- package/dist/src/agent.d.ts +2 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +3 -0
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +115 -15
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +820 -104
- package/dist/src/framework.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +87 -7
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +329 -49
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +13 -4
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/types/agent.d.ts +17 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +32 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/channel-registry-routing.test.js +186 -10
- package/dist/test/channel-registry-routing.test.js.map +1 -1
- package/dist/test/explicit-prose-routing.test.d.ts +11 -0
- package/dist/test/explicit-prose-routing.test.d.ts.map +1 -0
- package/dist/test/explicit-prose-routing.test.js +247 -0
- package/dist/test/explicit-prose-routing.test.js.map +1 -0
- package/dist/test/present-while-acting.test.js +85 -8
- package/dist/test/present-while-acting.test.js.map +1 -1
- package/dist/test/trunk-channel-routing.test.js +2 -2
- package/dist/test/trunk-channel-routing.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/agent.ts +3 -0
- package/src/framework.ts +881 -107
- package/src/mcpl/channel-registry.ts +401 -54
- package/src/modules/workspace/index.ts +13 -4
- package/src/types/agent.ts +18 -0
- 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,
|
|
@@ -109,6 +110,134 @@ function sniffImageMediaType(data: Buffer): string | undefined {
|
|
|
109
110
|
const SILENCING_TOOLS = new Set([
|
|
110
111
|
'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
|
|
111
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
|
+
};
|
|
112
241
|
/** Strip the `server--` MCPL prefix from a tool name. */
|
|
113
242
|
const bareToolName = (n: string): string => n.split('--').pop()!;
|
|
114
243
|
import { CheckpointManager } from './mcpl/checkpoint-manager.js';
|
|
@@ -391,20 +520,38 @@ export class AgentFramework {
|
|
|
391
520
|
private processLoggingBroadcast: boolean;
|
|
392
521
|
private activeStreams: Map<string, Promise<void>> = new Map();
|
|
393
522
|
|
|
394
|
-
/** Per-agent output locus
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
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. */
|
|
401
531
|
private turnLocusPins: Map<string, string> = new Map();
|
|
402
|
-
/** A tool boundary injected fresh
|
|
403
|
-
*
|
|
404
|
-
* explicit-send suppression before handling
|
|
405
|
-
*
|
|
406
|
-
|
|
407
|
-
|
|
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();
|
|
408
555
|
private pendingAssistantBlocks: Map<string, ContentBlock[]> = new Map();
|
|
409
556
|
/** Streams the FRAMEWORK cancelled for non-terminal reasons, keyed
|
|
410
557
|
* `${agentName}:${streamId}`: an endTurn tool result or a context-budget
|
|
@@ -1296,25 +1443,55 @@ export class AgentFramework {
|
|
|
1296
1443
|
return agent.getRuntimeSettings();
|
|
1297
1444
|
}
|
|
1298
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
|
+
*/
|
|
1299
1470
|
updateAgentRuntimeSettings(
|
|
1300
1471
|
agentName: string,
|
|
1301
1472
|
patch: AgentRuntimeSettingsPatch,
|
|
1473
|
+
opts?: { persist?: boolean },
|
|
1302
1474
|
): AgentRuntimeSettingsSnapshot {
|
|
1303
1475
|
const agent = this.agents.get(agentName);
|
|
1304
1476
|
if (!agent) throw new Error(`Unknown agent: ${agentName}`);
|
|
1305
1477
|
const result = agent.updateRuntimeSettings(patch);
|
|
1306
|
-
|
|
1478
|
+
if (opts?.persist !== false) {
|
|
1479
|
+
this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
|
|
1480
|
+
}
|
|
1307
1481
|
return result;
|
|
1308
1482
|
}
|
|
1309
1483
|
|
|
1310
1484
|
resetAgentRuntimeSettings(
|
|
1311
1485
|
agentName: string,
|
|
1312
1486
|
keys?: Array<keyof AgentRuntimeSettingsPatch>,
|
|
1487
|
+
opts?: { persist?: boolean },
|
|
1313
1488
|
): AgentRuntimeSettingsSnapshot {
|
|
1314
1489
|
const agent = this.agents.get(agentName);
|
|
1315
1490
|
if (!agent) throw new Error(`Unknown agent: ${agentName}`);
|
|
1316
1491
|
const result = agent.resetRuntimeSettings(keys);
|
|
1317
|
-
|
|
1492
|
+
if (opts?.persist !== false) {
|
|
1493
|
+
this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
|
|
1494
|
+
}
|
|
1318
1495
|
return result;
|
|
1319
1496
|
}
|
|
1320
1497
|
|
|
@@ -1375,7 +1552,7 @@ export class AgentFramework {
|
|
|
1375
1552
|
*/
|
|
1376
1553
|
async previewActivation(
|
|
1377
1554
|
agentName: string,
|
|
1378
|
-
opts?: { injections?: boolean }
|
|
1555
|
+
opts?: { injections?: boolean; budget?: TokenBudget }
|
|
1379
1556
|
): Promise<NormalizedRequest> {
|
|
1380
1557
|
const agent = this.agents.get(agentName);
|
|
1381
1558
|
if (!agent) {
|
|
@@ -1384,10 +1561,14 @@ export class AgentFramework {
|
|
|
1384
1561
|
|
|
1385
1562
|
const tools = this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name));
|
|
1386
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.
|
|
1387
1568
|
// Default: no dynamic injection gathering → fully transparent (no
|
|
1388
1569
|
// inference, no Chronicle writes, no external RPC). Opt in explicitly.
|
|
1389
1570
|
if (!opts?.injections) {
|
|
1390
|
-
return agent.buildActivationRequest(tools, undefined);
|
|
1571
|
+
return agent.buildActivationRequest(tools, undefined, opts?.budget);
|
|
1391
1572
|
}
|
|
1392
1573
|
|
|
1393
1574
|
// Full-fidelity path: mirrors startAgentStream's injection gathering.
|
|
@@ -1422,7 +1603,42 @@ export class AgentFramework {
|
|
|
1422
1603
|
}
|
|
1423
1604
|
}
|
|
1424
1605
|
|
|
1425
|
-
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
|
+
);
|
|
1426
1642
|
}
|
|
1427
1643
|
|
|
1428
1644
|
/**
|
|
@@ -3257,23 +3473,17 @@ export class AgentFramework {
|
|
|
3257
3473
|
}
|
|
3258
3474
|
}
|
|
3259
3475
|
|
|
3260
|
-
// A newly injected message begins a new
|
|
3261
|
-
// the same provider inference. Remember
|
|
3262
|
-
// driveStream: an explicit send in the preceding
|
|
3263
|
-
// silence the reply
|
|
3264
|
-
//
|
|
3265
|
-
//
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
? candidate
|
|
3272
|
-
: latest;
|
|
3273
|
-
},
|
|
3274
|
-
null,
|
|
3275
|
-
);
|
|
3276
|
-
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);
|
|
3277
3487
|
}
|
|
3278
3488
|
|
|
3279
3489
|
// Check if any tool result requested endTurn
|
|
@@ -3493,6 +3703,7 @@ export class AgentFramework {
|
|
|
3493
3703
|
content: ContentBlock[];
|
|
3494
3704
|
timestamp: string;
|
|
3495
3705
|
metadata?: Record<string, unknown>;
|
|
3706
|
+
tags?: string[];
|
|
3496
3707
|
triggerInference?: boolean;
|
|
3497
3708
|
}): Promise<void> {
|
|
3498
3709
|
const metadata: Record<string, unknown> = {
|
|
@@ -3504,6 +3715,7 @@ export class AgentFramework {
|
|
|
3504
3715
|
serverId: event.serverId,
|
|
3505
3716
|
};
|
|
3506
3717
|
if (event.threadId) metadata.threadId = event.threadId;
|
|
3718
|
+
if (event.tags) metadata.tags = event.tags;
|
|
3507
3719
|
|
|
3508
3720
|
// Per-channel conversation routing: messages go to the channel's fork
|
|
3509
3721
|
// agent (spawned from the template on first qualifying message), never
|
|
@@ -3517,6 +3729,7 @@ export class AgentFramework {
|
|
|
3517
3729
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
|
|
3518
3730
|
|
|
3519
3731
|
if (event.triggerInference) {
|
|
3732
|
+
const addressed = isAddressedMessage(event.tags, event.metadata);
|
|
3520
3733
|
for (const agentName of this.agents.keys()) {
|
|
3521
3734
|
this.pendingRequests.push({
|
|
3522
3735
|
agentName,
|
|
@@ -3526,6 +3739,9 @@ export class AgentFramework {
|
|
|
3526
3739
|
// Route this turn's auto-published speech back to THIS channel, not
|
|
3527
3740
|
// the global most-recent-inbound locus (item-3 redux, trunk agents).
|
|
3528
3741
|
channelId: event.channelId,
|
|
3742
|
+
// Addressed messages outrank ambient chatter when a batched wake
|
|
3743
|
+
// picks the turn's frozen speech locus.
|
|
3744
|
+
addressed,
|
|
3529
3745
|
});
|
|
3530
3746
|
}
|
|
3531
3747
|
}
|
|
@@ -3545,6 +3761,7 @@ export class AgentFramework {
|
|
|
3545
3761
|
author: { id: string; name: string };
|
|
3546
3762
|
content: ContentBlock[];
|
|
3547
3763
|
metadata?: Record<string, unknown>;
|
|
3764
|
+
tags?: string[];
|
|
3548
3765
|
triggerInference?: boolean;
|
|
3549
3766
|
},
|
|
3550
3767
|
messageMetadata: Record<string, unknown>,
|
|
@@ -3623,6 +3840,7 @@ export class AgentFramework {
|
|
|
3623
3840
|
// A fork's home channel wins in routeSpeech regardless, but carry the
|
|
3624
3841
|
// triggering channel too so the trunk/active path stays consistent.
|
|
3625
3842
|
channelId: event.channelId,
|
|
3843
|
+
addressed: isAddressedMessage(event.tags, event.metadata),
|
|
3626
3844
|
});
|
|
3627
3845
|
}
|
|
3628
3846
|
}
|
|
@@ -3800,6 +4018,7 @@ export class AgentFramework {
|
|
|
3800
4018
|
event.serverId,
|
|
3801
4019
|
triggerChannel.channelId,
|
|
3802
4020
|
triggerChannel.label,
|
|
4021
|
+
triggerChannel.metadata,
|
|
3803
4022
|
);
|
|
3804
4023
|
}
|
|
3805
4024
|
|
|
@@ -3809,7 +4028,17 @@ export class AgentFramework {
|
|
|
3809
4028
|
featureSet: event.featureSet,
|
|
3810
4029
|
eventId: event.eventId,
|
|
3811
4030
|
triggered: event.triggerInference ?? false,
|
|
4031
|
+
...(event.tags ? { tags: event.tags } : {}),
|
|
3812
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
|
+
}
|
|
3813
4042
|
|
|
3814
4043
|
const content = [...event.content];
|
|
3815
4044
|
const addressedWhileClosed = triggerChannel &&
|
|
@@ -3867,6 +4096,10 @@ export class AgentFramework {
|
|
|
3867
4096
|
source: event.serverId,
|
|
3868
4097
|
timestamp: Date.now(),
|
|
3869
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),
|
|
3870
4103
|
});
|
|
3871
4104
|
}
|
|
3872
4105
|
}
|
|
@@ -3890,13 +4123,29 @@ export class AgentFramework {
|
|
|
3890
4123
|
*/
|
|
3891
4124
|
private derivePushEventChannel(
|
|
3892
4125
|
origin: Record<string, unknown> | undefined,
|
|
3893
|
-
): { channelId: string; label?: string } | undefined {
|
|
4126
|
+
): { channelId: string; label?: string; metadata?: Record<string, unknown> } | undefined {
|
|
3894
4127
|
if (!origin) return undefined;
|
|
3895
|
-
|
|
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
|
+
}
|
|
3896
4145
|
|
|
3897
4146
|
const explicit = origin.mcplChannelId;
|
|
3898
4147
|
if (typeof explicit === 'string' && explicit) {
|
|
3899
|
-
return { channelId: explicit, label };
|
|
4148
|
+
return { channelId: explicit, ...(label ? { label } : {}), ...(metadata ? { metadata } : {}) };
|
|
3900
4149
|
}
|
|
3901
4150
|
|
|
3902
4151
|
// Discord fallback: reconstruct the composite from origin parts. `guildId`
|
|
@@ -3904,7 +4153,11 @@ export class AgentFramework {
|
|
|
3904
4153
|
if (origin.source === 'discord' && typeof origin.channelId === 'string' && origin.channelId) {
|
|
3905
4154
|
const guild =
|
|
3906
4155
|
typeof origin.guildId === 'string' && origin.guildId ? origin.guildId : 'dm';
|
|
3907
|
-
return {
|
|
4156
|
+
return {
|
|
4157
|
+
channelId: `discord:${guild}:${origin.channelId}`,
|
|
4158
|
+
...(label ? { label } : {}),
|
|
4159
|
+
...(metadata ? { metadata } : {}),
|
|
4160
|
+
};
|
|
3908
4161
|
}
|
|
3909
4162
|
|
|
3910
4163
|
return undefined;
|
|
@@ -3992,17 +4245,302 @@ export class AgentFramework {
|
|
|
3992
4245
|
const trigger = requests[0];
|
|
3993
4246
|
// Route this turn's auto-published speech to the channel that triggered
|
|
3994
4247
|
// it (item-3 redux). A batched wake may carry several triggering channels
|
|
3995
|
-
// (messages arrived in >1 channel while the agent was busy/idle)
|
|
3996
|
-
//
|
|
3997
|
-
//
|
|
3998
|
-
//
|
|
3999
|
-
//
|
|
4000
|
-
//
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
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' },
|
|
4004
4539
|
);
|
|
4005
|
-
|
|
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);
|
|
4006
4544
|
}
|
|
4007
4545
|
}
|
|
4008
4546
|
|
|
@@ -4019,14 +4557,60 @@ export class AgentFramework {
|
|
|
4019
4557
|
// instead falls back to the global default. A given agent runs one turn at a
|
|
4020
4558
|
// time, so a set here is only read during THIS turn's routeSpeech /
|
|
4021
4559
|
// buildChannelContext; retries re-run with the same trigger, re-setting it.
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
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
|
+
}
|
|
4026
4604
|
}
|
|
4027
4605
|
|
|
4028
4606
|
this.touchEphemeralRun(agent.name, true);
|
|
4029
|
-
this.emitTrace({
|
|
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
|
+
});
|
|
4030
4614
|
this.eventGate?.onInferenceStarted(agent.name);
|
|
4031
4615
|
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
|
|
4032
4616
|
|
|
@@ -4034,6 +4618,9 @@ export class AgentFramework {
|
|
|
4034
4618
|
const requestSnapshot = this.captureInferenceToolSnapshot(agent);
|
|
4035
4619
|
const allTools = this.getToolsForAgent(agent.name, requestSnapshot);
|
|
4036
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);
|
|
4037
4624
|
|
|
4038
4625
|
// Gather context from modules (pull-based) and MCPL hooks (push-based)
|
|
4039
4626
|
// Both produce ContextInjection[] that get merged before inference.
|
|
@@ -4138,24 +4725,15 @@ export class AgentFramework {
|
|
|
4138
4725
|
let hadToolCalls = false;
|
|
4139
4726
|
|
|
4140
4727
|
// ---- Present-while-acting turn state ---------------------------------
|
|
4141
|
-
// Output locus for the
|
|
4142
|
-
//
|
|
4143
|
-
//
|
|
4144
|
-
//
|
|
4145
|
-
//
|
|
4146
|
-
//
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
this.
|
|
4150
|
-
}
|
|
4151
|
-
const resolveTurnLocus = (): string | null => {
|
|
4152
|
-
let locus = this.turnLocusPins.get(agent.name) ?? null;
|
|
4153
|
-
if (locus === null && this.channelRegistry) {
|
|
4154
|
-
locus = this.channelRegistry.resolveLocus(agent.name);
|
|
4155
|
-
if (locus !== null) this.turnLocusPins.set(agent.name, locus);
|
|
4156
|
-
}
|
|
4157
|
-
return locus;
|
|
4158
|
-
};
|
|
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;
|
|
4159
4737
|
|
|
4160
4738
|
// Ordered delivery chain for live-routed prose. Links are enqueued
|
|
4161
4739
|
// WITHOUT awaiting in the stream-event loop — an awaited network post
|
|
@@ -4188,32 +4766,30 @@ export class AgentFramework {
|
|
|
4188
4766
|
// delivered exactly once, at turn end.
|
|
4189
4767
|
let liveProseRouting = false;
|
|
4190
4768
|
|
|
4191
|
-
// Typing indicator: show "<agent> is typing…"
|
|
4192
|
-
//
|
|
4193
|
-
//
|
|
4194
|
-
//
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
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();
|
|
4198
4783
|
if (typingChannel) this.channelRegistry!.startTyping(typingChannel);
|
|
4199
4784
|
|
|
4200
4785
|
const adoptInjectedRound = (): void => {
|
|
4201
|
-
if (!this.
|
|
4202
|
-
|
|
4203
|
-
this.midTurnRoutingResets.delete(agent.name);
|
|
4786
|
+
if (!this.midTurnInputSignals.has(agent.name)) return;
|
|
4787
|
+
this.midTurnInputSignals.delete(agent.name);
|
|
4204
4788
|
|
|
4205
|
-
// A new
|
|
4206
|
-
// 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.
|
|
4207
4792
|
turnSilenced = false;
|
|
4208
|
-
|
|
4209
|
-
if (nextLocus) {
|
|
4210
|
-
this.turnLocusPins.set(agent.name, nextLocus);
|
|
4211
|
-
if (typingChannel !== nextLocus) {
|
|
4212
|
-
if (typingChannel) this.channelRegistry?.stopTyping(typingChannel);
|
|
4213
|
-
typingChannel = nextLocus;
|
|
4214
|
-
this.channelRegistry?.startTyping(nextLocus);
|
|
4215
|
-
}
|
|
4216
|
-
}
|
|
4217
4793
|
};
|
|
4218
4794
|
|
|
4219
4795
|
try {
|
|
@@ -4227,6 +4803,10 @@ export class AgentFramework {
|
|
|
4227
4803
|
content: event.content,
|
|
4228
4804
|
blockType: event.meta.type,
|
|
4229
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,
|
|
4230
4810
|
});
|
|
4231
4811
|
break;
|
|
4232
4812
|
|
|
@@ -4238,6 +4818,7 @@ export class AgentFramework {
|
|
|
4238
4818
|
phase,
|
|
4239
4819
|
blockType: block.type,
|
|
4240
4820
|
blockIndex: index,
|
|
4821
|
+
channelId: typingChannel ?? undefined,
|
|
4241
4822
|
});
|
|
4242
4823
|
break;
|
|
4243
4824
|
}
|
|
@@ -4265,13 +4846,30 @@ export class AgentFramework {
|
|
|
4265
4846
|
(b) => b.type !== 'tool_result'
|
|
4266
4847
|
);
|
|
4267
4848
|
} else {
|
|
4268
|
-
// 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;
|
|
4269
4861
|
assistantBlocks = [];
|
|
4270
4862
|
if (event.context.preamble) {
|
|
4271
4863
|
assistantBlocks.push({ type: 'text', text: event.context.preamble });
|
|
4272
4864
|
}
|
|
4273
4865
|
for (const c of event.calls) {
|
|
4274
|
-
assistantBlocks.push({
|
|
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
|
+
});
|
|
4275
4873
|
}
|
|
4276
4874
|
}
|
|
4277
4875
|
this.pendingAssistantBlocks.set(agent.name, assistantBlocks);
|
|
@@ -4311,7 +4909,21 @@ export class AgentFramework {
|
|
|
4311
4909
|
liveProseRouting = true;
|
|
4312
4910
|
const roundSegments = splitProseSegments(assistantBlocks);
|
|
4313
4911
|
if (roundSegments.length > 0) {
|
|
4314
|
-
if (
|
|
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) {
|
|
4315
4927
|
console.error(
|
|
4316
4928
|
`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`,
|
|
4317
4929
|
);
|
|
@@ -4635,10 +5247,29 @@ export class AgentFramework {
|
|
|
4635
5247
|
.join('\n')
|
|
4636
5248
|
.trim();
|
|
4637
5249
|
if (speechText) {
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
|
|
4641
|
-
|
|
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
|
+
}
|
|
4642
5273
|
}
|
|
4643
5274
|
}
|
|
4644
5275
|
} else if (this.channelRegistry && hadToolCalls && allText.length > 0) {
|
|
@@ -4671,7 +5302,20 @@ export class AgentFramework {
|
|
|
4671
5302
|
// the chain may still be flushing earlier rounds' posts.
|
|
4672
5303
|
await turnSpeechChain;
|
|
4673
5304
|
|
|
4674
|
-
if (
|
|
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) {
|
|
4675
5319
|
console.error(
|
|
4676
5320
|
`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> trailing prose NOT routed ` +
|
|
4677
5321
|
`(${silenced ? 'silencing tool / explicit send' : 'no trailing prose'})`,
|
|
@@ -4702,6 +5346,21 @@ export class AgentFramework {
|
|
|
4702
5346
|
// BEFORE dispatchSpeech. Locus routing is speech dispatch and
|
|
4703
5347
|
// doesn't depend on the status field, so it correctly runs after.
|
|
4704
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
|
+
|
|
4705
5364
|
break;
|
|
4706
5365
|
}
|
|
4707
5366
|
|
|
@@ -5209,6 +5868,21 @@ export class AgentFramework {
|
|
|
5209
5868
|
return;
|
|
5210
5869
|
}
|
|
5211
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
|
+
|
|
5212
5886
|
const sepIndex = enrichedCall.name.indexOf('--');
|
|
5213
5887
|
const moduleName = sepIndex >= 0 ? enrichedCall.name.substring(0, sepIndex) : 'unknown';
|
|
5214
5888
|
const toolName = sepIndex >= 0 ? call.name.substring(sepIndex + 2) : call.name;
|
|
@@ -5587,6 +6261,13 @@ export class AgentFramework {
|
|
|
5587
6261
|
if (err.name === 'OverBudgetError') {
|
|
5588
6262
|
return { errorType: 'over_budget' };
|
|
5589
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
|
+
}
|
|
5590
6271
|
return {};
|
|
5591
6272
|
}
|
|
5592
6273
|
|
|
@@ -5656,7 +6337,25 @@ export class AgentFramework {
|
|
|
5656
6337
|
// (e.g. a reason string that crossed a serialization boundary). It matches
|
|
5657
6338
|
// CM's current OverBudgetError wording and MAY rot if CM rewords it — the
|
|
5658
6339
|
// errorType gate is the one that's load-bearing.
|
|
5659
|
-
const overBudget =
|
|
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
|
+
}
|
|
5660
6359
|
if (agent && overBudget && !this.overBudgetDrainInFlight.has(agentName)) {
|
|
5661
6360
|
this.overBudgetDrainInFlight.add(agentName);
|
|
5662
6361
|
void (async () => {
|
|
@@ -5853,11 +6552,24 @@ export class AgentFramework {
|
|
|
5853
6552
|
// yes, wake no).
|
|
5854
6553
|
onRouteFailure: ({ channelId, reason, textLen }) => {
|
|
5855
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';
|
|
5856
6568
|
this.addMessage(
|
|
5857
6569
|
'user',
|
|
5858
6570
|
[{
|
|
5859
6571
|
type: 'text',
|
|
5860
|
-
text: `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${
|
|
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.`,
|
|
5861
6573
|
}],
|
|
5862
6574
|
{ system: true, kind: 'discord-send-failed', channelId: channelId ?? '', reason },
|
|
5863
6575
|
);
|
|
@@ -5865,6 +6577,13 @@ export class AgentFramework {
|
|
|
5865
6577
|
console.error('onRouteFailure: failed to record send-failure marker:', err);
|
|
5866
6578
|
}
|
|
5867
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
|
+
},
|
|
5868
6587
|
},
|
|
5869
6588
|
);
|
|
5870
6589
|
|
|
@@ -6904,6 +7623,44 @@ export class AgentFramework {
|
|
|
6904
7623
|
}
|
|
6905
7624
|
}
|
|
6906
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
|
+
|
|
6907
7664
|
// Convert MCP tool result to framework ToolResult.
|
|
6908
7665
|
// When the result contains non-text blocks (e.g. images from an MCP
|
|
6909
7666
|
// tool like zulip-mcp's fetch_attachment), pass the full content array
|
|
@@ -7152,12 +7909,29 @@ export class AgentFramework {
|
|
|
7152
7909
|
: seconds >= 60 ? `${Math.round(seconds / 60)}m`
|
|
7153
7910
|
: `${Math.round(seconds)}s`;
|
|
7154
7911
|
|
|
7155
|
-
// Announce in the
|
|
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.
|
|
7156
7915
|
if (announce && this.channelRegistry) {
|
|
7157
7916
|
const text = input.message ?? `💤 Going quiet for ${human}. I'll still see messages, but won't respond until I wake.`;
|
|
7158
|
-
this.
|
|
7159
|
-
|
|
7160
|
-
|
|
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
|
+
}
|
|
7161
7935
|
}
|
|
7162
7936
|
|
|
7163
7937
|
console.error(`[sleep] agent=${agentName} seconds=${seconds} announce=${announce} until=${new Date(until).toISOString()}`);
|