@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
package/src/framework.ts CHANGED
@@ -44,6 +44,9 @@ import type {
44
44
  ContextMaintenanceRun,
45
45
  ContextMaintenanceAgentRun,
46
46
  ContextMaintenanceSnapshot,
47
+ AgentRuntimeSettingsPatch,
48
+ AgentRuntimeSettingsOverrides,
49
+ AgentRuntimeSettingsSnapshot,
47
50
  } from './types/index.js';
48
51
  import { ProcessQueueImpl } from './queue.js';
49
52
  import { Agent } from './agent.js';
@@ -59,6 +62,30 @@ import { ConversationRouter } from './mcpl/conversation-router.js';
59
62
  import { safeSlice } from './safe-slice.js';
60
63
  import { toolResultDataToHistoryString } from './tool-result-history.js';
61
64
  import { splitProseSegments } from './prose-segments.js';
65
+
66
+ /**
67
+ * Tools whose presence suppresses auto-routing of the surrounding prose,
68
+ * for two distinct reasons:
69
+ * - `skip_reply` — the agent EXPLICITLY chose not to reply (the deliberate
70
+ * "stay silent" signal).
71
+ * - explicit delivery tools (channel_publish / *send_message /
72
+ * *reply_message / *send_dm) — already sent the message, so routing the
73
+ * prose again would double-post.
74
+ * `think` is deliberately NOT here: it is silent *reasoning*, but prose
75
+ * written around a think is the agent's actual voice and must be delivered.
76
+ *
77
+ * Scope: mid-turn rounds apply this PER ROUND (a round's prose is suppressed
78
+ * only by a silencing tool in that same round — live delivery can't be
79
+ * retracted by a later round's skip_reply); the turn's TRAILING prose keeps
80
+ * the historical turn-wide rule (any silencing tool anywhere in the turn
81
+ * suppresses it), preserving the "send_message then 'sent it.' postscript"
82
+ * double-post guard.
83
+ */
84
+ const SILENCING_TOOLS = new Set([
85
+ 'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
86
+ ]);
87
+ /** Strip the `server--` MCPL prefix from a tool name. */
88
+ const bareToolName = (n: string): string => n.split('--').pop()!;
62
89
  import { CheckpointManager } from './mcpl/checkpoint-manager.js';
63
90
  import { isToolAllowed } from './mcpl/tool-policy.js';
64
91
  import { EventGate } from './gate/event-gate.js';
@@ -292,6 +319,13 @@ export class AgentFramework {
292
319
  private processLoggingPersist: boolean;
293
320
  private processLoggingBroadcast: boolean;
294
321
  private activeStreams: Map<string, Promise<void>> = new Map();
322
+
323
+ /** Per-agent output locus pinned for the CURRENT logical turn (see
324
+ * resolveTurnLocus in driveStream). Lives here — not in driveStream
325
+ * locals — so the pin survives a context-budget stream restart, which
326
+ * continues the same logical turn in a fresh driveStream. Cleared at the
327
+ * start of every non-restart turn. */
328
+ private turnLocusPins: Map<string, string> = new Map();
295
329
  private pendingAssistantBlocks: Map<string, ContentBlock[]> = new Map();
296
330
  /** Streams the FRAMEWORK cancelled for non-terminal reasons, keyed
297
331
  * `${agentName}:${streamId}`: an endTurn tool result or a context-budget
@@ -989,10 +1023,49 @@ export class AgentFramework {
989
1023
  AgentFramework.EVENT_TAGS_TOOL,
990
1024
  ]
991
1025
  : [];
992
- if (this.mcplTools.length === 0 && channelTools.length === 0 && gateTools.length === 0) {
993
- return moduleTools;
994
- }
995
- return [...moduleTools, ...this.mcplTools, ...channelTools, ...gateTools];
1026
+ return [
1027
+ ...moduleTools,
1028
+ ...this.mcplTools,
1029
+ ...channelTools,
1030
+ ...gateTools,
1031
+ AgentFramework.AGENT_SETTINGS_TOOL,
1032
+ ];
1033
+ }
1034
+
1035
+ getAgentRuntimeSettings(agentName: string): AgentRuntimeSettingsSnapshot {
1036
+ const agent = this.agents.get(agentName);
1037
+ if (!agent) throw new Error(`Unknown agent: ${agentName}`);
1038
+ return agent.getRuntimeSettings();
1039
+ }
1040
+
1041
+ updateAgentRuntimeSettings(
1042
+ agentName: string,
1043
+ patch: AgentRuntimeSettingsPatch,
1044
+ ): AgentRuntimeSettingsSnapshot {
1045
+ const agent = this.agents.get(agentName);
1046
+ if (!agent) throw new Error(`Unknown agent: ${agentName}`);
1047
+ const result = agent.updateRuntimeSettings(patch);
1048
+ this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
1049
+ return result;
1050
+ }
1051
+
1052
+ resetAgentRuntimeSettings(
1053
+ agentName: string,
1054
+ keys?: Array<keyof AgentRuntimeSettingsPatch>,
1055
+ ): AgentRuntimeSettingsSnapshot {
1056
+ const agent = this.agents.get(agentName);
1057
+ if (!agent) throw new Error(`Unknown agent: ${agentName}`);
1058
+ const result = agent.resetRuntimeSettings(keys);
1059
+ this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
1060
+ return result;
1061
+ }
1062
+
1063
+ cancelAgentRuntimeSettingsTransition(agentName: string): AgentRuntimeSettingsSnapshot {
1064
+ const agent = this.agents.get(agentName);
1065
+ if (!agent) throw new Error(`Unknown agent: ${agentName}`);
1066
+ const result = agent.cancelRuntimeSettingsTransition();
1067
+ this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
1068
+ return result;
996
1069
  }
997
1070
 
998
1071
  /** Counts-only context-maintenance diagnostics for authenticated debug UIs. */
@@ -1194,6 +1267,33 @@ export class AgentFramework {
1194
1267
  }
1195
1268
  }
1196
1269
 
1270
+ private readAgentRuntimeSettings(agentName: string): AgentRuntimeSettingsOverrides | null {
1271
+ try {
1272
+ const data = this.store.getStateJson(FRAMEWORK_STATE_ID) as {
1273
+ agentRuntimeSettings?: Record<string, AgentRuntimeSettingsOverrides>;
1274
+ } | null;
1275
+ const stored = data?.agentRuntimeSettings?.[agentName];
1276
+ return stored ? { ...stored } : null;
1277
+ } catch {
1278
+ return null;
1279
+ }
1280
+ }
1281
+
1282
+ private persistAgentRuntimeSettings(
1283
+ agentName: string,
1284
+ overrides: AgentRuntimeSettingsOverrides,
1285
+ ): void {
1286
+ const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
1287
+ const state = (data && typeof data === 'object' ? data : {}) as Record<string, unknown>;
1288
+ const all = {
1289
+ ...((state.agentRuntimeSettings as Record<string, AgentRuntimeSettingsOverrides> | undefined) ?? {}),
1290
+ };
1291
+ if (Object.keys(overrides).length === 0) delete all[agentName];
1292
+ else all[agentName] = { ...overrides };
1293
+ state.agentRuntimeSettings = all;
1294
+ this.store.setStateJson(FRAMEWORK_STATE_ID, state);
1295
+ }
1296
+
1197
1297
  /**
1198
1298
  * Create an ephemeral agent that is NOT registered in the main event loop.
1199
1299
  *
@@ -1533,6 +1633,31 @@ export class AgentFramework {
1533
1633
  inputSchema: { type: 'object' },
1534
1634
  };
1535
1635
 
1636
+ private static readonly AGENT_SETTINGS_TOOL: import('./types/index.js').ToolDefinition = {
1637
+ name: 'agent_settings',
1638
+ description:
1639
+ 'Read or change your hot runtime settings. This intentionally exposes only ' +
1640
+ 'context budget, recent raw tail size, and transition pace; model, prompts, ' +
1641
+ 'folding strategy, and other restart-bound configuration are not mutable here. ' +
1642
+ 'Lower context budgets converge gradually under the transition pace before ' +
1643
+ 'becoming the hard live limit; increases take effect immediately.',
1644
+ inputSchema: {
1645
+ type: 'object',
1646
+ properties: {
1647
+ action: { type: 'string', enum: ['get', 'update', 'reset', 'cancel'] },
1648
+ context_budget_tokens: { type: 'number', description: 'Total input context budget, including the reserved response allowance.' },
1649
+ tail_tokens: { type: 'number', description: 'Recent raw context retained verbatim.' },
1650
+ transition_pace_tokens: { type: 'number', description: 'Maximum ordinary KV re-read/perturbation per compile while converging.' },
1651
+ settings: {
1652
+ type: 'array',
1653
+ items: { type: 'string', enum: ['context_budget_tokens', 'tail_tokens', 'transition_pace_tokens'] },
1654
+ description: 'For reset: settings to restore to recipe values. Omit to reset all.',
1655
+ },
1656
+ },
1657
+ required: ['action'],
1658
+ },
1659
+ };
1660
+
1536
1661
  /** Reserved chat:* core vocabulary (MCPL RFC-001 §4) — short descriptions so
1537
1662
  * the agent can author rules without reading the spec. */
1538
1663
  private static readonly CHAT_CORE_TAGS: Record<string, string> = {
@@ -2601,6 +2726,8 @@ export class AgentFramework {
2601
2726
  });
2602
2727
 
2603
2728
  const agent = new Agent(config, contextManager, this.membrane);
2729
+ const restoredSettings = this.readAgentRuntimeSettings(config.name);
2730
+ if (restoredSettings) agent.restoreRuntimeSettings(restoredSettings);
2604
2731
  this.agents.set(config.name, agent);
2605
2732
  this.agentConfigs.set(config.name, config);
2606
2733
 
@@ -2708,16 +2835,51 @@ export class AgentFramework {
2708
2835
  }));
2709
2836
  agent.getContextManager().addMessage('user', toolResultContent);
2710
2837
 
2711
- // Flush any messages that were deferred while waiting for tool results.
2712
- // Route to the PRIMARY agent — deferred messages are framework-level
2713
- // (e.g. subagent return notifications) meant for the main conversation.
2838
+ // Flush any messages that were deferred while this turn was in
2839
+ // flight. Route to the PRIMARY agent — deferred messages are
2840
+ // framework-level (module messages, push events, subagent return
2841
+ // notifications) meant for the main conversation.
2842
+ //
2843
+ // Hear-while-acting: messages flushed into THIS agent's own window
2844
+ // are also collected for mid-turn injection — passed to the membrane
2845
+ // with the tool results below, so the NEXT inference round of the
2846
+ // live turn sees them instead of the agent staying deaf until the
2847
+ // turn ends. The window stores them here, right after the
2848
+ // tool_result message, which is exactly where the membrane appends
2849
+ // them in the live conversation — same participant, same content
2850
+ // blocks, same metadata, and compile() emits recent messages
2851
+ // verbatim, so the next turn's compiled prefix byte-matches the
2852
+ // live request (prompt-cache safe).
2853
+ //
2854
+ // The queue is drained ONLY at the target agent's own boundary:
2855
+ // draining at another agent's boundary would store the messages
2856
+ // mid-turn in the target's window without injecting them into the
2857
+ // target's live stream (deaf agent + window/live divergence). Left
2858
+ // queued, they flush at the target's own next boundary — with
2859
+ // injection — or in driveStream's finally when its turn ends.
2860
+ const midTurnInjections: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata }> = [];
2714
2861
  if (this.deferredMessages.length > 0) {
2715
- const deferred = this.deferredMessages.splice(0);
2716
- const primary = this.primaryAgentName
2717
- ? this.agents.get(this.primaryAgentName)
2718
- : agent;
2719
- for (const msg of deferred) {
2720
- (primary ?? agent).getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
2862
+ const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent;
2863
+ if (target === agent) {
2864
+ const deferred = this.deferredMessages.splice(0);
2865
+ for (const msg of deferred) {
2866
+ target.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
2867
+ // Injection guards: tool blocks would corrupt the tool-cycle
2868
+ // structure the membrane enforces, and a message named as the
2869
+ // agent itself would render as an ASSISTANT turn on the wire
2870
+ // (an unintended prefill the model would continue). Such
2871
+ // messages stay window-only — visible next turn.
2872
+ const hasToolBlocks = msg.content.some(
2873
+ (b) => b.type === 'tool_use' || b.type === 'tool_result'
2874
+ );
2875
+ if (!hasToolBlocks && msg.participant !== agent.name) {
2876
+ midTurnInjections.push({
2877
+ participant: msg.participant,
2878
+ content: msg.content,
2879
+ ...(msg.metadata ? { metadata: msg.metadata } : {}),
2880
+ });
2881
+ }
2882
+ }
2721
2883
  }
2722
2884
  }
2723
2885
 
@@ -2769,13 +2931,23 @@ export class AgentFramework {
2769
2931
  timestamp: Date.now(),
2770
2932
  });
2771
2933
  } else if (currentState.stream) {
2772
- // Streaming path: convert results and resume the stream
2934
+ // Streaming path: convert results and resume the stream.
2935
+ // Mid-turn messages collected above ride along as injected user
2936
+ // messages (membrane ≥0.5.72) — appended after the tool_result
2937
+ // envelope so the next round of THIS turn hears them.
2773
2938
  const membraneResults = currentState.toolResults.map(tc =>
2774
2939
  this.toMembraneToolResult(tc.id, tc.result, maxChars)
2775
2940
  );
2776
- currentState.stream.provideToolResults(membraneResults);
2941
+ currentState.stream.provideToolResults(
2942
+ membraneResults,
2943
+ midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections } : undefined,
2944
+ );
2777
2945
  agent.setStreaming(currentState.stream);
2778
- this.emitTrace({ type: 'inference:stream_resumed', agentName: agent.name });
2946
+ this.emitTrace({
2947
+ type: 'inference:stream_resumed',
2948
+ agentName: agent.name,
2949
+ ...(midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections.length } : {}),
2950
+ });
2779
2951
  } else {
2780
2952
  // Non-streaming fallback: schedule re-inference
2781
2953
  this.pendingRequests.push({
@@ -3229,6 +3401,15 @@ export class AgentFramework {
3229
3401
  * Convert an MCPL push event to a context message.
3230
3402
  */
3231
3403
  private handleMcplPushEvent(event: McplPushEvent): void {
3404
+ const triggerChannel = this.derivePushEventChannel(event.origin);
3405
+ if (triggerChannel && this.channelRegistry) {
3406
+ this.channelRegistry.ensureChannelRegistered(
3407
+ event.serverId,
3408
+ triggerChannel.channelId,
3409
+ triggerChannel.label,
3410
+ );
3411
+ }
3412
+
3232
3413
  const metadata: Record<string, unknown> = {
3233
3414
  ...event.origin,
3234
3415
  serverId: event.serverId,
@@ -3237,23 +3418,39 @@ export class AgentFramework {
3237
3418
  triggered: event.triggerInference ?? false,
3238
3419
  };
3239
3420
 
3240
- const id = this.addMessage('user', event.content, metadata);
3241
- this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
3242
-
3243
- // Some push events carry a channel of origin (Discord DMs arrive here, not
3244
- // via channels/incoming, because discord-mcpl forwards them with the channel
3245
- // closed). Register + open that channel so routeSpeech can resolve it, and
3246
- // thread it onto the wake request so the reply routes back to it instead of
3247
- // the global locus (item-3 redux, DM sub-case).
3248
- const triggerChannel = this.derivePushEventChannel(event.origin);
3249
- if (triggerChannel && this.channelRegistry) {
3250
- this.channelRegistry.ensureChannelRegistered(
3251
- event.serverId,
3252
- triggerChannel.channelId,
3253
- triggerChannel.label,
3254
- );
3421
+ const content = [...event.content];
3422
+ const addressedWhileClosed = triggerChannel &&
3423
+ event.tags?.includes('chat:addressed') &&
3424
+ this.channelRegistry &&
3425
+ !this.channelRegistry.isChannelOpen(triggerChannel.channelId);
3426
+ if (addressedWhileClosed) {
3427
+ const descriptor = this.channelRegistry!.getDescriptor(triggerChannel.channelId);
3428
+ const messageId = typeof event.origin?.messageId === 'string'
3429
+ ? event.origin.messageId
3430
+ : event.eventId;
3431
+ const maxBackscroll = descriptor?.capabilities?.history?.maxMessages ?? 0;
3432
+ content.push({
3433
+ type: 'text',
3434
+ text:
3435
+ `\n[Channel invitation] You were addressed in closed channel ` +
3436
+ `"${triggerChannel.label ?? triggerChannel.channelId}" (${triggerChannel.channelId}, server ${event.serverId}). ` +
3437
+ `You received this one message without subscribing. ` +
3438
+ (maxBackscroll > 0
3439
+ ? `To subscribe, call channel_open with channelId, serverId "${event.serverId}", backscroll (0-${maxBackscroll}), and beforeMessageId "${messageId}". `
3440
+ : `To subscribe, call channel_open with this channelId and serverId "${event.serverId}". `) +
3441
+ `You may reply once without subscribing. To remain closed, call channel_decline ` +
3442
+ `with channelId, serverId "${event.serverId}", and messageId "${messageId}"; ` +
3443
+ `optionally set acknowledge to a surface value such as 👀.`,
3444
+ });
3445
+ metadata.channelInvitation = true;
3446
+ metadata.channelOpen = false;
3447
+ metadata.channelId = triggerChannel.channelId;
3448
+ metadata.invitationMessageId = messageId;
3255
3449
  }
3256
3450
 
3451
+ const id = this.addMessage('user', content, metadata);
3452
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
3453
+
3257
3454
  if (event.triggerInference) {
3258
3455
  // Default broadcast excludes conversation forks (channel-driven).
3259
3456
  const targetAgents = event.targetAgents
@@ -3522,11 +3719,67 @@ export class AgentFramework {
3522
3719
  const myStreamId = agent.streamId;
3523
3720
  let hadToolCalls = false;
3524
3721
 
3722
+ // ---- Present-while-acting turn state ---------------------------------
3723
+ // Output locus for THIS turn: resolved lazily at the first routed prose
3724
+ // segment and pinned for the rest of the turn (mid-turn rounds AND the
3725
+ // trailing prose at 'complete'), so one turn's voice stays in one channel
3726
+ // even when a queued inbound from another channel overwrites the
3727
+ // per-agent triggering channel between segments (PR #32). The pin lives
3728
+ // in `turnLocusPins` so it survives a context-budget stream restart. A
3729
+ // null resolution is NOT pinned — a turn whose locus is unresolvable
3730
+ // early (e.g. heartbeat-triggered) may still resolve a channel by the
3731
+ // time later prose routes.
3732
+ if (trigger?.reason !== 'context_budget_restart') {
3733
+ this.turnLocusPins.delete(agent.name);
3734
+ }
3735
+ const resolveTurnLocus = (): string | null => {
3736
+ let locus = this.turnLocusPins.get(agent.name) ?? null;
3737
+ if (locus === null && this.channelRegistry) {
3738
+ locus = this.channelRegistry.resolveLocus(agent.name);
3739
+ if (locus !== null) this.turnLocusPins.set(agent.name, locus);
3740
+ }
3741
+ return locus;
3742
+ };
3743
+
3744
+ // Ordered delivery chain for live-routed prose. Links are enqueued
3745
+ // WITHOUT awaiting in the stream-event loop — an awaited network post
3746
+ // here would stall consumption of the next round's events by
3747
+ // segments × RTT on every prose-bearing round. The 'complete' case
3748
+ // awaits the chain before routing trailing prose, so in-channel ordering
3749
+ // is preserved end-to-end. Each link catches its own error: one failed
3750
+ // post must not silence the rest of the turn.
3751
+ let turnSpeechChain: Promise<void> = Promise.resolve();
3752
+ const enqueueSpeech = (text: string, locus: string | null): void => {
3753
+ turnSpeechChain = turnSpeechChain
3754
+ .then(async () => {
3755
+ await this.channelRegistry!.routeSpeech(agent.name, text, locus);
3756
+ })
3757
+ .catch((err) => console.error('mid-turn speech routing failed:', err));
3758
+ };
3759
+
3760
+ // Sticky silencing: once any round uses a SILENCING_TOOLS member,
3761
+ // prose from that round ONWARD is suppressed — skip_reply means "stay
3762
+ // silent", an explicit send means "I deliver my own words". Earlier
3763
+ // rounds' prose was already delivered live and cannot be retracted.
3764
+ let turnSilenced = false;
3765
+
3766
+ // Live routing is only trusted when the membrane provides verbatim
3767
+ // round-scoped blocks (roundContent, native tool mode, membrane ≥0.5.64).
3768
+ // The fallback `preamble` is CUMULATIVE in XML mode (assistant prefill +
3769
+ // all earlier rounds' prose and raw tool/thinking XML) — live-routing it
3770
+ // would repost the transcript and leak thinking every round. When no
3771
+ // round was live-routed, the 'complete' case falls back to the
3772
+ // historical whole-turn routing so fallback-mode prose is still
3773
+ // delivered exactly once, at turn end.
3774
+ let liveProseRouting = false;
3775
+
3525
3776
  // Typing indicator: show "<agent> is typing…" in the channel she's
3526
3777
  // responding to, for the whole duration of this turn. Started here (paired
3527
3778
  // with the finally below, so it can never leak) and refreshed on a 7s
3528
3779
  // interval by the ChannelRegistry until stopped on any exit path.
3529
- const typingChannel = this.channelRegistry?.getDefaultPublishChannel();
3780
+ const typingChannel = this.channelRegistry
3781
+ ? trigger?.channelId ?? this.channelRegistry.getDefaultPublishChannel()
3782
+ : null;
3530
3783
  if (typingChannel) this.channelRegistry!.startTyping(typingChannel);
3531
3784
 
3532
3785
  try {
@@ -3599,6 +3852,43 @@ export class AgentFramework {
3599
3852
  for (const call of event.calls) {
3600
3853
  this.dispatchToolCall(agent.name, call);
3601
3854
  }
3855
+
3856
+ // Speak-while-acting: route THIS round's prose to the locus NOW
3857
+ // instead of batching every segment to the end of the turn. Long
3858
+ // tool-using turns (robot control, live sessions) otherwise dump
3859
+ // the whole narration in one blob when the turn finally settles —
3860
+ // and, worse, make "talking" and "acting" feel mutually exclusive
3861
+ // to the agent. Mid-turn prose is speech (delivered live, in
3862
+ // order, via the turn's speech chain — never awaited here); the
3863
+ // `think` tool remains the private channel. Silencing is STICKY
3864
+ // from the round it occurs (see SILENCING_TOOLS). Only rounds
3865
+ // with verbatim roundContent are live-routed (see liveProseRouting
3866
+ // note above — the fallback preamble is cumulative in XML mode).
3867
+ if (this.channelRegistry) {
3868
+ const roundToolNames = event.calls.map((c) => c.name);
3869
+ if (roundToolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)))) {
3870
+ turnSilenced = true;
3871
+ }
3872
+ if (roundContent && roundContent.length > 0) {
3873
+ liveProseRouting = true;
3874
+ const roundSegments = splitProseSegments(assistantBlocks);
3875
+ if (roundSegments.length > 0) {
3876
+ if (turnSilenced) {
3877
+ console.error(
3878
+ `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`,
3879
+ );
3880
+ } else {
3881
+ const locus = resolveTurnLocus();
3882
+ console.error(
3883
+ `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> routing ${roundSegments.length} prose segment(s) live -> ${locus ?? '(default)'}`,
3884
+ );
3885
+ for (const seg of roundSegments) {
3886
+ enqueueSpeech(seg, locus);
3887
+ }
3888
+ }
3889
+ }
3890
+ }
3891
+ }
3602
3892
  // Stream's async iterator blocks on next() until provideToolResults() is called
3603
3893
  break;
3604
3894
  }
@@ -3703,10 +3993,13 @@ export class AgentFramework {
3703
3993
  }
3704
3994
  }
3705
3995
 
3706
- // Separate speech from thoughts.
3707
- // When tools were used, ALL text is thoughts (the tools themselves are
3708
- // the agent's actions — surrounding text is just reasoning).
3709
- // When no tools were used, all text is speech.
3996
+ // Separate speech from thoughts — for MODULE dispatch only
3997
+ // (dispatchSpeech / TUI rendering below). This split does NOT
3998
+ // decide what reaches the channel: mid-turn prose is routed live
3999
+ // per round (see the 'tool-calls' case) and trailing prose is
4000
+ // routed after this block. On a tool-using turn the module-level
4001
+ // convention remains "text is thoughts"; on a text-only turn all
4002
+ // text is speech.
3710
4003
  const isTextBlock = (block: ContentBlock): block is ContentBlock & { type: 'text' } =>
3711
4004
  block.type === 'text';
3712
4005
  const allText = response.content.filter(isTextBlock);
@@ -3906,70 +4199,54 @@ export class AgentFramework {
3906
4199
  }
3907
4200
  }
3908
4201
  } else if (this.channelRegistry && hadToolCalls && allText.length > 0) {
3909
- // Tool-call turn that also produced prose. Per design, route the
3910
- // prose to the locus as a reply UNLESS the turn used an explicit
3911
- // send/publish tool (channel_publish, *--send_message,
3912
- // *--reply_message, *--send_dm) — already delivered, so routing
3913
- // again would double-post. `think` and other non-sending tools
3914
- // (shell, workspace, etc.) still let the agent speak in the same
3915
- // turn. The global speech/thoughts split is left untouched
3916
- // (module/TUI rendering unaffected) — this only governs what
3917
- // reaches the channel.
3918
- // Tools whose presence suppresses the trailing prose, for two
3919
- // distinct reasons:
3920
- // - `skip_reply` — the agent EXPLICITLY chose not to reply this
3921
- // turn (the deliberate "stay silent" signal).
3922
- // - explicit delivery tools (channel_publish / *send_message /
3923
- // *reply_message / *send_dm) — already sent the message, so
3924
- // routing it again would double-post.
3925
- // `think` is deliberately NOT here: it is silent *reasoning*, but
3926
- // prose written after a think is the agent's actual reply and must
3927
- // be delivered. (A think-only turn has no trailing prose and stays
3928
- // silent via the `!t` check below.)
3929
- const SILENCING = new Set([
3930
- 'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
3931
- ]);
3932
- const bare = (n: string) => (n.includes('--') ? n.split('--').pop()! : n);
4202
+ // Tool-call turn that also produced prose. When live routing was
4203
+ // active (native roundContent), each mid-turn round's prose was
4204
+ // already delivered when that round yielded — only the TRAILING
4205
+ // prose (blocks after the last tool round) remains here. On the
4206
+ // fallback path (XML tool mode / older membrane: no roundContent,
4207
+ // so nothing was live-routed) the historical behavior applies:
4208
+ // route the whole turn's parsed segments, once, now. The global
4209
+ // speech/thoughts split is left untouched (module/TUI rendering
4210
+ // unaffected) — this only governs what reaches the channel.
4211
+ //
4212
+ // Silencing here is the sticky flag ∪ turn-wide scan: the scan
4213
+ // (over the whole turn's tool_use blocks) covers the fallback
4214
+ // path and the "send_message then a 'sent it.' postscript"
4215
+ // double-post guard; the sticky flag is its live-rounds twin.
3933
4216
  const toolNames = response.content
3934
4217
  .filter((b) => b.type === 'tool_use')
3935
4218
  .map((b) => (b as unknown as { name?: string }).name)
3936
4219
  .filter((n): n is string => typeof n === 'string');
3937
- const silenced = toolNames.some((n) => SILENCING.has(bare(n)));
3938
-
3939
- // Split the turn's content into ordered prose segments, broken at
3940
- // each tool_use / tool_result boundary. `response.content` holds the
3941
- // WHOLE turn's blocks in provider order — earlier tool rounds
3942
- // included (see the trailing-slice note above, which slices exactly
3943
- // because the full turn is present) — so a left-to-right walk
3944
- // reconstructs the emission order. Interleaved prose
3945
- // ("msgA → [tool] → msgB → [tool] → msgC") is then delivered as
3946
- // separate messages IN ORDER instead of being collapsed into one
3947
- // trailing post (item 4). The silencing rule stays turn-wide: an
3948
- // explicit send / skip suppresses ALL auto-routed prose this turn,
3949
- // preserving the double-post guard.
3950
- const segments = splitProseSegments(response.content);
4220
+ const silenced = turnSilenced || toolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)));
4221
+
4222
+ const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content);
4223
+
4224
+ // Preserve in-channel ordering: everything enqueued live must
4225
+ // land before the trailing prose. Awaited even when silenced —
4226
+ // the chain may still be flushing earlier rounds' posts.
4227
+ await turnSpeechChain;
3951
4228
 
3952
4229
  if (silenced || segments.length === 0) {
3953
4230
  console.error(
3954
- `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> NOT routed ` +
3955
- `(${silenced ? 'silencing tool / explicit send' : 'no prose'})`,
4231
+ `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> trailing prose NOT routed ` +
4232
+ `(${silenced ? 'silencing tool / explicit send' : 'no trailing prose'})`,
3956
4233
  );
3957
4234
  } else {
3958
- // Snapshot the locus ONCE. This loop dispatches AFTER the agent
3959
- // is idle (see PR #32 note below), so a queued inbound from
3960
- // another channel can start a NEW turn and overwrite the per-agent
3961
- // triggering channel (`activeTriggerChannels`) BETWEEN segments —
3962
- // splitting one turn's reply across channels (the residual
3963
- // multi-segment race left by 83dedf8's per-agent locus). Pin every
3964
- // segment of THIS turn to the channel resolved right now.
3965
- const turnLocus = this.channelRegistry.resolveLocus(agent.name);
4235
+ // Reuse the locus pinned at the turn's first live-routed
4236
+ // segment (or resolve it now for a turn whose only prose is
4237
+ // trailing). This dispatch runs AFTER the agent is idle (see
4238
+ // PR #32 note below), so a queued inbound from another channel
4239
+ // could otherwise overwrite the per-agent triggering channel
4240
+ // between segments — and a turn that narrated live into one
4241
+ // channel must not land its postscript in another.
4242
+ const locus = resolveTurnLocus();
3966
4243
  console.error(
3967
- `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} prose segment(s) in order -> ${turnLocus ?? '(default)'}`,
4244
+ `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} ${liveProseRouting ? 'trailing ' : ''}prose segment(s) -> ${locus ?? '(default)'}`,
3968
4245
  );
3969
4246
  // Deliver sequentially (await each) so the segments land in order.
3970
4247
  for (const seg of segments) {
3971
4248
  try {
3972
- await this.channelRegistry.routeSpeech(agent.name, seg, turnLocus);
4249
+ await this.channelRegistry.routeSpeech(agent.name, seg, locus);
3973
4250
  } catch (err) {
3974
4251
  console.error('speech routing failed:', err);
3975
4252
  }
@@ -4393,6 +4670,12 @@ export class AgentFramework {
4393
4670
  return;
4394
4671
  }
4395
4672
 
4673
+ // Route the agent's typed, allowlisted hot-settings surface.
4674
+ if (enrichedCall.name === 'agent_settings') {
4675
+ this.dispatchAgentSettingsToolCall(agentName, enrichedCall);
4676
+ return;
4677
+ }
4678
+
4396
4679
  // Route gate_status tool
4397
4680
  if (enrichedCall.name === 'gate_status' && this.eventGate) {
4398
4681
  this.dispatchGateToolCall(agentName, enrichedCall);
@@ -4511,12 +4794,19 @@ export class AgentFramework {
4511
4794
  throw new Error('No agents configured');
4512
4795
  }
4513
4796
 
4514
- // If any agent has pending assistant blocks (tool_use not yet flushed),
4515
- // defer non-tool_result messages to preserve tool_use → tool_result adjacency.
4797
+ // Defer non-tool_result messages while a tool cycle is mid-flight
4798
+ // (pendingAssistantBlocks: preserves tool_use → tool_result adjacency)
4799
+ // OR while the target agent has a live stream at all. A message stored
4800
+ // mid-stream lands BEFORE the turn's assistant blocks in the window even
4801
+ // though the live conversation never saw it — so the next compile
4802
+ // diverges from the live prefix (prompt-cache bust) and the message
4803
+ // misses mid-turn injection. Deferred messages flush at the agent's next
4804
+ // tool boundary (where they are ALSO injected into the live stream —
4805
+ // hear-while-acting) or in driveStream's finally when the turn ends.
4516
4806
  const hasToolResult = content.some(b => b.type === 'tool_result');
4517
- if (!hasToolResult && this.pendingAssistantBlocks.size > 0) {
4807
+ if (!hasToolResult && (this.pendingAssistantBlocks.size > 0 || this.activeStreams.has(agent.name))) {
4518
4808
  this.deferredMessages.push({ participant, content, metadata });
4519
- return '' as MessageId; // Deferred — will be added after tool_result flush
4809
+ return '' as MessageId; // Deferred — flushed at the next boundary
4520
4810
  }
4521
4811
 
4522
4812
  return agent.getContextManager().addMessage(participant, content, metadata);
@@ -5004,6 +5294,7 @@ export class AgentFramework {
5004
5294
  (event) => this.pushEvent(event),
5005
5295
  (event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }),
5006
5296
  {
5297
+ store: this.store,
5007
5298
  sendTypingFn: (serverId, channelId, metadata, op) => {
5008
5299
  const server = this.mcplServerRegistry!.getServer(serverId);
5009
5300
  if (server) {
@@ -5108,8 +5399,11 @@ export class AgentFramework {
5108
5399
 
5109
5400
  // Record per-server channel subscription policy before the server
5110
5401
  // registers channels — handleRegister fires during the handshake.
5111
- if (config.channelSubscription !== undefined && this.channelRegistry) {
5112
- this.channelRegistry.setSubscriptionPolicy(config.id, config.channelSubscription);
5402
+ if (this.channelRegistry) {
5403
+ this.channelRegistry.setSubscriptionPolicy(
5404
+ config.id,
5405
+ config.channelSubscription ?? 'manual',
5406
+ );
5113
5407
  }
5114
5408
 
5115
5409
  const connection = await this.mcplServerRegistry.addServer(config, this.mcplHostCapabilities);
@@ -5934,6 +6228,88 @@ export class AgentFramework {
5934
6228
  });
5935
6229
  }
5936
6230
 
6231
+ private dispatchAgentSettingsToolCall(agentName: string, call: ToolCall): void {
6232
+ this.emitTrace({
6233
+ type: 'tool:started',
6234
+ module: 'agent',
6235
+ tool: call.name,
6236
+ callId: call.id,
6237
+ input: call.input,
6238
+ });
6239
+ let result: ToolResult;
6240
+ try {
6241
+ const input = (call.input ?? {}) as {
6242
+ action?: unknown;
6243
+ context_budget_tokens?: unknown;
6244
+ tail_tokens?: unknown;
6245
+ transition_pace_tokens?: unknown;
6246
+ settings?: unknown;
6247
+ };
6248
+ switch (input.action) {
6249
+ case 'get':
6250
+ result = { success: true, data: this.getAgentRuntimeSettings(agentName) };
6251
+ break;
6252
+ case 'cancel':
6253
+ result = { success: true, data: this.cancelAgentRuntimeSettingsTransition(agentName) };
6254
+ break;
6255
+ case 'update': {
6256
+ const patch: AgentRuntimeSettingsPatch = {};
6257
+ if (input.context_budget_tokens !== undefined) {
6258
+ patch.contextBudgetTokens = Number(input.context_budget_tokens);
6259
+ }
6260
+ if (input.tail_tokens !== undefined) patch.tailTokens = Number(input.tail_tokens);
6261
+ if (input.transition_pace_tokens !== undefined) {
6262
+ patch.transitionPaceTokens = Number(input.transition_pace_tokens);
6263
+ }
6264
+ result = { success: true, data: this.updateAgentRuntimeSettings(agentName, patch) };
6265
+ break;
6266
+ }
6267
+ case 'reset': {
6268
+ let keys: Array<keyof AgentRuntimeSettingsPatch> | undefined;
6269
+ if (input.settings !== undefined) {
6270
+ if (!Array.isArray(input.settings)) throw new Error('reset `settings` must be an array');
6271
+ const names: Record<string, keyof AgentRuntimeSettingsPatch> = {
6272
+ context_budget_tokens: 'contextBudgetTokens',
6273
+ tail_tokens: 'tailTokens',
6274
+ transition_pace_tokens: 'transitionPaceTokens',
6275
+ };
6276
+ keys = input.settings.map((name) => {
6277
+ if (typeof name !== 'string' || !names[name]) {
6278
+ throw new Error(`Unknown reset setting: ${String(name)}`);
6279
+ }
6280
+ return names[name];
6281
+ });
6282
+ }
6283
+ result = { success: true, data: this.resetAgentRuntimeSettings(agentName, keys) };
6284
+ break;
6285
+ }
6286
+ default:
6287
+ throw new Error('agent_settings: action must be get, update, reset, or cancel');
6288
+ }
6289
+ } catch (error) {
6290
+ result = {
6291
+ success: false,
6292
+ error: error instanceof Error ? error.message : String(error),
6293
+ isError: true,
6294
+ };
6295
+ }
6296
+ this.emitTrace({
6297
+ type: result.isError ? 'tool:failed' : 'tool:completed',
6298
+ module: 'agent',
6299
+ tool: call.name,
6300
+ callId: call.id,
6301
+ durationMs: 0,
6302
+ ...(result.isError ? { error: result.error } : {}),
6303
+ });
6304
+ this.pushEvent({
6305
+ type: 'tool-result',
6306
+ callId: call.id,
6307
+ agentName,
6308
+ moduleName: 'agent',
6309
+ result,
6310
+ });
6311
+ }
6312
+
5937
6313
  /** Aggregate the event-tag vocabulary: reserved chat:* core + each connected
5938
6314
  * server's declared tag ontology + gate.js status. */
5939
6315
  private buildEventTagsResult(): Record<string, unknown> {