@animalabs/agent-framework 0.6.5 → 0.6.6
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 +18 -1
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +201 -2
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +15 -1
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +423 -86
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +27 -4
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +34 -15
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +299 -67
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +1 -0
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +1 -0
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +3 -1
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +4 -0
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +43 -12
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js +1 -0
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/types/agent.d.ts +15 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +1 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +2 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/agent-runtime-settings.test.d.ts +2 -0
- package/dist/test/agent-runtime-settings.test.d.ts.map +1 -0
- package/dist/test/agent-runtime-settings.test.js +120 -0
- package/dist/test/agent-runtime-settings.test.js.map +1 -0
- package/dist/test/channel-registry-routing.test.js +5 -4
- package/dist/test/channel-registry-routing.test.js.map +1 -1
- package/dist/test/conversation-fork-injection-scoping.test.js +12 -1
- package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
- package/dist/test/event-gate.test.js +21 -1
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/helpers/mock-membrane.d.ts +13 -1
- package/dist/test/helpers/mock-membrane.d.ts.map +1 -1
- package/dist/test/helpers/mock-membrane.js +8 -1
- package/dist/test/helpers/mock-membrane.js.map +1 -1
- package/dist/test/maintenance-timer.test.js +3 -1
- package/dist/test/maintenance-timer.test.js.map +1 -1
- package/dist/test/mcpl-gate-roundtrip.test.js +138 -6
- package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
- package/dist/test/present-while-acting.test.d.ts +18 -0
- package/dist/test/present-while-acting.test.d.ts.map +1 -0
- package/dist/test/present-while-acting.test.js +267 -0
- package/dist/test/present-while-acting.test.js.map +1 -0
- package/dist/test/trunk-channel-routing.test.js +38 -0
- package/dist/test/trunk-channel-routing.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/agent.ts +222 -3
- package/src/framework.ts +468 -92
- package/src/gate/event-gate.ts +38 -4
- package/src/mcpl/channel-registry.ts +372 -76
- package/src/mcpl/push-handler.ts +2 -0
- package/src/mcpl/server-connection.ts +10 -0
- package/src/mcpl/types.ts +49 -12
- package/src/types/agent.ts +18 -0
- package/src/types/events.ts +1 -1
- package/src/types/trace.ts +2 -0
package/dist/src/framework.js
CHANGED
|
@@ -17,6 +17,29 @@ 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
|
+
/**
|
|
21
|
+
* Tools whose presence suppresses auto-routing of the surrounding prose,
|
|
22
|
+
* for two distinct reasons:
|
|
23
|
+
* - `skip_reply` — the agent EXPLICITLY chose not to reply (the deliberate
|
|
24
|
+
* "stay silent" signal).
|
|
25
|
+
* - explicit delivery tools (channel_publish / *send_message /
|
|
26
|
+
* *reply_message / *send_dm) — already sent the message, so routing the
|
|
27
|
+
* prose again would double-post.
|
|
28
|
+
* `think` is deliberately NOT here: it is silent *reasoning*, but prose
|
|
29
|
+
* written around a think is the agent's actual voice and must be delivered.
|
|
30
|
+
*
|
|
31
|
+
* Scope: mid-turn rounds apply this PER ROUND (a round's prose is suppressed
|
|
32
|
+
* only by a silencing tool in that same round — live delivery can't be
|
|
33
|
+
* retracted by a later round's skip_reply); the turn's TRAILING prose keeps
|
|
34
|
+
* the historical turn-wide rule (any silencing tool anywhere in the turn
|
|
35
|
+
* suppresses it), preserving the "send_message then 'sent it.' postscript"
|
|
36
|
+
* double-post guard.
|
|
37
|
+
*/
|
|
38
|
+
const SILENCING_TOOLS = new Set([
|
|
39
|
+
'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
|
|
40
|
+
]);
|
|
41
|
+
/** Strip the `server--` MCPL prefix from a tool name. */
|
|
42
|
+
const bareToolName = (n) => n.split('--').pop();
|
|
20
43
|
import { CheckpointManager } from './mcpl/checkpoint-manager.js';
|
|
21
44
|
import { isToolAllowed } from './mcpl/tool-policy.js';
|
|
22
45
|
import { EventGate } from './gate/event-gate.js';
|
|
@@ -146,6 +169,12 @@ export class AgentFramework {
|
|
|
146
169
|
processLoggingPersist;
|
|
147
170
|
processLoggingBroadcast;
|
|
148
171
|
activeStreams = new Map();
|
|
172
|
+
/** Per-agent output locus pinned for the CURRENT logical turn (see
|
|
173
|
+
* resolveTurnLocus in driveStream). Lives here — not in driveStream
|
|
174
|
+
* locals — so the pin survives a context-budget stream restart, which
|
|
175
|
+
* continues the same logical turn in a fresh driveStream. Cleared at the
|
|
176
|
+
* start of every non-restart turn. */
|
|
177
|
+
turnLocusPins = new Map();
|
|
149
178
|
pendingAssistantBlocks = new Map();
|
|
150
179
|
/** Streams the FRAMEWORK cancelled for non-terminal reasons, keyed
|
|
151
180
|
* `${agentName}:${streamId}`: an endTurn tool result or a context-budget
|
|
@@ -752,10 +781,43 @@ export class AgentFramework {
|
|
|
752
781
|
AgentFramework.EVENT_TAGS_TOOL,
|
|
753
782
|
]
|
|
754
783
|
: [];
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
784
|
+
return [
|
|
785
|
+
...moduleTools,
|
|
786
|
+
...this.mcplTools,
|
|
787
|
+
...channelTools,
|
|
788
|
+
...gateTools,
|
|
789
|
+
AgentFramework.AGENT_SETTINGS_TOOL,
|
|
790
|
+
];
|
|
791
|
+
}
|
|
792
|
+
getAgentRuntimeSettings(agentName) {
|
|
793
|
+
const agent = this.agents.get(agentName);
|
|
794
|
+
if (!agent)
|
|
795
|
+
throw new Error(`Unknown agent: ${agentName}`);
|
|
796
|
+
return agent.getRuntimeSettings();
|
|
797
|
+
}
|
|
798
|
+
updateAgentRuntimeSettings(agentName, patch) {
|
|
799
|
+
const agent = this.agents.get(agentName);
|
|
800
|
+
if (!agent)
|
|
801
|
+
throw new Error(`Unknown agent: ${agentName}`);
|
|
802
|
+
const result = agent.updateRuntimeSettings(patch);
|
|
803
|
+
this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
|
|
804
|
+
return result;
|
|
805
|
+
}
|
|
806
|
+
resetAgentRuntimeSettings(agentName, keys) {
|
|
807
|
+
const agent = this.agents.get(agentName);
|
|
808
|
+
if (!agent)
|
|
809
|
+
throw new Error(`Unknown agent: ${agentName}`);
|
|
810
|
+
const result = agent.resetRuntimeSettings(keys);
|
|
811
|
+
this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
|
|
812
|
+
return result;
|
|
813
|
+
}
|
|
814
|
+
cancelAgentRuntimeSettingsTransition(agentName) {
|
|
815
|
+
const agent = this.agents.get(agentName);
|
|
816
|
+
if (!agent)
|
|
817
|
+
throw new Error(`Unknown agent: ${agentName}`);
|
|
818
|
+
const result = agent.cancelRuntimeSettingsTransition();
|
|
819
|
+
this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
|
|
820
|
+
return result;
|
|
759
821
|
}
|
|
760
822
|
/** Counts-only context-maintenance diagnostics for authenticated debug UIs. */
|
|
761
823
|
getContextMaintenanceSnapshot() {
|
|
@@ -932,6 +994,29 @@ export class AgentFramework {
|
|
|
932
994
|
// Non-fatal — usage tracking is best-effort
|
|
933
995
|
}
|
|
934
996
|
}
|
|
997
|
+
readAgentRuntimeSettings(agentName) {
|
|
998
|
+
try {
|
|
999
|
+
const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
|
|
1000
|
+
const stored = data?.agentRuntimeSettings?.[agentName];
|
|
1001
|
+
return stored ? { ...stored } : null;
|
|
1002
|
+
}
|
|
1003
|
+
catch {
|
|
1004
|
+
return null;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
persistAgentRuntimeSettings(agentName, overrides) {
|
|
1008
|
+
const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
|
|
1009
|
+
const state = (data && typeof data === 'object' ? data : {});
|
|
1010
|
+
const all = {
|
|
1011
|
+
...(state.agentRuntimeSettings ?? {}),
|
|
1012
|
+
};
|
|
1013
|
+
if (Object.keys(overrides).length === 0)
|
|
1014
|
+
delete all[agentName];
|
|
1015
|
+
else
|
|
1016
|
+
all[agentName] = { ...overrides };
|
|
1017
|
+
state.agentRuntimeSettings = all;
|
|
1018
|
+
this.store.setStateJson(FRAMEWORK_STATE_ID, state);
|
|
1019
|
+
}
|
|
935
1020
|
/**
|
|
936
1021
|
* Create an ephemeral agent that is NOT registered in the main event loop.
|
|
937
1022
|
*
|
|
@@ -1238,6 +1323,29 @@ export class AgentFramework {
|
|
|
1238
1323
|
'tagsAll / tagsNone) or in gate.js.',
|
|
1239
1324
|
inputSchema: { type: 'object' },
|
|
1240
1325
|
};
|
|
1326
|
+
static AGENT_SETTINGS_TOOL = {
|
|
1327
|
+
name: 'agent_settings',
|
|
1328
|
+
description: 'Read or change your hot runtime settings. This intentionally exposes only ' +
|
|
1329
|
+
'context budget, recent raw tail size, and transition pace; model, prompts, ' +
|
|
1330
|
+
'folding strategy, and other restart-bound configuration are not mutable here. ' +
|
|
1331
|
+
'Lower context budgets converge gradually under the transition pace before ' +
|
|
1332
|
+
'becoming the hard live limit; increases take effect immediately.',
|
|
1333
|
+
inputSchema: {
|
|
1334
|
+
type: 'object',
|
|
1335
|
+
properties: {
|
|
1336
|
+
action: { type: 'string', enum: ['get', 'update', 'reset', 'cancel'] },
|
|
1337
|
+
context_budget_tokens: { type: 'number', description: 'Total input context budget, including the reserved response allowance.' },
|
|
1338
|
+
tail_tokens: { type: 'number', description: 'Recent raw context retained verbatim.' },
|
|
1339
|
+
transition_pace_tokens: { type: 'number', description: 'Maximum ordinary KV re-read/perturbation per compile while converging.' },
|
|
1340
|
+
settings: {
|
|
1341
|
+
type: 'array',
|
|
1342
|
+
items: { type: 'string', enum: ['context_budget_tokens', 'tail_tokens', 'transition_pace_tokens'] },
|
|
1343
|
+
description: 'For reset: settings to restore to recipe values. Omit to reset all.',
|
|
1344
|
+
},
|
|
1345
|
+
},
|
|
1346
|
+
required: ['action'],
|
|
1347
|
+
},
|
|
1348
|
+
};
|
|
1241
1349
|
/** Reserved chat:* core vocabulary (MCPL RFC-001 §4) — short descriptions so
|
|
1242
1350
|
* the agent can author rules without reading the spec. */
|
|
1243
1351
|
static CHAT_CORE_TAGS = {
|
|
@@ -2155,6 +2263,9 @@ export class AgentFramework {
|
|
|
2155
2263
|
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
2156
2264
|
});
|
|
2157
2265
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
2266
|
+
const restoredSettings = this.readAgentRuntimeSettings(config.name);
|
|
2267
|
+
if (restoredSettings)
|
|
2268
|
+
agent.restoreRuntimeSettings(restoredSettings);
|
|
2158
2269
|
this.agents.set(config.name, agent);
|
|
2159
2270
|
this.agentConfigs.set(config.name, config);
|
|
2160
2271
|
// First non-ephemeral agent becomes the primary for message routing
|
|
@@ -2247,16 +2358,49 @@ export class AgentFramework {
|
|
|
2247
2358
|
isError: tc.result.isError,
|
|
2248
2359
|
}));
|
|
2249
2360
|
agent.getContextManager().addMessage('user', toolResultContent);
|
|
2250
|
-
// Flush any messages that were deferred while
|
|
2251
|
-
// Route to the PRIMARY agent — deferred messages are
|
|
2252
|
-
// (
|
|
2361
|
+
// Flush any messages that were deferred while this turn was in
|
|
2362
|
+
// flight. Route to the PRIMARY agent — deferred messages are
|
|
2363
|
+
// framework-level (module messages, push events, subagent return
|
|
2364
|
+
// notifications) meant for the main conversation.
|
|
2365
|
+
//
|
|
2366
|
+
// Hear-while-acting: messages flushed into THIS agent's own window
|
|
2367
|
+
// are also collected for mid-turn injection — passed to the membrane
|
|
2368
|
+
// with the tool results below, so the NEXT inference round of the
|
|
2369
|
+
// live turn sees them instead of the agent staying deaf until the
|
|
2370
|
+
// turn ends. The window stores them here, right after the
|
|
2371
|
+
// tool_result message, which is exactly where the membrane appends
|
|
2372
|
+
// them in the live conversation — same participant, same content
|
|
2373
|
+
// blocks, same metadata, and compile() emits recent messages
|
|
2374
|
+
// verbatim, so the next turn's compiled prefix byte-matches the
|
|
2375
|
+
// live request (prompt-cache safe).
|
|
2376
|
+
//
|
|
2377
|
+
// The queue is drained ONLY at the target agent's own boundary:
|
|
2378
|
+
// draining at another agent's boundary would store the messages
|
|
2379
|
+
// mid-turn in the target's window without injecting them into the
|
|
2380
|
+
// target's live stream (deaf agent + window/live divergence). Left
|
|
2381
|
+
// queued, they flush at the target's own next boundary — with
|
|
2382
|
+
// injection — or in driveStream's finally when its turn ends.
|
|
2383
|
+
const midTurnInjections = [];
|
|
2253
2384
|
if (this.deferredMessages.length > 0) {
|
|
2254
|
-
const
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2385
|
+
const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent;
|
|
2386
|
+
if (target === agent) {
|
|
2387
|
+
const deferred = this.deferredMessages.splice(0);
|
|
2388
|
+
for (const msg of deferred) {
|
|
2389
|
+
target.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
|
|
2390
|
+
// Injection guards: tool blocks would corrupt the tool-cycle
|
|
2391
|
+
// structure the membrane enforces, and a message named as the
|
|
2392
|
+
// agent itself would render as an ASSISTANT turn on the wire
|
|
2393
|
+
// (an unintended prefill the model would continue). Such
|
|
2394
|
+
// messages stay window-only — visible next turn.
|
|
2395
|
+
const hasToolBlocks = msg.content.some((b) => b.type === 'tool_use' || b.type === 'tool_result');
|
|
2396
|
+
if (!hasToolBlocks && msg.participant !== agent.name) {
|
|
2397
|
+
midTurnInjections.push({
|
|
2398
|
+
participant: msg.participant,
|
|
2399
|
+
content: msg.content,
|
|
2400
|
+
...(msg.metadata ? { metadata: msg.metadata } : {}),
|
|
2401
|
+
});
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2260
2404
|
}
|
|
2261
2405
|
}
|
|
2262
2406
|
// Check if any tool result requested endTurn
|
|
@@ -2307,11 +2451,18 @@ export class AgentFramework {
|
|
|
2307
2451
|
});
|
|
2308
2452
|
}
|
|
2309
2453
|
else if (currentState.stream) {
|
|
2310
|
-
// Streaming path: convert results and resume the stream
|
|
2454
|
+
// Streaming path: convert results and resume the stream.
|
|
2455
|
+
// Mid-turn messages collected above ride along as injected user
|
|
2456
|
+
// messages (membrane ≥0.5.72) — appended after the tool_result
|
|
2457
|
+
// envelope so the next round of THIS turn hears them.
|
|
2311
2458
|
const membraneResults = currentState.toolResults.map(tc => this.toMembraneToolResult(tc.id, tc.result, maxChars));
|
|
2312
|
-
currentState.stream.provideToolResults(membraneResults);
|
|
2459
|
+
currentState.stream.provideToolResults(membraneResults, midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections } : undefined);
|
|
2313
2460
|
agent.setStreaming(currentState.stream);
|
|
2314
|
-
this.emitTrace({
|
|
2461
|
+
this.emitTrace({
|
|
2462
|
+
type: 'inference:stream_resumed',
|
|
2463
|
+
agentName: agent.name,
|
|
2464
|
+
...(midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections.length } : {}),
|
|
2465
|
+
});
|
|
2315
2466
|
}
|
|
2316
2467
|
else {
|
|
2317
2468
|
// Non-streaming fallback: schedule re-inference
|
|
@@ -2689,6 +2840,10 @@ export class AgentFramework {
|
|
|
2689
2840
|
* Convert an MCPL push event to a context message.
|
|
2690
2841
|
*/
|
|
2691
2842
|
handleMcplPushEvent(event) {
|
|
2843
|
+
const triggerChannel = this.derivePushEventChannel(event.origin);
|
|
2844
|
+
if (triggerChannel && this.channelRegistry) {
|
|
2845
|
+
this.channelRegistry.ensureChannelRegistered(event.serverId, triggerChannel.channelId, triggerChannel.label);
|
|
2846
|
+
}
|
|
2692
2847
|
const metadata = {
|
|
2693
2848
|
...event.origin,
|
|
2694
2849
|
serverId: event.serverId,
|
|
@@ -2696,17 +2851,36 @@ export class AgentFramework {
|
|
|
2696
2851
|
eventId: event.eventId,
|
|
2697
2852
|
triggered: event.triggerInference ?? false,
|
|
2698
2853
|
};
|
|
2699
|
-
const
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2854
|
+
const content = [...event.content];
|
|
2855
|
+
const addressedWhileClosed = triggerChannel &&
|
|
2856
|
+
event.tags?.includes('chat:addressed') &&
|
|
2857
|
+
this.channelRegistry &&
|
|
2858
|
+
!this.channelRegistry.isChannelOpen(triggerChannel.channelId);
|
|
2859
|
+
if (addressedWhileClosed) {
|
|
2860
|
+
const descriptor = this.channelRegistry.getDescriptor(triggerChannel.channelId);
|
|
2861
|
+
const messageId = typeof event.origin?.messageId === 'string'
|
|
2862
|
+
? event.origin.messageId
|
|
2863
|
+
: event.eventId;
|
|
2864
|
+
const maxBackscroll = descriptor?.capabilities?.history?.maxMessages ?? 0;
|
|
2865
|
+
content.push({
|
|
2866
|
+
type: 'text',
|
|
2867
|
+
text: `\n[Channel invitation] You were addressed in closed channel ` +
|
|
2868
|
+
`"${triggerChannel.label ?? triggerChannel.channelId}" (${triggerChannel.channelId}, server ${event.serverId}). ` +
|
|
2869
|
+
`You received this one message without subscribing. ` +
|
|
2870
|
+
(maxBackscroll > 0
|
|
2871
|
+
? `To subscribe, call channel_open with channelId, serverId "${event.serverId}", backscroll (0-${maxBackscroll}), and beforeMessageId "${messageId}". `
|
|
2872
|
+
: `To subscribe, call channel_open with this channelId and serverId "${event.serverId}". `) +
|
|
2873
|
+
`You may reply once without subscribing. To remain closed, call channel_decline ` +
|
|
2874
|
+
`with channelId, serverId "${event.serverId}", and messageId "${messageId}"; ` +
|
|
2875
|
+
`optionally set acknowledge to a surface value such as 👀.`,
|
|
2876
|
+
});
|
|
2877
|
+
metadata.channelInvitation = true;
|
|
2878
|
+
metadata.channelOpen = false;
|
|
2879
|
+
metadata.channelId = triggerChannel.channelId;
|
|
2880
|
+
metadata.invitationMessageId = messageId;
|
|
2709
2881
|
}
|
|
2882
|
+
const id = this.addMessage('user', content, metadata);
|
|
2883
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
|
|
2710
2884
|
if (event.triggerInference) {
|
|
2711
2885
|
// Default broadcast excludes conversation forks (channel-driven).
|
|
2712
2886
|
const targetAgents = event.targetAgents
|
|
@@ -2935,11 +3109,64 @@ export class AgentFramework {
|
|
|
2935
3109
|
const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2936
3110
|
const myStreamId = agent.streamId;
|
|
2937
3111
|
let hadToolCalls = false;
|
|
3112
|
+
// ---- Present-while-acting turn state ---------------------------------
|
|
3113
|
+
// Output locus for THIS turn: resolved lazily at the first routed prose
|
|
3114
|
+
// segment and pinned for the rest of the turn (mid-turn rounds AND the
|
|
3115
|
+
// trailing prose at 'complete'), so one turn's voice stays in one channel
|
|
3116
|
+
// even when a queued inbound from another channel overwrites the
|
|
3117
|
+
// per-agent triggering channel between segments (PR #32). The pin lives
|
|
3118
|
+
// in `turnLocusPins` so it survives a context-budget stream restart. A
|
|
3119
|
+
// null resolution is NOT pinned — a turn whose locus is unresolvable
|
|
3120
|
+
// early (e.g. heartbeat-triggered) may still resolve a channel by the
|
|
3121
|
+
// time later prose routes.
|
|
3122
|
+
if (trigger?.reason !== 'context_budget_restart') {
|
|
3123
|
+
this.turnLocusPins.delete(agent.name);
|
|
3124
|
+
}
|
|
3125
|
+
const resolveTurnLocus = () => {
|
|
3126
|
+
let locus = this.turnLocusPins.get(agent.name) ?? null;
|
|
3127
|
+
if (locus === null && this.channelRegistry) {
|
|
3128
|
+
locus = this.channelRegistry.resolveLocus(agent.name);
|
|
3129
|
+
if (locus !== null)
|
|
3130
|
+
this.turnLocusPins.set(agent.name, locus);
|
|
3131
|
+
}
|
|
3132
|
+
return locus;
|
|
3133
|
+
};
|
|
3134
|
+
// Ordered delivery chain for live-routed prose. Links are enqueued
|
|
3135
|
+
// WITHOUT awaiting in the stream-event loop — an awaited network post
|
|
3136
|
+
// here would stall consumption of the next round's events by
|
|
3137
|
+
// segments × RTT on every prose-bearing round. The 'complete' case
|
|
3138
|
+
// awaits the chain before routing trailing prose, so in-channel ordering
|
|
3139
|
+
// is preserved end-to-end. Each link catches its own error: one failed
|
|
3140
|
+
// post must not silence the rest of the turn.
|
|
3141
|
+
let turnSpeechChain = Promise.resolve();
|
|
3142
|
+
const enqueueSpeech = (text, locus) => {
|
|
3143
|
+
turnSpeechChain = turnSpeechChain
|
|
3144
|
+
.then(async () => {
|
|
3145
|
+
await this.channelRegistry.routeSpeech(agent.name, text, locus);
|
|
3146
|
+
})
|
|
3147
|
+
.catch((err) => console.error('mid-turn speech routing failed:', err));
|
|
3148
|
+
};
|
|
3149
|
+
// Sticky silencing: once any round uses a SILENCING_TOOLS member,
|
|
3150
|
+
// prose from that round ONWARD is suppressed — skip_reply means "stay
|
|
3151
|
+
// silent", an explicit send means "I deliver my own words". Earlier
|
|
3152
|
+
// rounds' prose was already delivered live and cannot be retracted.
|
|
3153
|
+
let turnSilenced = false;
|
|
3154
|
+
// Live routing is only trusted when the membrane provides verbatim
|
|
3155
|
+
// round-scoped blocks (roundContent, native tool mode, membrane ≥0.5.64).
|
|
3156
|
+
// The fallback `preamble` is CUMULATIVE in XML mode (assistant prefill +
|
|
3157
|
+
// all earlier rounds' prose and raw tool/thinking XML) — live-routing it
|
|
3158
|
+
// would repost the transcript and leak thinking every round. When no
|
|
3159
|
+
// round was live-routed, the 'complete' case falls back to the
|
|
3160
|
+
// historical whole-turn routing so fallback-mode prose is still
|
|
3161
|
+
// delivered exactly once, at turn end.
|
|
3162
|
+
let liveProseRouting = false;
|
|
2938
3163
|
// Typing indicator: show "<agent> is typing…" in the channel she's
|
|
2939
3164
|
// responding to, for the whole duration of this turn. Started here (paired
|
|
2940
3165
|
// with the finally below, so it can never leak) and refreshed on a 7s
|
|
2941
3166
|
// interval by the ChannelRegistry until stopped on any exit path.
|
|
2942
|
-
const typingChannel = this.channelRegistry
|
|
3167
|
+
const typingChannel = this.channelRegistry
|
|
3168
|
+
? trigger?.channelId ?? this.channelRegistry.getDefaultPublishChannel()
|
|
3169
|
+
: null;
|
|
2943
3170
|
if (typingChannel)
|
|
2944
3171
|
this.channelRegistry.startTyping(typingChannel);
|
|
2945
3172
|
try {
|
|
@@ -3005,6 +3232,39 @@ export class AgentFramework {
|
|
|
3005
3232
|
for (const call of event.calls) {
|
|
3006
3233
|
this.dispatchToolCall(agent.name, call);
|
|
3007
3234
|
}
|
|
3235
|
+
// Speak-while-acting: route THIS round's prose to the locus NOW
|
|
3236
|
+
// instead of batching every segment to the end of the turn. Long
|
|
3237
|
+
// tool-using turns (robot control, live sessions) otherwise dump
|
|
3238
|
+
// the whole narration in one blob when the turn finally settles —
|
|
3239
|
+
// and, worse, make "talking" and "acting" feel mutually exclusive
|
|
3240
|
+
// to the agent. Mid-turn prose is speech (delivered live, in
|
|
3241
|
+
// order, via the turn's speech chain — never awaited here); the
|
|
3242
|
+
// `think` tool remains the private channel. Silencing is STICKY
|
|
3243
|
+
// from the round it occurs (see SILENCING_TOOLS). Only rounds
|
|
3244
|
+
// with verbatim roundContent are live-routed (see liveProseRouting
|
|
3245
|
+
// note above — the fallback preamble is cumulative in XML mode).
|
|
3246
|
+
if (this.channelRegistry) {
|
|
3247
|
+
const roundToolNames = event.calls.map((c) => c.name);
|
|
3248
|
+
if (roundToolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)))) {
|
|
3249
|
+
turnSilenced = true;
|
|
3250
|
+
}
|
|
3251
|
+
if (roundContent && roundContent.length > 0) {
|
|
3252
|
+
liveProseRouting = true;
|
|
3253
|
+
const roundSegments = splitProseSegments(assistantBlocks);
|
|
3254
|
+
if (roundSegments.length > 0) {
|
|
3255
|
+
if (turnSilenced) {
|
|
3256
|
+
console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`);
|
|
3257
|
+
}
|
|
3258
|
+
else {
|
|
3259
|
+
const locus = resolveTurnLocus();
|
|
3260
|
+
console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> routing ${roundSegments.length} prose segment(s) live -> ${locus ?? '(default)'}`);
|
|
3261
|
+
for (const seg of roundSegments) {
|
|
3262
|
+
enqueueSpeech(seg, locus);
|
|
3263
|
+
}
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
}
|
|
3008
3268
|
// Stream's async iterator blocks on next() until provideToolResults() is called
|
|
3009
3269
|
break;
|
|
3010
3270
|
}
|
|
@@ -3098,10 +3358,13 @@ export class AgentFramework {
|
|
|
3098
3358
|
console.error('afterInference hook error:', error);
|
|
3099
3359
|
}
|
|
3100
3360
|
}
|
|
3101
|
-
// Separate speech from thoughts
|
|
3102
|
-
//
|
|
3103
|
-
//
|
|
3104
|
-
//
|
|
3361
|
+
// Separate speech from thoughts — for MODULE dispatch only
|
|
3362
|
+
// (dispatchSpeech / TUI rendering below). This split does NOT
|
|
3363
|
+
// decide what reaches the channel: mid-turn prose is routed live
|
|
3364
|
+
// per round (see the 'tool-calls' case) and trailing prose is
|
|
3365
|
+
// routed after this block. On a tool-using turn the module-level
|
|
3366
|
+
// convention remains "text is thoughts"; on a text-only turn all
|
|
3367
|
+
// text is speech.
|
|
3105
3368
|
const isTextBlock = (block) => block.type === 'text';
|
|
3106
3369
|
const allText = response.content.filter(isTextBlock);
|
|
3107
3370
|
const speechContent = hadToolCalls ? [] : allText;
|
|
@@ -3273,65 +3536,48 @@ export class AgentFramework {
|
|
|
3273
3536
|
}
|
|
3274
3537
|
}
|
|
3275
3538
|
else if (this.channelRegistry && hadToolCalls && allText.length > 0) {
|
|
3276
|
-
// Tool-call turn that also produced prose.
|
|
3277
|
-
//
|
|
3278
|
-
//
|
|
3279
|
-
//
|
|
3280
|
-
//
|
|
3281
|
-
//
|
|
3282
|
-
// turn
|
|
3283
|
-
//
|
|
3284
|
-
// reaches the channel.
|
|
3285
|
-
//
|
|
3286
|
-
//
|
|
3287
|
-
//
|
|
3288
|
-
//
|
|
3289
|
-
//
|
|
3290
|
-
// *reply_message / *send_dm) — already sent the message, so
|
|
3291
|
-
// routing it again would double-post.
|
|
3292
|
-
// `think` is deliberately NOT here: it is silent *reasoning*, but
|
|
3293
|
-
// prose written after a think is the agent's actual reply and must
|
|
3294
|
-
// be delivered. (A think-only turn has no trailing prose and stays
|
|
3295
|
-
// silent via the `!t` check below.)
|
|
3296
|
-
const SILENCING = new Set([
|
|
3297
|
-
'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
|
|
3298
|
-
]);
|
|
3299
|
-
const bare = (n) => (n.includes('--') ? n.split('--').pop() : n);
|
|
3539
|
+
// Tool-call turn that also produced prose. When live routing was
|
|
3540
|
+
// active (native roundContent), each mid-turn round's prose was
|
|
3541
|
+
// already delivered when that round yielded — only the TRAILING
|
|
3542
|
+
// prose (blocks after the last tool round) remains here. On the
|
|
3543
|
+
// fallback path (XML tool mode / older membrane: no roundContent,
|
|
3544
|
+
// so nothing was live-routed) the historical behavior applies:
|
|
3545
|
+
// route the whole turn's parsed segments, once, now. The global
|
|
3546
|
+
// speech/thoughts split is left untouched (module/TUI rendering
|
|
3547
|
+
// unaffected) — this only governs what reaches the channel.
|
|
3548
|
+
//
|
|
3549
|
+
// Silencing here is the sticky flag ∪ turn-wide scan: the scan
|
|
3550
|
+
// (over the whole turn's tool_use blocks) covers the fallback
|
|
3551
|
+
// path and the "send_message then a 'sent it.' postscript"
|
|
3552
|
+
// double-post guard; the sticky flag is its live-rounds twin.
|
|
3300
3553
|
const toolNames = response.content
|
|
3301
3554
|
.filter((b) => b.type === 'tool_use')
|
|
3302
3555
|
.map((b) => b.name)
|
|
3303
3556
|
.filter((n) => typeof n === 'string');
|
|
3304
|
-
const silenced = toolNames.some((n) =>
|
|
3305
|
-
|
|
3306
|
-
//
|
|
3307
|
-
//
|
|
3308
|
-
//
|
|
3309
|
-
|
|
3310
|
-
// reconstructs the emission order. Interleaved prose
|
|
3311
|
-
// ("msgA → [tool] → msgB → [tool] → msgC") is then delivered as
|
|
3312
|
-
// separate messages IN ORDER instead of being collapsed into one
|
|
3313
|
-
// trailing post (item 4). The silencing rule stays turn-wide: an
|
|
3314
|
-
// explicit send / skip suppresses ALL auto-routed prose this turn,
|
|
3315
|
-
// preserving the double-post guard.
|
|
3316
|
-
const segments = splitProseSegments(response.content);
|
|
3557
|
+
const silenced = turnSilenced || toolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)));
|
|
3558
|
+
const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content);
|
|
3559
|
+
// Preserve in-channel ordering: everything enqueued live must
|
|
3560
|
+
// land before the trailing prose. Awaited even when silenced —
|
|
3561
|
+
// the chain may still be flushing earlier rounds' posts.
|
|
3562
|
+
await turnSpeechChain;
|
|
3317
3563
|
if (silenced || segments.length === 0) {
|
|
3318
|
-
console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> NOT routed ` +
|
|
3319
|
-
`(${silenced ? 'silencing tool / explicit send' : 'no prose'})`);
|
|
3564
|
+
console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> trailing prose NOT routed ` +
|
|
3565
|
+
`(${silenced ? 'silencing tool / explicit send' : 'no trailing prose'})`);
|
|
3320
3566
|
}
|
|
3321
3567
|
else {
|
|
3322
|
-
//
|
|
3323
|
-
//
|
|
3324
|
-
//
|
|
3325
|
-
//
|
|
3326
|
-
//
|
|
3327
|
-
//
|
|
3328
|
-
//
|
|
3329
|
-
const
|
|
3330
|
-
console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} prose segment(s)
|
|
3568
|
+
// Reuse the locus pinned at the turn's first live-routed
|
|
3569
|
+
// segment (or resolve it now for a turn whose only prose is
|
|
3570
|
+
// trailing). This dispatch runs AFTER the agent is idle (see
|
|
3571
|
+
// PR #32 note below), so a queued inbound from another channel
|
|
3572
|
+
// could otherwise overwrite the per-agent triggering channel
|
|
3573
|
+
// between segments — and a turn that narrated live into one
|
|
3574
|
+
// channel must not land its postscript in another.
|
|
3575
|
+
const locus = resolveTurnLocus();
|
|
3576
|
+
console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} ${liveProseRouting ? 'trailing ' : ''}prose segment(s) -> ${locus ?? '(default)'}`);
|
|
3331
3577
|
// Deliver sequentially (await each) so the segments land in order.
|
|
3332
3578
|
for (const seg of segments) {
|
|
3333
3579
|
try {
|
|
3334
|
-
await this.channelRegistry.routeSpeech(agent.name, seg,
|
|
3580
|
+
await this.channelRegistry.routeSpeech(agent.name, seg, locus);
|
|
3335
3581
|
}
|
|
3336
3582
|
catch (err) {
|
|
3337
3583
|
console.error('speech routing failed:', err);
|
|
@@ -3729,6 +3975,11 @@ export class AgentFramework {
|
|
|
3729
3975
|
this.dispatchChannelToolCall(agentName, enrichedCall);
|
|
3730
3976
|
return;
|
|
3731
3977
|
}
|
|
3978
|
+
// Route the agent's typed, allowlisted hot-settings surface.
|
|
3979
|
+
if (enrichedCall.name === 'agent_settings') {
|
|
3980
|
+
this.dispatchAgentSettingsToolCall(agentName, enrichedCall);
|
|
3981
|
+
return;
|
|
3982
|
+
}
|
|
3732
3983
|
// Route gate_status tool
|
|
3733
3984
|
if (enrichedCall.name === 'gate_status' && this.eventGate) {
|
|
3734
3985
|
this.dispatchGateToolCall(agentName, enrichedCall);
|
|
@@ -3829,12 +4080,19 @@ export class AgentFramework {
|
|
|
3829
4080
|
if (!agent) {
|
|
3830
4081
|
throw new Error('No agents configured');
|
|
3831
4082
|
}
|
|
3832
|
-
//
|
|
3833
|
-
//
|
|
4083
|
+
// Defer non-tool_result messages while a tool cycle is mid-flight
|
|
4084
|
+
// (pendingAssistantBlocks: preserves tool_use → tool_result adjacency)
|
|
4085
|
+
// OR while the target agent has a live stream at all. A message stored
|
|
4086
|
+
// mid-stream lands BEFORE the turn's assistant blocks in the window even
|
|
4087
|
+
// though the live conversation never saw it — so the next compile
|
|
4088
|
+
// diverges from the live prefix (prompt-cache bust) and the message
|
|
4089
|
+
// misses mid-turn injection. Deferred messages flush at the agent's next
|
|
4090
|
+
// tool boundary (where they are ALSO injected into the live stream —
|
|
4091
|
+
// hear-while-acting) or in driveStream's finally when the turn ends.
|
|
3834
4092
|
const hasToolResult = content.some(b => b.type === 'tool_result');
|
|
3835
|
-
if (!hasToolResult && this.pendingAssistantBlocks.size > 0) {
|
|
4093
|
+
if (!hasToolResult && (this.pendingAssistantBlocks.size > 0 || this.activeStreams.has(agent.name))) {
|
|
3836
4094
|
this.deferredMessages.push({ participant, content, metadata });
|
|
3837
|
-
return ''; // Deferred —
|
|
4095
|
+
return ''; // Deferred — flushed at the next boundary
|
|
3838
4096
|
}
|
|
3839
4097
|
return agent.getContextManager().addMessage(participant, content, metadata);
|
|
3840
4098
|
}
|
|
@@ -4222,6 +4480,7 @@ export class AgentFramework {
|
|
|
4222
4480
|
this.checkpointManager = new CheckpointManager(this.store, (event) => this.emitTrace(event));
|
|
4223
4481
|
// Channel registry (Step 7)
|
|
4224
4482
|
this.channelRegistry = new ChannelRegistry(this.mcplServerRegistry, this.featureSetManager, (event) => this.pushEvent(event), (event) => this.emitTrace(event), {
|
|
4483
|
+
store: this.store,
|
|
4225
4484
|
sendTypingFn: (serverId, channelId, metadata, op) => {
|
|
4226
4485
|
const server = this.mcplServerRegistry.getServer(serverId);
|
|
4227
4486
|
if (server) {
|
|
@@ -4314,8 +4573,8 @@ export class AgentFramework {
|
|
|
4314
4573
|
this.mcplServerConfigs.set(config.id, config);
|
|
4315
4574
|
// Record per-server channel subscription policy before the server
|
|
4316
4575
|
// registers channels — handleRegister fires during the handshake.
|
|
4317
|
-
if (
|
|
4318
|
-
this.channelRegistry.setSubscriptionPolicy(config.id, config.channelSubscription);
|
|
4576
|
+
if (this.channelRegistry) {
|
|
4577
|
+
this.channelRegistry.setSubscriptionPolicy(config.id, config.channelSubscription ?? 'manual');
|
|
4319
4578
|
}
|
|
4320
4579
|
const connection = await this.mcplServerRegistry.addServer(config, this.mcplHostCapabilities);
|
|
4321
4580
|
// Wire event listeners then flush any events that arrived during the
|
|
@@ -5021,6 +5280,84 @@ export class AgentFramework {
|
|
|
5021
5280
|
endTurn: true,
|
|
5022
5281
|
});
|
|
5023
5282
|
}
|
|
5283
|
+
dispatchAgentSettingsToolCall(agentName, call) {
|
|
5284
|
+
this.emitTrace({
|
|
5285
|
+
type: 'tool:started',
|
|
5286
|
+
module: 'agent',
|
|
5287
|
+
tool: call.name,
|
|
5288
|
+
callId: call.id,
|
|
5289
|
+
input: call.input,
|
|
5290
|
+
});
|
|
5291
|
+
let result;
|
|
5292
|
+
try {
|
|
5293
|
+
const input = (call.input ?? {});
|
|
5294
|
+
switch (input.action) {
|
|
5295
|
+
case 'get':
|
|
5296
|
+
result = { success: true, data: this.getAgentRuntimeSettings(agentName) };
|
|
5297
|
+
break;
|
|
5298
|
+
case 'cancel':
|
|
5299
|
+
result = { success: true, data: this.cancelAgentRuntimeSettingsTransition(agentName) };
|
|
5300
|
+
break;
|
|
5301
|
+
case 'update': {
|
|
5302
|
+
const patch = {};
|
|
5303
|
+
if (input.context_budget_tokens !== undefined) {
|
|
5304
|
+
patch.contextBudgetTokens = Number(input.context_budget_tokens);
|
|
5305
|
+
}
|
|
5306
|
+
if (input.tail_tokens !== undefined)
|
|
5307
|
+
patch.tailTokens = Number(input.tail_tokens);
|
|
5308
|
+
if (input.transition_pace_tokens !== undefined) {
|
|
5309
|
+
patch.transitionPaceTokens = Number(input.transition_pace_tokens);
|
|
5310
|
+
}
|
|
5311
|
+
result = { success: true, data: this.updateAgentRuntimeSettings(agentName, patch) };
|
|
5312
|
+
break;
|
|
5313
|
+
}
|
|
5314
|
+
case 'reset': {
|
|
5315
|
+
let keys;
|
|
5316
|
+
if (input.settings !== undefined) {
|
|
5317
|
+
if (!Array.isArray(input.settings))
|
|
5318
|
+
throw new Error('reset `settings` must be an array');
|
|
5319
|
+
const names = {
|
|
5320
|
+
context_budget_tokens: 'contextBudgetTokens',
|
|
5321
|
+
tail_tokens: 'tailTokens',
|
|
5322
|
+
transition_pace_tokens: 'transitionPaceTokens',
|
|
5323
|
+
};
|
|
5324
|
+
keys = input.settings.map((name) => {
|
|
5325
|
+
if (typeof name !== 'string' || !names[name]) {
|
|
5326
|
+
throw new Error(`Unknown reset setting: ${String(name)}`);
|
|
5327
|
+
}
|
|
5328
|
+
return names[name];
|
|
5329
|
+
});
|
|
5330
|
+
}
|
|
5331
|
+
result = { success: true, data: this.resetAgentRuntimeSettings(agentName, keys) };
|
|
5332
|
+
break;
|
|
5333
|
+
}
|
|
5334
|
+
default:
|
|
5335
|
+
throw new Error('agent_settings: action must be get, update, reset, or cancel');
|
|
5336
|
+
}
|
|
5337
|
+
}
|
|
5338
|
+
catch (error) {
|
|
5339
|
+
result = {
|
|
5340
|
+
success: false,
|
|
5341
|
+
error: error instanceof Error ? error.message : String(error),
|
|
5342
|
+
isError: true,
|
|
5343
|
+
};
|
|
5344
|
+
}
|
|
5345
|
+
this.emitTrace({
|
|
5346
|
+
type: result.isError ? 'tool:failed' : 'tool:completed',
|
|
5347
|
+
module: 'agent',
|
|
5348
|
+
tool: call.name,
|
|
5349
|
+
callId: call.id,
|
|
5350
|
+
durationMs: 0,
|
|
5351
|
+
...(result.isError ? { error: result.error } : {}),
|
|
5352
|
+
});
|
|
5353
|
+
this.pushEvent({
|
|
5354
|
+
type: 'tool-result',
|
|
5355
|
+
callId: call.id,
|
|
5356
|
+
agentName,
|
|
5357
|
+
moduleName: 'agent',
|
|
5358
|
+
result,
|
|
5359
|
+
});
|
|
5360
|
+
}
|
|
5024
5361
|
/** Aggregate the event-tag vocabulary: reserved chat:* core + each connected
|
|
5025
5362
|
* server's declared tag ontology + gate.js status. */
|
|
5026
5363
|
buildEventTagsResult() {
|