@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.
Files changed (69) hide show
  1. package/dist/src/agent.d.ts +18 -1
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +201 -2
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +15 -1
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +423 -86
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/gate/event-gate.d.ts.map +1 -1
  10. package/dist/src/gate/event-gate.js +27 -4
  11. package/dist/src/gate/event-gate.js.map +1 -1
  12. package/dist/src/mcpl/channel-registry.d.ts +34 -15
  13. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  14. package/dist/src/mcpl/channel-registry.js +299 -67
  15. package/dist/src/mcpl/channel-registry.js.map +1 -1
  16. package/dist/src/mcpl/push-handler.d.ts +1 -0
  17. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  18. package/dist/src/mcpl/push-handler.js +1 -0
  19. package/dist/src/mcpl/push-handler.js.map +1 -1
  20. package/dist/src/mcpl/server-connection.d.ts +3 -1
  21. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  22. package/dist/src/mcpl/server-connection.js +4 -0
  23. package/dist/src/mcpl/server-connection.js.map +1 -1
  24. package/dist/src/mcpl/types.d.ts +43 -12
  25. package/dist/src/mcpl/types.d.ts.map +1 -1
  26. package/dist/src/mcpl/types.js +1 -0
  27. package/dist/src/mcpl/types.js.map +1 -1
  28. package/dist/src/types/agent.d.ts +15 -0
  29. package/dist/src/types/agent.d.ts.map +1 -1
  30. package/dist/src/types/events.d.ts +1 -0
  31. package/dist/src/types/events.d.ts.map +1 -1
  32. package/dist/src/types/trace.d.ts +2 -0
  33. package/dist/src/types/trace.d.ts.map +1 -1
  34. package/dist/test/agent-runtime-settings.test.d.ts +2 -0
  35. package/dist/test/agent-runtime-settings.test.d.ts.map +1 -0
  36. package/dist/test/agent-runtime-settings.test.js +120 -0
  37. package/dist/test/agent-runtime-settings.test.js.map +1 -0
  38. package/dist/test/channel-registry-routing.test.js +5 -4
  39. package/dist/test/channel-registry-routing.test.js.map +1 -1
  40. package/dist/test/conversation-fork-injection-scoping.test.js +12 -1
  41. package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
  42. package/dist/test/event-gate.test.js +21 -1
  43. package/dist/test/event-gate.test.js.map +1 -1
  44. package/dist/test/helpers/mock-membrane.d.ts +13 -1
  45. package/dist/test/helpers/mock-membrane.d.ts.map +1 -1
  46. package/dist/test/helpers/mock-membrane.js +8 -1
  47. package/dist/test/helpers/mock-membrane.js.map +1 -1
  48. package/dist/test/maintenance-timer.test.js +3 -1
  49. package/dist/test/maintenance-timer.test.js.map +1 -1
  50. package/dist/test/mcpl-gate-roundtrip.test.js +138 -6
  51. package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
  52. package/dist/test/present-while-acting.test.d.ts +18 -0
  53. package/dist/test/present-while-acting.test.d.ts.map +1 -0
  54. package/dist/test/present-while-acting.test.js +267 -0
  55. package/dist/test/present-while-acting.test.js.map +1 -0
  56. package/dist/test/trunk-channel-routing.test.js +38 -0
  57. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  58. package/dist/tsconfig.tsbuildinfo +1 -1
  59. package/package.json +3 -3
  60. package/src/agent.ts +222 -3
  61. package/src/framework.ts +468 -92
  62. package/src/gate/event-gate.ts +38 -4
  63. package/src/mcpl/channel-registry.ts +372 -76
  64. package/src/mcpl/push-handler.ts +2 -0
  65. package/src/mcpl/server-connection.ts +10 -0
  66. package/src/mcpl/types.ts +49 -12
  67. package/src/types/agent.ts +18 -0
  68. package/src/types/events.ts +1 -1
  69. package/src/types/trace.ts +2 -0
@@ -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
- if (this.mcplTools.length === 0 && channelTools.length === 0 && gateTools.length === 0) {
756
- return moduleTools;
757
- }
758
- return [...moduleTools, ...this.mcplTools, ...channelTools, ...gateTools];
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 waiting for tool results.
2251
- // Route to the PRIMARY agent — deferred messages are framework-level
2252
- // (e.g. subagent return notifications) meant for the main conversation.
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 deferred = this.deferredMessages.splice(0);
2255
- const primary = this.primaryAgentName
2256
- ? this.agents.get(this.primaryAgentName)
2257
- : agent;
2258
- for (const msg of deferred) {
2259
- (primary ?? agent).getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
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({ type: 'inference:stream_resumed', agentName: agent.name });
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 id = this.addMessage('user', event.content, metadata);
2700
- this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
2701
- // Some push events carry a channel of origin (Discord DMs arrive here, not
2702
- // via channels/incoming, because discord-mcpl forwards them with the channel
2703
- // closed). Register + open that channel so routeSpeech can resolve it, and
2704
- // thread it onto the wake request so the reply routes back to it instead of
2705
- // the global locus (item-3 redux, DM sub-case).
2706
- const triggerChannel = this.derivePushEventChannel(event.origin);
2707
- if (triggerChannel && this.channelRegistry) {
2708
- this.channelRegistry.ensureChannelRegistered(event.serverId, triggerChannel.channelId, triggerChannel.label);
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?.getDefaultPublishChannel();
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
- // When tools were used, ALL text is thoughts (the tools themselves are
3103
- // the agent's actions — surrounding text is just reasoning).
3104
- // When no tools were used, all text is speech.
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. Per design, route the
3277
- // prose to the locus as a reply UNLESS the turn used an explicit
3278
- // send/publish tool (channel_publish, *--send_message,
3279
- // *--reply_message, *--send_dm) — already delivered, so routing
3280
- // again would double-post. `think` and other non-sending tools
3281
- // (shell, workspace, etc.) still let the agent speak in the same
3282
- // turn. The global speech/thoughts split is left untouched
3283
- // (module/TUI rendering unaffected) — this only governs what
3284
- // reaches the channel.
3285
- // Tools whose presence suppresses the trailing prose, for two
3286
- // distinct reasons:
3287
- // - `skip_reply` — the agent EXPLICITLY chose not to reply this
3288
- // turn (the deliberate "stay silent" signal).
3289
- // - explicit delivery tools (channel_publish / *send_message /
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) => SILENCING.has(bare(n)));
3305
- // Split the turn's content into ordered prose segments, broken at
3306
- // each tool_use / tool_result boundary. `response.content` holds the
3307
- // WHOLE turn's blocks in provider order — earlier tool rounds
3308
- // included (see the trailing-slice note above, which slices exactly
3309
- // because the full turn is present) — so a left-to-right walk
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
- // Snapshot the locus ONCE. This loop dispatches AFTER the agent
3323
- // is idle (see PR #32 note below), so a queued inbound from
3324
- // another channel can start a NEW turn and overwrite the per-agent
3325
- // triggering channel (`activeTriggerChannels`) BETWEEN segments —
3326
- // splitting one turn's reply across channels (the residual
3327
- // multi-segment race left by 83dedf8's per-agent locus). Pin every
3328
- // segment of THIS turn to the channel resolved right now.
3329
- const turnLocus = this.channelRegistry.resolveLocus(agent.name);
3330
- console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} prose segment(s) in order -> ${turnLocus ?? '(default)'}`);
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, turnLocus);
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
- // If any agent has pending assistant blocks (tool_use not yet flushed),
3833
- // defer non-tool_result messages to preserve tool_use → tool_result adjacency.
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 — will be added after tool_result flush
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 (config.channelSubscription !== undefined && this.channelRegistry) {
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() {