@animalabs/agent-framework 0.6.4 → 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 (85) hide show
  1. package/README.md +5 -0
  2. package/dist/src/agent.d.ts +18 -1
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +221 -2
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/framework.d.ts +59 -2
  7. package/dist/src/framework.d.ts.map +1 -1
  8. package/dist/src/framework.js +687 -229
  9. package/dist/src/framework.js.map +1 -1
  10. package/dist/src/gate/event-gate.d.ts.map +1 -1
  11. package/dist/src/gate/event-gate.js +27 -4
  12. package/dist/src/gate/event-gate.js.map +1 -1
  13. package/dist/src/mcpl/channel-registry.d.ts +34 -15
  14. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  15. package/dist/src/mcpl/channel-registry.js +299 -67
  16. package/dist/src/mcpl/channel-registry.js.map +1 -1
  17. package/dist/src/mcpl/push-handler.d.ts +1 -0
  18. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  19. package/dist/src/mcpl/push-handler.js +1 -0
  20. package/dist/src/mcpl/push-handler.js.map +1 -1
  21. package/dist/src/mcpl/server-connection.d.ts +3 -1
  22. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  23. package/dist/src/mcpl/server-connection.js +4 -0
  24. package/dist/src/mcpl/server-connection.js.map +1 -1
  25. package/dist/src/mcpl/types.d.ts +43 -12
  26. package/dist/src/mcpl/types.d.ts.map +1 -1
  27. package/dist/src/mcpl/types.js +1 -0
  28. package/dist/src/mcpl/types.js.map +1 -1
  29. package/dist/src/types/agent.d.ts +15 -0
  30. package/dist/src/types/agent.d.ts.map +1 -1
  31. package/dist/src/types/events.d.ts +1 -0
  32. package/dist/src/types/events.d.ts.map +1 -1
  33. package/dist/src/types/framework.d.ts +9 -0
  34. package/dist/src/types/framework.d.ts.map +1 -1
  35. package/dist/src/types/trace.d.ts +2 -0
  36. package/dist/src/types/trace.d.ts.map +1 -1
  37. package/dist/test/activation-request-sanitize.test.d.ts +2 -0
  38. package/dist/test/activation-request-sanitize.test.d.ts.map +1 -0
  39. package/dist/test/activation-request-sanitize.test.js +89 -0
  40. package/dist/test/activation-request-sanitize.test.js.map +1 -0
  41. package/dist/test/agent-runtime-settings.test.d.ts +2 -0
  42. package/dist/test/agent-runtime-settings.test.d.ts.map +1 -0
  43. package/dist/test/agent-runtime-settings.test.js +120 -0
  44. package/dist/test/agent-runtime-settings.test.js.map +1 -0
  45. package/dist/test/channel-registry-routing.test.js +5 -4
  46. package/dist/test/channel-registry-routing.test.js.map +1 -1
  47. package/dist/test/conversation-fork-injection-scoping.test.js +12 -1
  48. package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
  49. package/dist/test/ephemeral-settle.test.d.ts +2 -0
  50. package/dist/test/ephemeral-settle.test.d.ts.map +1 -0
  51. package/dist/test/ephemeral-settle.test.js +351 -0
  52. package/dist/test/ephemeral-settle.test.js.map +1 -0
  53. package/dist/test/event-gate.test.js +21 -1
  54. package/dist/test/event-gate.test.js.map +1 -1
  55. package/dist/test/helpers/mock-membrane.d.ts +13 -1
  56. package/dist/test/helpers/mock-membrane.d.ts.map +1 -1
  57. package/dist/test/helpers/mock-membrane.js +8 -1
  58. package/dist/test/helpers/mock-membrane.js.map +1 -1
  59. package/dist/test/maintenance-timer.test.js +52 -1
  60. package/dist/test/maintenance-timer.test.js.map +1 -1
  61. package/dist/test/mcpl-gate-roundtrip.test.js +138 -6
  62. package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
  63. package/dist/test/overbudget-drain-breaker.test.d.ts +2 -0
  64. package/dist/test/overbudget-drain-breaker.test.d.ts.map +1 -0
  65. package/dist/test/overbudget-drain-breaker.test.js +157 -0
  66. package/dist/test/overbudget-drain-breaker.test.js.map +1 -0
  67. package/dist/test/present-while-acting.test.d.ts +18 -0
  68. package/dist/test/present-while-acting.test.d.ts.map +1 -0
  69. package/dist/test/present-while-acting.test.js +267 -0
  70. package/dist/test/present-while-acting.test.js.map +1 -0
  71. package/dist/test/trunk-channel-routing.test.js +38 -0
  72. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  73. package/dist/tsconfig.tsbuildinfo +1 -1
  74. package/package.json +3 -3
  75. package/src/agent.ts +244 -3
  76. package/src/framework.ts +763 -235
  77. package/src/gate/event-gate.ts +38 -4
  78. package/src/mcpl/channel-registry.ts +372 -76
  79. package/src/mcpl/push-handler.ts +2 -0
  80. package/src/mcpl/server-connection.ts +10 -0
  81. package/src/mcpl/types.ts +49 -12
  82. package/src/types/agent.ts +18 -0
  83. package/src/types/events.ts +1 -1
  84. package/src/types/framework.ts +10 -0
  85. 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,7 +169,24 @@ 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();
179
+ /** Streams the FRAMEWORK cancelled for non-terminal reasons, keyed
180
+ * `${agentName}:${streamId}`: an endTurn tool result or a context-budget
181
+ * restart. The membrane still delivers an `aborted` event for these, and
182
+ * without the marker the abort handler would treat it as a terminal
183
+ * failure — settling ephemerals with a rejection and emitting a spurious
184
+ * `inference:exhausted` (which also pollutes the failure streak). Kept
185
+ * separate from ephemeralRuns deliberately: endTurn/budget cancels happen
186
+ * for resident agents too, and the key is per-stream, not per-agent. */
187
+ frameworkCancelledStreams = new Map();
188
+ /** Active runEphemeralToCompletion runs, keyed by agent name. */
189
+ ephemeralRuns = new Map();
150
190
  /** Per-agent count of consecutive exhausted inferences (reset on any success).
151
191
  * Drives hard-down escalation — see noteInferenceExhausted. */
152
192
  consecutiveInferenceFailures = new Map();
@@ -175,6 +215,13 @@ export class AgentFramework {
175
215
  * Bounds the automatic quarantine loop the same way refusalRewinds bounds
176
216
  * the refusal loop, so the breaker can never shed the whole history. */
177
217
  exhaustionRewinds = new Map();
218
+ /** Agents with an OverBudget drain kick currently in flight. The breaker in
219
+ * noteInferenceExhausted fires on EVERY matching failure and the scenario it
220
+ * exists for is "every activation fails, repeatedly" — without this guard,
221
+ * overlapping kicks race the strategy's own pendingCompression gate and the
222
+ * tick counts in the success log stop meaning anything. One kick per agent
223
+ * at a time; cleared when the kick settles (success or failure). */
224
+ overBudgetDrainInFlight = new Set();
178
225
  /** Per-agent current rewind episode: the single consolidated marker's id and
179
226
  * how many turns have been shed so far. One marker per episode, updated in
180
227
  * place (see updateRewindMarker); cleared when the episode ends. */
@@ -537,7 +584,9 @@ export class AgentFramework {
537
584
  if (this.maintenancePass || !this.running)
538
585
  return;
539
586
  const pass = this.runQueuedMaintenance().catch((error) => {
540
- console.error('[context-maintenance] pass failed:', error instanceof Error ? error.message : error);
587
+ const reason = error instanceof Error ? error.message : String(error);
588
+ console.error('[context-maintenance] pass failed:', reason);
589
+ this.opsAlert('context-maintenance-failed', 'framework', reason, { data: { scope: 'pass' } });
541
590
  });
542
591
  this.maintenancePass = pass;
543
592
  void pass.finally(() => {
@@ -588,6 +637,13 @@ export class AgentFramework {
588
637
  catch (error) {
589
638
  record.error = error instanceof Error ? error.message : String(error);
590
639
  console.error(`[context-maintenance] agent=${agent.name} failed:`, record.error);
640
+ this.opsAlert('context-maintenance-failed', agent.name, record.error, {
641
+ data: {
642
+ scope: 'agent',
643
+ ...(record.pendingBefore ? { pending: record.pendingBefore } : {}),
644
+ ...(record.progressBefore ? { progress: record.progressBefore } : {}),
645
+ },
646
+ });
591
647
  }
592
648
  finally {
593
649
  const pending = cm.getPendingWork()?.description;
@@ -725,10 +781,43 @@ export class AgentFramework {
725
781
  AgentFramework.EVENT_TAGS_TOOL,
726
782
  ]
727
783
  : [];
728
- if (this.mcplTools.length === 0 && channelTools.length === 0 && gateTools.length === 0) {
729
- return moduleTools;
730
- }
731
- 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;
732
821
  }
733
822
  /** Counts-only context-maintenance diagnostics for authenticated debug UIs. */
734
823
  getContextMaintenanceSnapshot() {
@@ -905,6 +994,29 @@ export class AgentFramework {
905
994
  // Non-fatal — usage tracking is best-effort
906
995
  }
907
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
+ }
908
1020
  /**
909
1021
  * Create an ephemeral agent that is NOT registered in the main event loop.
910
1022
  *
@@ -934,6 +1046,50 @@ export class AgentFramework {
934
1046
  };
935
1047
  return { agent, contextManager, cleanup };
936
1048
  }
1049
+ createDeferred() {
1050
+ let resolve;
1051
+ let reject;
1052
+ const promise = new Promise((res, rej) => {
1053
+ resolve = res;
1054
+ reject = rej;
1055
+ });
1056
+ return { promise, resolve, reject };
1057
+ }
1058
+ /** Liveness ping for an active ephemeral run (no-op otherwise). */
1059
+ touchEphemeralRun(agentName, inferenceStarted = false) {
1060
+ const run = this.ephemeralRuns.get(agentName);
1061
+ if (!run)
1062
+ return;
1063
+ if (inferenceStarted) {
1064
+ run.inferenceStarted = true;
1065
+ }
1066
+ run.lastActivity = Date.now();
1067
+ }
1068
+ recordEphemeralToolCalls(agentName, count) {
1069
+ const run = this.ephemeralRuns.get(agentName);
1070
+ if (!run)
1071
+ return;
1072
+ run.toolCallsCount += count;
1073
+ run.lastActivity = Date.now();
1074
+ }
1075
+ /** Settle an active ephemeral run (no-op for resident agents). The tool-call
1076
+ * count comes from the run's own counter — the single source of truth,
1077
+ * which unlike a per-stream local survives budget restarts. */
1078
+ settleAgent(agentName, outcome) {
1079
+ const run = this.ephemeralRuns.get(agentName);
1080
+ if (!run)
1081
+ return;
1082
+ run.lastActivity = Date.now();
1083
+ if (outcome.stopReason === 'exhausted') {
1084
+ run.settle.reject(new Error(outcome.error ?? 'Unknown error'));
1085
+ return;
1086
+ }
1087
+ run.settle.resolve({
1088
+ stopReason: outcome.stopReason,
1089
+ speech: outcome.speech,
1090
+ toolCallsCount: run.toolCallsCount,
1091
+ });
1092
+ }
937
1093
  /**
938
1094
  * Run an ephemeral agent to completion through the framework's event loop.
939
1095
  *
@@ -944,139 +1100,73 @@ export class AgentFramework {
944
1100
  * The caller provides a pre-created agent + contextManager (from createEphemeralAgent).
945
1101
  * The task message should already be in the context manager.
946
1102
  */
947
- async runEphemeralToCompletion(agent, contextManager) {
1103
+ async runEphemeralToCompletion(agent, contextManager, watchdogs) {
948
1104
  // Register temporarily so the event loop can drive it
949
1105
  this.agents.set(agent.name, agent);
950
- return new Promise((resolve, reject) => {
951
- let speech = '';
952
- let toolCallsCount = 0;
953
- let settled = false;
954
- let inferenceStarted = false;
955
- let lastActivity = Date.now();
956
- const STARTUP_TIMEOUT_MS = 30_000;
957
- // After inference has started, give it 15 minutes of activity-bounded
958
- // life. Each addressed trace event refreshes the deadline; only
959
- // sustained silence trips it. Subagents that legitimately stream for
960
- // 10+ minutes (e.g. long fork investigations) get the extra slack but
961
- // stalled streams that emit `inference:completed` and then go quiet —
962
- // the exact zombie shape we saw in production — are caught here.
963
- const COMPLETION_IDLE_TIMEOUT_MS = 15 * 60_000;
964
- const cleanup = () => {
965
- if (settled)
966
- return;
967
- settled = true;
968
- clearTimeout(startupWatchdog);
969
- clearInterval(completionWatchdog);
970
- this.offTrace(traceListener);
971
- this.agents.delete(agent.name);
972
- this.evictTurnCheckpoints(agent.name);
973
- };
974
- // Watchdog: if inference never starts within 30s, the agent is a zombie.
975
- // This catches cases where the event loop stalled, the agent was
976
- // deregistered, or processInferenceRequests() skipped the request.
977
- const startupWatchdog = setTimeout(() => {
978
- if (!inferenceStarted && !settled) {
979
- cleanup();
1106
+ const run = {
1107
+ settle: this.createDeferred(),
1108
+ inferenceStarted: false,
1109
+ lastActivity: Date.now(),
1110
+ toolCallsCount: 0,
1111
+ };
1112
+ this.ephemeralRuns.set(agent.name, run);
1113
+ const STARTUP_TIMEOUT_MS = watchdogs?.startupTimeoutMs ?? 30_000;
1114
+ // After inference has started, give it 15 minutes of activity-bounded
1115
+ // life. The stream driver refreshes the deadline as it makes progress;
1116
+ // sustained silence trips it.
1117
+ const COMPLETION_IDLE_TIMEOUT_MS = watchdogs?.idleTimeoutMs ?? 15 * 60_000;
1118
+ const IDLE_POLL_MS = watchdogs?.idlePollMs ?? 30_000;
1119
+ let startupWatchdog = null;
1120
+ let completionWatchdog = null;
1121
+ const startupTimeout = new Promise((_, reject) => {
1122
+ startupWatchdog = setTimeout(() => {
1123
+ if (!run.inferenceStarted) {
980
1124
  reject(new Error(`Ephemeral agent "${agent.name}" failed to start inference within ` +
981
1125
  `${STARTUP_TIMEOUT_MS}ms — zombie detected. The event loop may have ` +
982
1126
  `stalled or the inference request was dropped.`));
983
1127
  }
984
1128
  }, STARTUP_TIMEOUT_MS);
985
- // Completion watchdog: once inference has started, reject if no trace
986
- // activity for COMPLETION_IDLE_TIMEOUT_MS. Polls every 30s. Without
987
- // this, an ephemeral that loses its terminal event (e.g. due to a
988
- // future ordering regression or an upstream stream drop) hangs forever
989
- // and keeps its concurrency slot pinned.
990
- const completionWatchdog = setInterval(() => {
991
- if (settled || !inferenceStarted)
1129
+ });
1130
+ const completionIdleTimeout = new Promise((_, reject) => {
1131
+ completionWatchdog = setInterval(() => {
1132
+ if (!run.inferenceStarted)
992
1133
  return;
993
- const idle = Date.now() - lastActivity;
1134
+ const idle = Date.now() - run.lastActivity;
994
1135
  if (idle > COMPLETION_IDLE_TIMEOUT_MS) {
995
- cleanup();
996
- reject(new Error(`Ephemeral agent "${agent.name}" stalled: no trace activity for ` +
1136
+ reject(new Error(`Ephemeral agent "${agent.name}" stalled: no stream-driver activity for ` +
997
1137
  `${Math.round(idle / 1000)}s after inference started (threshold ` +
998
1138
  `${Math.round(COMPLETION_IDLE_TIMEOUT_MS / 1000)}s). Stream likely ` +
999
1139
  `dropped or terminal event was lost.`));
1000
1140
  }
1001
- }, 30_000);
1002
- const traceListener = (event) => {
1003
- if (settled)
1004
- return;
1005
- // Only track events for our ephemeral agent
1006
- const agentName = 'agentName' in event ? event.agentName : null;
1007
- if (agentName !== agent.name)
1008
- return;
1009
- // Any addressed event counts as liveness for the completion watchdog.
1010
- lastActivity = Date.now();
1011
- switch (event.type) {
1012
- case 'inference:started':
1013
- inferenceStarted = true;
1014
- break;
1015
- case 'inference:tokens': {
1016
- inferenceStarted = true;
1017
- const content = event.content;
1018
- if (content)
1019
- speech += content;
1020
- break;
1021
- }
1022
- case 'inference:tool_calls_yielded': {
1023
- const calls = event.calls;
1024
- if (calls)
1025
- toolCallsCount += calls.length;
1026
- // Reset speech buffer — tool calls break the text
1027
- speech = '';
1028
- break;
1029
- }
1030
- case 'inference:stream_resumed':
1031
- speech = '';
1032
- break;
1033
- case 'inference:completed': {
1034
- // `inference:completed` is terminal — driveStream only emits it
1035
- // from `case 'complete'`, which fires after the full tool
1036
- // round-trip has finished and the model has stopped. Intermediate
1037
- // tool-cycle transitions use `inference:stream_resumed`, not
1038
- // `inference:completed`. So we resolve unconditionally here.
1039
- //
1040
- // Previously this branch gated on `agent.state.status === 'idle'`,
1041
- // which was correct in intent but order-fragile: the framework
1042
- // emitted `inference:completed` BEFORE calling `agent.reset()`,
1043
- // so synchronous listeners observed status === 'streaming' at
1044
- // the trace boundary. The gate failed silently and the promise
1045
- // hung forever — every production zombie subagent originated
1046
- // here. The reset+emit order has been fixed in driveStream's
1047
- // `case 'complete'` handler; the gate is dropped here as
1048
- // belt-and-suspenders.
1049
- cleanup();
1050
- resolve({ speech, toolCallsCount });
1051
- break;
1052
- }
1053
- case 'inference:turn_ended': {
1054
- // endTurn from a tool result — agent is done
1055
- cleanup();
1056
- resolve({ speech, toolCallsCount });
1057
- break;
1058
- }
1059
- case 'inference:exhausted': {
1060
- // Retries exhausted — reject only after the error policy gives up.
1061
- // Earlier inference:failed events are left alone so the framework
1062
- // retry path in driveStream/startAgentStream can restart the stream
1063
- // while this listener stays alive.
1064
- const error = event.error ?? 'Unknown error';
1065
- cleanup();
1066
- reject(new Error(error));
1067
- break;
1068
- }
1069
- }
1070
- };
1071
- this.onTrace(traceListener);
1072
- // Trigger inference
1141
+ }, IDLE_POLL_MS);
1142
+ });
1143
+ try {
1144
+ // Trigger inference after the settle promise is registered.
1073
1145
  this.pendingRequests.push({
1074
1146
  agentName: agent.name,
1075
1147
  reason: 'ephemeral',
1076
1148
  source: 'subagent',
1077
1149
  timestamp: Date.now(),
1078
1150
  });
1079
- });
1151
+ const result = await Promise.race([
1152
+ run.settle.promise,
1153
+ startupTimeout,
1154
+ completionIdleTimeout,
1155
+ ]);
1156
+ return { speech: result.speech, toolCallsCount: result.toolCallsCount };
1157
+ }
1158
+ finally {
1159
+ if (startupWatchdog)
1160
+ clearTimeout(startupWatchdog);
1161
+ if (completionWatchdog)
1162
+ clearInterval(completionWatchdog);
1163
+ this.ephemeralRuns.delete(agent.name);
1164
+ this.agents.delete(agent.name);
1165
+ // Spawn-and-dispose bookkeeping (main, d453165/fee96a7): without this,
1166
+ // ephemeral agents leave checkpoint-tree keys and diagnostics map
1167
+ // entries behind for the life of the store/session.
1168
+ this.evictTurnCheckpoints(agent.name);
1169
+ }
1080
1170
  }
1081
1171
  /**
1082
1172
  * Get queue depth.
@@ -1233,6 +1323,29 @@ export class AgentFramework {
1233
1323
  'tagsAll / tagsNone) or in gate.js.',
1234
1324
  inputSchema: { type: 'object' },
1235
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
+ };
1236
1349
  /** Reserved chat:* core vocabulary (MCPL RFC-001 §4) — short descriptions so
1237
1350
  * the agent can author rules without reading the spec. */
1238
1351
  static CHAT_CORE_TAGS = {
@@ -2150,6 +2263,9 @@ export class AgentFramework {
2150
2263
  debugLogContext: !!process.env.DEBUG_CONTEXT,
2151
2264
  });
2152
2265
  const agent = new Agent(config, contextManager, this.membrane);
2266
+ const restoredSettings = this.readAgentRuntimeSettings(config.name);
2267
+ if (restoredSettings)
2268
+ agent.restoreRuntimeSettings(restoredSettings);
2153
2269
  this.agents.set(config.name, agent);
2154
2270
  this.agentConfigs.set(config.name, config);
2155
2271
  // First non-ephemeral agent becomes the primary for message routing
@@ -2196,6 +2312,9 @@ export class AgentFramework {
2196
2312
  // messages between tool_use and tool_result, causing a 400 error.
2197
2313
  if (event.type === 'tool-result') {
2198
2314
  const agent = this.agents.get(event.agentName);
2315
+ if (agent) {
2316
+ this.touchEphemeralRun(event.agentName, true);
2317
+ }
2199
2318
  if (!agent) {
2200
2319
  console.warn(`[framework] Dropping tool-result for unknown agent '${event.agentName}' (callId=${event.callId}). ` +
2201
2320
  `Agent may have been destroyed while tool was executing.`);
@@ -2239,16 +2358,49 @@ export class AgentFramework {
2239
2358
  isError: tc.result.isError,
2240
2359
  }));
2241
2360
  agent.getContextManager().addMessage('user', toolResultContent);
2242
- // Flush any messages that were deferred while waiting for tool results.
2243
- // Route to the PRIMARY agent — deferred messages are framework-level
2244
- // (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 = [];
2245
2384
  if (this.deferredMessages.length > 0) {
2246
- const deferred = this.deferredMessages.splice(0);
2247
- const primary = this.primaryAgentName
2248
- ? this.agents.get(this.primaryAgentName)
2249
- : agent;
2250
- for (const msg of deferred) {
2251
- (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
+ }
2252
2404
  }
2253
2405
  }
2254
2406
  // Check if any tool result requested endTurn
@@ -2260,13 +2412,29 @@ export class AgentFramework {
2260
2412
  if (shouldEndTurn) {
2261
2413
  // endTurn: messages already stored above, cancel stream, reset to idle.
2262
2414
  if (currentState.stream) {
2263
- agent.cancelStream();
2415
+ this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'turn_ended');
2416
+ currentState.stream.cancel();
2264
2417
  }
2265
2418
  agent.reset();
2419
+ // The turn is over — release the gate here, not just in the abort
2420
+ // handler: the membrane's aborted event is asynchronous and the
2421
+ // agent must be wakeable the moment it goes idle. (Idempotent;
2422
+ // driveStream's finally is the backstop.)
2423
+ this.eventGate?.onInferenceEnded(agent.name);
2424
+ this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' });
2266
2425
  this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
2267
2426
  }
2268
2427
  else if (overBudget) {
2269
- // Context budget exceeded: break the stream, let compile() compress
2428
+ // Context budget exceeded: break the stream, let compile() compress.
2429
+ // Mark the cancel as framework-initiated BEFORE cancelling: the
2430
+ // membrane delivers `aborted` before the restart bumps streamId,
2431
+ // and without the marker the abort handler treats the restart as
2432
+ // a terminal failure — rejecting an ephemeral's promise mid-run
2433
+ // and emitting a spurious inference:exhausted (same race shape as
2434
+ // endTurn above).
2435
+ if (currentState.stream) {
2436
+ this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart');
2437
+ }
2270
2438
  agent.cancelStream();
2271
2439
  this.emitTrace({
2272
2440
  type: 'inference:stream_restarted',
@@ -2283,11 +2451,18 @@ export class AgentFramework {
2283
2451
  });
2284
2452
  }
2285
2453
  else if (currentState.stream) {
2286
- // 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.
2287
2458
  const membraneResults = currentState.toolResults.map(tc => this.toMembraneToolResult(tc.id, tc.result, maxChars));
2288
- currentState.stream.provideToolResults(membraneResults);
2459
+ currentState.stream.provideToolResults(membraneResults, midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections } : undefined);
2289
2460
  agent.setStreaming(currentState.stream);
2290
- 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
+ });
2291
2466
  }
2292
2467
  else {
2293
2468
  // Non-streaming fallback: schedule re-inference
@@ -2665,6 +2840,10 @@ export class AgentFramework {
2665
2840
  * Convert an MCPL push event to a context message.
2666
2841
  */
2667
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
+ }
2668
2847
  const metadata = {
2669
2848
  ...event.origin,
2670
2849
  serverId: event.serverId,
@@ -2672,17 +2851,36 @@ export class AgentFramework {
2672
2851
  eventId: event.eventId,
2673
2852
  triggered: event.triggerInference ?? false,
2674
2853
  };
2675
- const id = this.addMessage('user', event.content, metadata);
2676
- this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
2677
- // Some push events carry a channel of origin (Discord DMs arrive here, not
2678
- // via channels/incoming, because discord-mcpl forwards them with the channel
2679
- // closed). Register + open that channel so routeSpeech can resolve it, and
2680
- // thread it onto the wake request so the reply routes back to it instead of
2681
- // the global locus (item-3 redux, DM sub-case).
2682
- const triggerChannel = this.derivePushEventChannel(event.origin);
2683
- if (triggerChannel && this.channelRegistry) {
2684
- 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;
2685
2881
  }
2882
+ const id = this.addMessage('user', content, metadata);
2883
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
2686
2884
  if (event.triggerInference) {
2687
2885
  // Default broadcast excludes conversation forks (channel-driven).
2688
2886
  const targetAgents = event.targetAgents
@@ -2825,6 +3023,7 @@ export class AgentFramework {
2825
3023
  else {
2826
3024
  this.activeTriggerChannels.delete(agent.name);
2827
3025
  }
3026
+ this.touchEphemeralRun(agent.name, true);
2828
3027
  this.emitTrace({ type: 'inference:started', agentName: agent.name });
2829
3028
  this.eventGate?.onInferenceStarted(agent.name);
2830
3029
  this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
@@ -2880,17 +3079,23 @@ export class AgentFramework {
2880
3079
  await this.startAgentStream(agent, trigger, attempt + 1);
2881
3080
  }
2882
3081
  else {
3082
+ this.settleAgent(agent.name, {
3083
+ stopReason: 'exhausted',
3084
+ speech: '',
3085
+ error: err.message,
3086
+ });
2883
3087
  this.emitTrace({
2884
3088
  type: 'inference:exhausted',
2885
3089
  agentName: agent.name,
2886
3090
  error: err.message,
2887
- // Drives the poison-history breaker. `retryable` is kept for
2888
- // observability, but the breaker gates on `errorType` — only an
2889
- // `invalid_request` (a 400-class rejection of the history itself)
2890
- // means retrying the same context can never succeed. auth/abort/
2891
- // context_length are also non-retryable but must NOT cost history.
2892
- retryable: err instanceof MembraneError ? err.retryable : undefined,
2893
- errorType: err instanceof MembraneError ? err.type : undefined,
3091
+ // Drives the poison-history breaker (`invalid_request`: a 400-class
3092
+ // rejection of the history itself — retrying the same context can
3093
+ // never succeed; auth/abort/context_length are also non-retryable
3094
+ // but must NOT cost history) and the OverBudget drain breaker
3095
+ // (`over_budget`: compile refused to fit the hard budget — this is
3096
+ // the site that sees it, since compile runs before the stream
3097
+ // exists). `retryable` is kept for observability.
3098
+ ...this.classifyInferenceError(err),
2894
3099
  });
2895
3100
  this.eventGate?.onInferenceEnded(agent.name);
2896
3101
  if (action.emit) {
@@ -2904,15 +3109,69 @@ export class AgentFramework {
2904
3109
  const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
2905
3110
  const myStreamId = agent.streamId;
2906
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;
2907
3163
  // Typing indicator: show "<agent> is typing…" in the channel she's
2908
3164
  // responding to, for the whole duration of this turn. Started here (paired
2909
3165
  // with the finally below, so it can never leak) and refreshed on a 7s
2910
3166
  // interval by the ChannelRegistry until stopped on any exit path.
2911
- const typingChannel = this.channelRegistry?.getDefaultPublishChannel();
3167
+ const typingChannel = this.channelRegistry
3168
+ ? trigger?.channelId ?? this.channelRegistry.getDefaultPublishChannel()
3169
+ : null;
2912
3170
  if (typingChannel)
2913
3171
  this.channelRegistry.startTyping(typingChannel);
2914
3172
  try {
2915
3173
  for await (const event of stream) {
3174
+ this.touchEphemeralRun(agent.name, true);
2916
3175
  switch (event.type) {
2917
3176
  case 'tokens':
2918
3177
  this.emitTrace({
@@ -2936,6 +3195,7 @@ export class AgentFramework {
2936
3195
  }
2937
3196
  case 'tool-calls': {
2938
3197
  hadToolCalls = true;
3198
+ this.recordEphemeralToolCalls(agent.name, event.calls.length);
2939
3199
  this.emitTrace({
2940
3200
  type: 'inference:tool_calls_yielded',
2941
3201
  agentName: agent.name,
@@ -2972,6 +3232,39 @@ export class AgentFramework {
2972
3232
  for (const call of event.calls) {
2973
3233
  this.dispatchToolCall(agent.name, call);
2974
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
+ }
2975
3268
  // Stream's async iterator blocks on next() until provideToolResults() is called
2976
3269
  break;
2977
3270
  }
@@ -3021,14 +3314,16 @@ export class AgentFramework {
3021
3314
  // thinking blocks from their tool_use turn (the API requires
3022
3315
  // thinking to precede tool_use in the same assistant turn).
3023
3316
  const lastToolIdx = response.content.reduce((last, b, i) => b.type === 'tool_use' || b.type === 'tool_result' ? i : last, -1);
3317
+ const terminalContent = lastToolIdx >= 0
3318
+ ? response.content.slice(lastToolIdx + 1)
3319
+ : response.content;
3024
3320
  if (lastToolIdx >= 0) {
3025
- const trailingContent = response.content.slice(lastToolIdx + 1);
3026
- if (trailingContent.length > 0) {
3027
- agent.addAssistantResponse(trailingContent);
3321
+ if (terminalContent.length > 0) {
3322
+ agent.addAssistantResponse(terminalContent);
3028
3323
  }
3029
3324
  }
3030
3325
  else {
3031
- agent.addAssistantResponse(response.content);
3326
+ agent.addAssistantResponse(terminalContent);
3032
3327
  }
3033
3328
  // Run afterInference hooks (no-op if no MCPL servers)
3034
3329
  if (this.hookOrchestrator) {
@@ -3063,10 +3358,13 @@ export class AgentFramework {
3063
3358
  console.error('afterInference hook error:', error);
3064
3359
  }
3065
3360
  }
3066
- // Separate speech from thoughts.
3067
- // When tools were used, ALL text is thoughts (the tools themselves are
3068
- // the agent's actions — surrounding text is just reasoning).
3069
- // 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.
3070
3368
  const isTextBlock = (block) => block.type === 'text';
3071
3369
  const allText = response.content.filter(isTextBlock);
3072
3370
  const speechContent = hadToolCalls ? [] : allText;
@@ -3080,17 +3378,20 @@ export class AgentFramework {
3080
3378
  cacheRead: du.cacheReadTokens,
3081
3379
  }
3082
3380
  : undefined;
3083
- // Reset agent state BEFORE emitting inference:completed so that
3084
- // synchronous listeners (e.g. runEphemeralToCompletion) observe
3085
- // status === 'idle' the moment the event fires. Previously
3086
- // agent.reset() ran after `await dispatchSpeech`, which meant the
3087
- // ephemeral-completion listener saw status === 'streaming' at the
3088
- // trace boundary, failed its idle gate, and the promise hung
3089
- // forever — every "zombie subagent" in production tracked back
3090
- // here. Speech dispatch happens after but doesn't depend on the
3091
- // status field.
3381
+ // Reset agent state before emitting inference:completed. Traces are
3382
+ // observability-only, but external synchronous listeners should
3383
+ // still see the terminal state at the terminal trace boundary.
3384
+ // Speech dispatch happens after but doesn't depend on the status
3385
+ // field.
3092
3386
  agent.reset();
3093
3387
  this.eventGate?.onInferenceEnded(agent.name);
3388
+ this.settleAgent(agent.name, {
3389
+ stopReason: 'completed',
3390
+ speech: terminalContent
3391
+ .filter((block) => block.type === 'text')
3392
+ .map((block) => block.text)
3393
+ .join('\n'),
3394
+ });
3094
3395
  this.emitTrace({
3095
3396
  type: 'inference:completed',
3096
3397
  agentName: agent.name,
@@ -3235,65 +3536,48 @@ export class AgentFramework {
3235
3536
  }
3236
3537
  }
3237
3538
  else if (this.channelRegistry && hadToolCalls && allText.length > 0) {
3238
- // Tool-call turn that also produced prose. Per design, route the
3239
- // prose to the locus as a reply UNLESS the turn used an explicit
3240
- // send/publish tool (channel_publish, *--send_message,
3241
- // *--reply_message, *--send_dm) — already delivered, so routing
3242
- // again would double-post. `think` and other non-sending tools
3243
- // (shell, workspace, etc.) still let the agent speak in the same
3244
- // turn. The global speech/thoughts split is left untouched
3245
- // (module/TUI rendering unaffected) — this only governs what
3246
- // reaches the channel.
3247
- // Tools whose presence suppresses the trailing prose, for two
3248
- // distinct reasons:
3249
- // - `skip_reply` — the agent EXPLICITLY chose not to reply this
3250
- // turn (the deliberate "stay silent" signal).
3251
- // - explicit delivery tools (channel_publish / *send_message /
3252
- // *reply_message / *send_dm) — already sent the message, so
3253
- // routing it again would double-post.
3254
- // `think` is deliberately NOT here: it is silent *reasoning*, but
3255
- // prose written after a think is the agent's actual reply and must
3256
- // be delivered. (A think-only turn has no trailing prose and stays
3257
- // silent via the `!t` check below.)
3258
- const SILENCING = new Set([
3259
- 'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
3260
- ]);
3261
- 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.
3262
3553
  const toolNames = response.content
3263
3554
  .filter((b) => b.type === 'tool_use')
3264
3555
  .map((b) => b.name)
3265
3556
  .filter((n) => typeof n === 'string');
3266
- const silenced = toolNames.some((n) => SILENCING.has(bare(n)));
3267
- // Split the turn's content into ordered prose segments, broken at
3268
- // each tool_use / tool_result boundary. `response.content` holds the
3269
- // WHOLE turn's blocks in provider order — earlier tool rounds
3270
- // included (see the trailing-slice note above, which slices exactly
3271
- // because the full turn is present) — so a left-to-right walk
3272
- // reconstructs the emission order. Interleaved prose
3273
- // ("msgA → [tool] → msgB → [tool] → msgC") is then delivered as
3274
- // separate messages IN ORDER instead of being collapsed into one
3275
- // trailing post (item 4). The silencing rule stays turn-wide: an
3276
- // explicit send / skip suppresses ALL auto-routed prose this turn,
3277
- // preserving the double-post guard.
3278
- 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;
3279
3563
  if (silenced || segments.length === 0) {
3280
- console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> NOT routed ` +
3281
- `(${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'})`);
3282
3566
  }
3283
3567
  else {
3284
- // Snapshot the locus ONCE. This loop dispatches AFTER the agent
3285
- // is idle (see PR #32 note below), so a queued inbound from
3286
- // another channel can start a NEW turn and overwrite the per-agent
3287
- // triggering channel (`activeTriggerChannels`) BETWEEN segments —
3288
- // splitting one turn's reply across channels (the residual
3289
- // multi-segment race left by 83dedf8's per-agent locus). Pin every
3290
- // segment of THIS turn to the channel resolved right now.
3291
- const turnLocus = this.channelRegistry.resolveLocus(agent.name);
3292
- 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)'}`);
3293
3577
  // Deliver sequentially (await each) so the segments land in order.
3294
3578
  for (const seg of segments) {
3295
3579
  try {
3296
- await this.channelRegistry.routeSpeech(agent.name, seg, turnLocus);
3580
+ await this.channelRegistry.routeSpeech(agent.name, seg, locus);
3297
3581
  }
3298
3582
  catch (err) {
3299
3583
  console.error('speech routing failed:', err);
@@ -3301,9 +3585,8 @@ export class AgentFramework {
3301
3585
  }
3302
3586
  }
3303
3587
  }
3304
- // NOTE: agent.reset() + onInferenceEnded() already ran above, BEFORE
3305
- // dispatchSpeech (main's zombie-subagent fix, PR #32 — sync listeners
3306
- // must see status === 'idle'). Locus routing is speech dispatch and
3588
+ // NOTE: agent.reset() + onInferenceEnded() already ran above,
3589
+ // BEFORE dispatchSpeech. Locus routing is speech dispatch and
3307
3590
  // doesn't depend on the status field, so it correctly runs after.
3308
3591
  break;
3309
3592
  }
@@ -3335,16 +3618,20 @@ export class AgentFramework {
3335
3618
  await this.startAgentStream(agent, trigger, attempt + 1);
3336
3619
  }
3337
3620
  else {
3621
+ this.settleAgent(agent.name, {
3622
+ stopReason: 'exhausted',
3623
+ speech: '',
3624
+ error: err.message,
3625
+ });
3338
3626
  this.emitTrace({
3339
3627
  type: 'inference:exhausted',
3340
3628
  agentName: agent.name,
3341
3629
  error: err.message,
3342
3630
  // Drives the poison-history breaker. `retryable` is kept for
3343
- // observability, but the breaker gates on `errorType` — only an
3631
+ // observability, but the breakers gate on `errorType` — only an
3344
3632
  // `invalid_request` (a 400-class rejection of the history
3345
3633
  // itself) means retrying the same context can never succeed.
3346
- retryable: err instanceof MembraneError ? err.retryable : undefined,
3347
- errorType: err instanceof MembraneError ? err.type : undefined,
3634
+ ...this.classifyInferenceError(err),
3348
3635
  });
3349
3636
  this.eventGate?.onInferenceEnded(agent.name);
3350
3637
  if (action.emit) {
@@ -3354,12 +3641,28 @@ export class AgentFramework {
3354
3641
  break;
3355
3642
  }
3356
3643
  case 'aborted': {
3644
+ // Framework-initiated, non-terminal cancels (endTurn tool result,
3645
+ // context-budget restart) also surface here as `aborted` — but
3646
+ // the turn either already settled (endTurn) or a replacement
3647
+ // stream is queued (budget restart). Neither is a failure: no
3648
+ // reset, no settle, no spurious inference:exhausted (which would
3649
+ // reject an ephemeral's promise mid-run and bump the failure
3650
+ // streak). Gate release + stream teardown happen in `finally`.
3651
+ if (this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`)) {
3652
+ this.eventGate?.onInferenceEnded(agent.name);
3653
+ return;
3654
+ }
3357
3655
  const reason = event.reason ?? 'unknown';
3358
3656
  // Only reset if this is still the active stream (a budget restart
3359
3657
  // may have already started a new stream, bumping streamId)
3360
3658
  if (agent.streamId === myStreamId) {
3361
3659
  const durationMs = Date.now() - startTime;
3362
3660
  agent.reset();
3661
+ this.settleAgent(agent.name, {
3662
+ stopReason: 'exhausted',
3663
+ speech: '',
3664
+ error: `Stream aborted: ${reason}`,
3665
+ });
3363
3666
  this.emitTrace({
3364
3667
  type: 'inference:exhausted',
3365
3668
  agentName: agent.name,
@@ -3429,10 +3732,16 @@ export class AgentFramework {
3429
3732
  error: err.message,
3430
3733
  stack: err.stack,
3431
3734
  });
3735
+ this.settleAgent(agent.name, {
3736
+ stopReason: 'exhausted',
3737
+ speech: '',
3738
+ error: err.message,
3739
+ });
3432
3740
  this.emitTrace({
3433
3741
  type: 'inference:exhausted',
3434
3742
  agentName: agent.name,
3435
3743
  error: err.message,
3744
+ ...this.classifyInferenceError(err),
3436
3745
  });
3437
3746
  // Postmortem 2026-05-28 P2 #7: catch-path failures (stream itself
3438
3747
  // threw) were previously only visible via in-memory trace listeners.
@@ -3462,6 +3771,7 @@ export class AgentFramework {
3462
3771
  // Stop the typing indicator on every exit path (complete, error,
3463
3772
  // exhausted, abort) so it never sticks after the turn ends.
3464
3773
  this.channelRegistry?.stopTyping();
3774
+ this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
3465
3775
  this.activeStreams.delete(agent.name);
3466
3776
  this.pendingAssistantBlocks.delete(agent.name);
3467
3777
  // A conversation fork whose TTL closure turn just finished is done for
@@ -3665,6 +3975,11 @@ export class AgentFramework {
3665
3975
  this.dispatchChannelToolCall(agentName, enrichedCall);
3666
3976
  return;
3667
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
+ }
3668
3983
  // Route gate_status tool
3669
3984
  if (enrichedCall.name === 'gate_status' && this.eventGate) {
3670
3985
  this.dispatchGateToolCall(agentName, enrichedCall);
@@ -3765,12 +4080,19 @@ export class AgentFramework {
3765
4080
  if (!agent) {
3766
4081
  throw new Error('No agents configured');
3767
4082
  }
3768
- // If any agent has pending assistant blocks (tool_use not yet flushed),
3769
- // 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.
3770
4092
  const hasToolResult = content.some(b => b.type === 'tool_result');
3771
- if (!hasToolResult && this.pendingAssistantBlocks.size > 0) {
4093
+ if (!hasToolResult && (this.pendingAssistantBlocks.size > 0 || this.activeStreams.has(agent.name))) {
3772
4094
  this.deferredMessages.push({ participant, content, metadata });
3773
- return ''; // Deferred — will be added after tool_result flush
4095
+ return ''; // Deferred — flushed at the next boundary
3774
4096
  }
3775
4097
  return agent.getContextManager().addMessage(participant, content, metadata);
3776
4098
  }
@@ -3979,6 +4301,27 @@ export class AgentFramework {
3979
4301
  }
3980
4302
  return streak;
3981
4303
  }
4304
+ /**
4305
+ * Classify a terminal inference error for the `inference:exhausted` trace.
4306
+ * Single-sourced for every emit site: the downstream breakers (poison-history
4307
+ * quarantine, OverBudget drain kick) gate on `errorType`, so classification
4308
+ * drift between sites would silently disable a safety net.
4309
+ *
4310
+ * context-manager's OverBudgetError is recognized by `err.name`: CM does not
4311
+ * export the class from its package root, so a cross-package `instanceof` is
4312
+ * unavailable — but `name` is set in its constructor and survives the package
4313
+ * boundary. Deliberately NOT a message match: the message wording belongs to
4314
+ * CM and can be reworded without warning.
4315
+ */
4316
+ classifyInferenceError(err) {
4317
+ if (err instanceof MembraneError) {
4318
+ return { retryable: err.retryable, errorType: err.type };
4319
+ }
4320
+ if (err.name === 'OverBudgetError') {
4321
+ return { errorType: 'over_budget' };
4322
+ }
4323
+ return {};
4324
+ }
3982
4325
  noteInferenceExhausted(agentName, reason, retryable, errorType) {
3983
4326
  const streak = (this.consecutiveInferenceFailures.get(agentName) ?? 0) + 1;
3984
4327
  this.consecutiveInferenceFailures.set(agentName, streak);
@@ -4006,6 +4349,42 @@ export class AgentFramework {
4006
4349
  console.error(`[inference-failed] could not record chronicle marker for ${agentName}:`, err);
4007
4350
  }
4008
4351
  }
4352
+ // (2b) OverBudget deadlock breaker. When compile fails with
4353
+ // OverBudgetError, the normal compression drain never runs: it is driven
4354
+ // by successful activity, which the over-budget state prevents - a closed
4355
+ // loop with no internal exit. Field data (2026-07-10, resident agent):
4356
+ // 36 minutes hard-down, zero self-rescue; only an operator raising the
4357
+ // budget externally could break the loop. Break it here instead: kick the
4358
+ // strategy drain directly so folding/merging frees space for the next
4359
+ // compile. Bounded ticks, best-effort, never throws, one kick per agent
4360
+ // at a time (see overBudgetDrainInFlight).
4361
+ //
4362
+ // Gates on the classified errorType (see classifyInferenceError); the
4363
+ // message match is only a fallback for paths that lost the Error object
4364
+ // (e.g. a reason string that crossed a serialization boundary). It matches
4365
+ // CM's current OverBudgetError wording and MAY rot if CM rewords it — the
4366
+ // errorType gate is the one that's load-bearing.
4367
+ const overBudget = errorType === 'over_budget' || /exceed hard budget/i.test(reason);
4368
+ if (agent && overBudget && !this.overBudgetDrainInFlight.has(agentName)) {
4369
+ this.overBudgetDrainInFlight.add(agentName);
4370
+ void (async () => {
4371
+ let ticks = 0;
4372
+ try {
4373
+ const cm = agent.getContextManager();
4374
+ while (ticks < 8) {
4375
+ await cm.tick();
4376
+ ticks++;
4377
+ }
4378
+ console.error(`[inference-failed] drain kicked for ${agentName} (OverBudget breaker, ${ticks} ticks)`);
4379
+ }
4380
+ catch (err) {
4381
+ console.error(`[inference-failed] drain kick failed for ${agentName} after ${ticks} ticks:`, err);
4382
+ }
4383
+ finally {
4384
+ this.overBudgetDrainInFlight.delete(agentName);
4385
+ }
4386
+ })();
4387
+ }
4009
4388
  // (3) Hard-down escalation on repeated identical failure.
4010
4389
  if (streak >= this.inferenceFailureEscalationThreshold) {
4011
4390
  console.error(`[inference-hard-down] agent=${agentName} has FAILED ${streak} consecutive ` +
@@ -4101,6 +4480,7 @@ export class AgentFramework {
4101
4480
  this.checkpointManager = new CheckpointManager(this.store, (event) => this.emitTrace(event));
4102
4481
  // Channel registry (Step 7)
4103
4482
  this.channelRegistry = new ChannelRegistry(this.mcplServerRegistry, this.featureSetManager, (event) => this.pushEvent(event), (event) => this.emitTrace(event), {
4483
+ store: this.store,
4104
4484
  sendTypingFn: (serverId, channelId, metadata, op) => {
4105
4485
  const server = this.mcplServerRegistry.getServer(serverId);
4106
4486
  if (server) {
@@ -4193,8 +4573,8 @@ export class AgentFramework {
4193
4573
  this.mcplServerConfigs.set(config.id, config);
4194
4574
  // Record per-server channel subscription policy before the server
4195
4575
  // registers channels — handleRegister fires during the handshake.
4196
- if (config.channelSubscription !== undefined && this.channelRegistry) {
4197
- this.channelRegistry.setSubscriptionPolicy(config.id, config.channelSubscription);
4576
+ if (this.channelRegistry) {
4577
+ this.channelRegistry.setSubscriptionPolicy(config.id, config.channelSubscription ?? 'manual');
4198
4578
  }
4199
4579
  const connection = await this.mcplServerRegistry.addServer(config, this.mcplHostCapabilities);
4200
4580
  // Wire event listeners then flush any events that arrived during the
@@ -4900,6 +5280,84 @@ export class AgentFramework {
4900
5280
  endTurn: true,
4901
5281
  });
4902
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
+ }
4903
5361
  /** Aggregate the event-tag vocabulary: reserved chat:* core + each connected
4904
5362
  * server's declared tag ontology + gate.js status. */
4905
5363
  buildEventTagsResult() {