@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
package/src/framework.ts CHANGED
@@ -32,6 +32,7 @@ import type {
32
32
  ProcessEvent,
33
33
  EventResponse,
34
34
  ModuleProcessResponse,
35
+ AgentSettleResult,
35
36
  ToolCall,
36
37
  ToolCallEvent,
37
38
  ToolResult,
@@ -43,6 +44,9 @@ import type {
43
44
  ContextMaintenanceRun,
44
45
  ContextMaintenanceAgentRun,
45
46
  ContextMaintenanceSnapshot,
47
+ AgentRuntimeSettingsPatch,
48
+ AgentRuntimeSettingsOverrides,
49
+ AgentRuntimeSettingsSnapshot,
46
50
  } from './types/index.js';
47
51
  import { ProcessQueueImpl } from './queue.js';
48
52
  import { Agent } from './agent.js';
@@ -58,6 +62,30 @@ import { ConversationRouter } from './mcpl/conversation-router.js';
58
62
  import { safeSlice } from './safe-slice.js';
59
63
  import { toolResultDataToHistoryString } from './tool-result-history.js';
60
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()!;
61
89
  import { CheckpointManager } from './mcpl/checkpoint-manager.js';
62
90
  import { isToolAllowed } from './mcpl/tool-policy.js';
63
91
  import { EventGate } from './gate/event-gate.js';
@@ -229,6 +257,23 @@ interface RewindRecord {
229
257
  discordRef?: { channelId: string; messageId: string };
230
258
  }
231
259
 
260
+ interface Deferred<T> {
261
+ promise: Promise<T>;
262
+ resolve: (value: T) => void;
263
+ reject: (reason?: unknown) => void;
264
+ }
265
+
266
+ /** Per-run state for a framework-driven ephemeral agent: the settle signal
267
+ * the caller awaits plus the liveness/progress bookkeeping the watchdogs and
268
+ * the stream driver share. One map entry per run — created and torn down in
269
+ * runEphemeralToCompletion — so the pieces cannot desync. */
270
+ interface EphemeralRun {
271
+ settle: Deferred<AgentSettleResult>;
272
+ inferenceStarted: boolean;
273
+ lastActivity: number;
274
+ toolCallsCount: number;
275
+ }
276
+
232
277
  /** Cap an API error message for inline use in a rewind marker: keep enough to
233
278
  * identify the failure class without pasting a wall of provider JSON into the
234
279
  * agent's context. */
@@ -274,7 +319,25 @@ export class AgentFramework {
274
319
  private processLoggingPersist: boolean;
275
320
  private processLoggingBroadcast: boolean;
276
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();
277
329
  private pendingAssistantBlocks: Map<string, ContentBlock[]> = new Map();
330
+ /** Streams the FRAMEWORK cancelled for non-terminal reasons, keyed
331
+ * `${agentName}:${streamId}`: an endTurn tool result or a context-budget
332
+ * restart. The membrane still delivers an `aborted` event for these, and
333
+ * without the marker the abort handler would treat it as a terminal
334
+ * failure — settling ephemerals with a rejection and emitting a spurious
335
+ * `inference:exhausted` (which also pollutes the failure streak). Kept
336
+ * separate from ephemeralRuns deliberately: endTurn/budget cancels happen
337
+ * for resident agents too, and the key is per-stream, not per-agent. */
338
+ private frameworkCancelledStreams: Map<string, 'turn_ended' | 'budget_restart'> = new Map();
339
+ /** Active runEphemeralToCompletion runs, keyed by agent name. */
340
+ private ephemeralRuns: Map<string, EphemeralRun> = new Map();
278
341
  /** Per-agent count of consecutive exhausted inferences (reset on any success).
279
342
  * Drives hard-down escalation — see noteInferenceExhausted. */
280
343
  private consecutiveInferenceFailures: Map<string, number> = new Map();
@@ -303,6 +366,13 @@ export class AgentFramework {
303
366
  * Bounds the automatic quarantine loop the same way refusalRewinds bounds
304
367
  * the refusal loop, so the breaker can never shed the whole history. */
305
368
  private exhaustionRewinds: Map<string, number> = new Map();
369
+ /** Agents with an OverBudget drain kick currently in flight. The breaker in
370
+ * noteInferenceExhausted fires on EVERY matching failure and the scenario it
371
+ * exists for is "every activation fails, repeatedly" — without this guard,
372
+ * overlapping kicks race the strategy's own pendingCompression gate and the
373
+ * tick counts in the success log stop meaning anything. One kick per agent
374
+ * at a time; cleared when the kick settles (success or failure). */
375
+ private overBudgetDrainInFlight: Set<string> = new Set();
306
376
  /** Per-agent current rewind episode: the single consolidated marker's id and
307
377
  * how many turns have been shed so far. One marker per episode, updated in
308
378
  * place (see updateRewindMarker); cleared when the episode ends. */
@@ -732,9 +802,16 @@ export class AgentFramework {
732
802
  private startQueuedMaintenance(): void {
733
803
  if (this.maintenancePass || !this.running) return;
734
804
  const pass = this.runQueuedMaintenance().catch((error) => {
805
+ const reason = error instanceof Error ? error.message : String(error);
735
806
  console.error(
736
807
  '[context-maintenance] pass failed:',
737
- error instanceof Error ? error.message : error,
808
+ reason,
809
+ );
810
+ this.opsAlert(
811
+ 'context-maintenance-failed',
812
+ 'framework',
813
+ reason,
814
+ { data: { scope: 'pass' } },
738
815
  );
739
816
  });
740
817
  this.maintenancePass = pass;
@@ -789,6 +866,18 @@ export class AgentFramework {
789
866
  } catch (error) {
790
867
  record.error = error instanceof Error ? error.message : String(error);
791
868
  console.error(`[context-maintenance] agent=${agent.name} failed:`, record.error);
869
+ this.opsAlert(
870
+ 'context-maintenance-failed',
871
+ agent.name,
872
+ record.error,
873
+ {
874
+ data: {
875
+ scope: 'agent',
876
+ ...(record.pendingBefore ? { pending: record.pendingBefore } : {}),
877
+ ...(record.progressBefore ? { progress: record.progressBefore } : {}),
878
+ },
879
+ },
880
+ );
792
881
  } finally {
793
882
  const pending = cm.getPendingWork()?.description;
794
883
  record.finishedAt = Date.now();
@@ -934,10 +1023,49 @@ export class AgentFramework {
934
1023
  AgentFramework.EVENT_TAGS_TOOL,
935
1024
  ]
936
1025
  : [];
937
- if (this.mcplTools.length === 0 && channelTools.length === 0 && gateTools.length === 0) {
938
- return moduleTools;
939
- }
940
- 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;
941
1069
  }
942
1070
 
943
1071
  /** Counts-only context-maintenance diagnostics for authenticated debug UIs. */
@@ -1139,6 +1267,33 @@ export class AgentFramework {
1139
1267
  }
1140
1268
  }
1141
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
+
1142
1297
  /**
1143
1298
  * Create an ephemeral agent that is NOT registered in the main event loop.
1144
1299
  *
@@ -1177,6 +1332,54 @@ export class AgentFramework {
1177
1332
  return { agent, contextManager, cleanup };
1178
1333
  }
1179
1334
 
1335
+ private createDeferred<T>(): Deferred<T> {
1336
+ let resolve!: (value: T) => void;
1337
+ let reject!: (reason?: unknown) => void;
1338
+ const promise = new Promise<T>((res, rej) => {
1339
+ resolve = res;
1340
+ reject = rej;
1341
+ });
1342
+ return { promise, resolve, reject };
1343
+ }
1344
+
1345
+ /** Liveness ping for an active ephemeral run (no-op otherwise). */
1346
+ private touchEphemeralRun(agentName: string, inferenceStarted = false): void {
1347
+ const run = this.ephemeralRuns.get(agentName);
1348
+ if (!run) return;
1349
+ if (inferenceStarted) {
1350
+ run.inferenceStarted = true;
1351
+ }
1352
+ run.lastActivity = Date.now();
1353
+ }
1354
+
1355
+ private recordEphemeralToolCalls(agentName: string, count: number): void {
1356
+ const run = this.ephemeralRuns.get(agentName);
1357
+ if (!run) return;
1358
+ run.toolCallsCount += count;
1359
+ run.lastActivity = Date.now();
1360
+ }
1361
+
1362
+ /** Settle an active ephemeral run (no-op for resident agents). The tool-call
1363
+ * count comes from the run's own counter — the single source of truth,
1364
+ * which unlike a per-stream local survives budget restarts. */
1365
+ private settleAgent(
1366
+ agentName: string,
1367
+ outcome: { stopReason: AgentSettleResult['stopReason']; speech: string; error?: string },
1368
+ ): void {
1369
+ const run = this.ephemeralRuns.get(agentName);
1370
+ if (!run) return;
1371
+ run.lastActivity = Date.now();
1372
+ if (outcome.stopReason === 'exhausted') {
1373
+ run.settle.reject(new Error(outcome.error ?? 'Unknown error'));
1374
+ return;
1375
+ }
1376
+ run.settle.resolve({
1377
+ stopReason: outcome.stopReason,
1378
+ speech: outcome.speech,
1379
+ toolCallsCount: run.toolCallsCount,
1380
+ });
1381
+ }
1382
+
1180
1383
  /**
1181
1384
  * Run an ephemeral agent to completion through the framework's event loop.
1182
1385
  *
@@ -1190,42 +1393,31 @@ export class AgentFramework {
1190
1393
  async runEphemeralToCompletion(
1191
1394
  agent: Agent,
1192
1395
  contextManager: ContextManager,
1396
+ watchdogs?: { startupTimeoutMs?: number; idleTimeoutMs?: number; idlePollMs?: number },
1193
1397
  ): Promise<{ speech: string; toolCallsCount: number }> {
1194
1398
  // Register temporarily so the event loop can drive it
1195
1399
  this.agents.set(agent.name, agent);
1400
+ const run: EphemeralRun = {
1401
+ settle: this.createDeferred<AgentSettleResult>(),
1402
+ inferenceStarted: false,
1403
+ lastActivity: Date.now(),
1404
+ toolCallsCount: 0,
1405
+ };
1406
+ this.ephemeralRuns.set(agent.name, run);
1196
1407
 
1197
- return new Promise<{ speech: string; toolCallsCount: number }>((resolve, reject) => {
1198
- let speech = '';
1199
- let toolCallsCount = 0;
1200
- let settled = false;
1201
- let inferenceStarted = false;
1202
- let lastActivity = Date.now();
1203
-
1204
- const STARTUP_TIMEOUT_MS = 30_000;
1205
- // After inference has started, give it 15 minutes of activity-bounded
1206
- // life. Each addressed trace event refreshes the deadline; only
1207
- // sustained silence trips it. Subagents that legitimately stream for
1208
- // 10+ minutes (e.g. long fork investigations) get the extra slack but
1209
- // stalled streams that emit `inference:completed` and then go quiet —
1210
- // the exact zombie shape we saw in production — are caught here.
1211
- const COMPLETION_IDLE_TIMEOUT_MS = 15 * 60_000;
1212
-
1213
- const cleanup = () => {
1214
- if (settled) return;
1215
- settled = true;
1216
- clearTimeout(startupWatchdog);
1217
- clearInterval(completionWatchdog);
1218
- this.offTrace(traceListener);
1219
- this.agents.delete(agent.name);
1220
- this.evictTurnCheckpoints(agent.name);
1221
- };
1408
+ const STARTUP_TIMEOUT_MS = watchdogs?.startupTimeoutMs ?? 30_000;
1409
+ // After inference has started, give it 15 minutes of activity-bounded
1410
+ // life. The stream driver refreshes the deadline as it makes progress;
1411
+ // sustained silence trips it.
1412
+ const COMPLETION_IDLE_TIMEOUT_MS = watchdogs?.idleTimeoutMs ?? 15 * 60_000;
1413
+ const IDLE_POLL_MS = watchdogs?.idlePollMs ?? 30_000;
1222
1414
 
1223
- // Watchdog: if inference never starts within 30s, the agent is a zombie.
1224
- // This catches cases where the event loop stalled, the agent was
1225
- // deregistered, or processInferenceRequests() skipped the request.
1226
- const startupWatchdog = setTimeout(() => {
1227
- if (!inferenceStarted && !settled) {
1228
- cleanup();
1415
+ let startupWatchdog: ReturnType<typeof setTimeout> | null = null;
1416
+ let completionWatchdog: ReturnType<typeof setInterval> | null = null;
1417
+
1418
+ const startupTimeout = new Promise<never>((_, reject) => {
1419
+ startupWatchdog = setTimeout(() => {
1420
+ if (!run.inferenceStarted) {
1229
1421
  reject(new Error(
1230
1422
  `Ephemeral agent "${agent.name}" failed to start inference within ` +
1231
1423
  `${STARTUP_TIMEOUT_MS}ms — zombie detected. The event loop may have ` +
@@ -1233,104 +1425,48 @@ export class AgentFramework {
1233
1425
  ));
1234
1426
  }
1235
1427
  }, STARTUP_TIMEOUT_MS);
1428
+ });
1236
1429
 
1237
- // Completion watchdog: once inference has started, reject if no trace
1238
- // activity for COMPLETION_IDLE_TIMEOUT_MS. Polls every 30s. Without
1239
- // this, an ephemeral that loses its terminal event (e.g. due to a
1240
- // future ordering regression or an upstream stream drop) hangs forever
1241
- // and keeps its concurrency slot pinned.
1242
- const completionWatchdog = setInterval(() => {
1243
- if (settled || !inferenceStarted) return;
1244
- const idle = Date.now() - lastActivity;
1430
+ const completionIdleTimeout = new Promise<never>((_, reject) => {
1431
+ completionWatchdog = setInterval(() => {
1432
+ if (!run.inferenceStarted) return;
1433
+ const idle = Date.now() - run.lastActivity;
1245
1434
  if (idle > COMPLETION_IDLE_TIMEOUT_MS) {
1246
- cleanup();
1247
1435
  reject(new Error(
1248
- `Ephemeral agent "${agent.name}" stalled: no trace activity for ` +
1436
+ `Ephemeral agent "${agent.name}" stalled: no stream-driver activity for ` +
1249
1437
  `${Math.round(idle / 1000)}s after inference started (threshold ` +
1250
1438
  `${Math.round(COMPLETION_IDLE_TIMEOUT_MS / 1000)}s). Stream likely ` +
1251
1439
  `dropped or terminal event was lost.`
1252
1440
  ));
1253
1441
  }
1254
- }, 30_000);
1255
-
1256
- const traceListener = (event: TraceEvent) => {
1257
- if (settled) return;
1258
- // Only track events for our ephemeral agent
1259
- const agentName = 'agentName' in event ? (event as { agentName: string }).agentName : null;
1260
- if (agentName !== agent.name) return;
1261
-
1262
- // Any addressed event counts as liveness for the completion watchdog.
1263
- lastActivity = Date.now();
1264
-
1265
- switch (event.type) {
1266
- case 'inference:started':
1267
- inferenceStarted = true;
1268
- break;
1269
- case 'inference:tokens': {
1270
- inferenceStarted = true;
1271
- const content = (event as { content?: string }).content;
1272
- if (content) speech += content;
1273
- break;
1274
- }
1275
- case 'inference:tool_calls_yielded': {
1276
- const calls = (event as { calls?: Array<unknown> }).calls;
1277
- if (calls) toolCallsCount += calls.length;
1278
- // Reset speech buffer — tool calls break the text
1279
- speech = '';
1280
- break;
1281
- }
1282
- case 'inference:stream_resumed':
1283
- speech = '';
1284
- break;
1285
- case 'inference:completed': {
1286
- // `inference:completed` is terminal — driveStream only emits it
1287
- // from `case 'complete'`, which fires after the full tool
1288
- // round-trip has finished and the model has stopped. Intermediate
1289
- // tool-cycle transitions use `inference:stream_resumed`, not
1290
- // `inference:completed`. So we resolve unconditionally here.
1291
- //
1292
- // Previously this branch gated on `agent.state.status === 'idle'`,
1293
- // which was correct in intent but order-fragile: the framework
1294
- // emitted `inference:completed` BEFORE calling `agent.reset()`,
1295
- // so synchronous listeners observed status === 'streaming' at
1296
- // the trace boundary. The gate failed silently and the promise
1297
- // hung forever — every production zombie subagent originated
1298
- // here. The reset+emit order has been fixed in driveStream's
1299
- // `case 'complete'` handler; the gate is dropped here as
1300
- // belt-and-suspenders.
1301
- cleanup();
1302
- resolve({ speech, toolCallsCount });
1303
- break;
1304
- }
1305
- case 'inference:turn_ended': {
1306
- // endTurn from a tool result — agent is done
1307
- cleanup();
1308
- resolve({ speech, toolCallsCount });
1309
- break;
1310
- }
1311
- case 'inference:exhausted': {
1312
- // Retries exhausted — reject only after the error policy gives up.
1313
- // Earlier inference:failed events are left alone so the framework
1314
- // retry path in driveStream/startAgentStream can restart the stream
1315
- // while this listener stays alive.
1316
- const error = (event as { error?: string }).error ?? 'Unknown error';
1317
- cleanup();
1318
- reject(new Error(error));
1319
- break;
1320
- }
1321
- }
1322
- };
1323
-
1324
- this.onTrace(traceListener);
1442
+ }, IDLE_POLL_MS);
1443
+ });
1325
1444
 
1326
- // Trigger inference
1445
+ try {
1446
+ // Trigger inference after the settle promise is registered.
1327
1447
  this.pendingRequests.push({
1328
1448
  agentName: agent.name,
1329
1449
  reason: 'ephemeral',
1330
1450
  source: 'subagent',
1331
1451
  timestamp: Date.now(),
1332
1452
  });
1333
- });
1453
+
1454
+ const result = await Promise.race([
1455
+ run.settle.promise,
1456
+ startupTimeout,
1457
+ completionIdleTimeout,
1458
+ ]);
1459
+ return { speech: result.speech, toolCallsCount: result.toolCallsCount };
1460
+ } finally {
1461
+ if (startupWatchdog) clearTimeout(startupWatchdog);
1462
+ if (completionWatchdog) clearInterval(completionWatchdog);
1463
+ this.ephemeralRuns.delete(agent.name);
1464
+ this.agents.delete(agent.name);
1465
+ // Spawn-and-dispose bookkeeping (main, d453165/fee96a7): without this,
1466
+ // ephemeral agents leave checkpoint-tree keys and diagnostics map
1467
+ // entries behind for the life of the store/session.
1468
+ this.evictTurnCheckpoints(agent.name);
1469
+ }
1334
1470
  }
1335
1471
 
1336
1472
  /**
@@ -1497,6 +1633,31 @@ export class AgentFramework {
1497
1633
  inputSchema: { type: 'object' },
1498
1634
  };
1499
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
+
1500
1661
  /** Reserved chat:* core vocabulary (MCPL RFC-001 §4) — short descriptions so
1501
1662
  * the agent can author rules without reading the spec. */
1502
1663
  private static readonly CHAT_CORE_TAGS: Record<string, string> = {
@@ -2565,6 +2726,8 @@ export class AgentFramework {
2565
2726
  });
2566
2727
 
2567
2728
  const agent = new Agent(config, contextManager, this.membrane);
2729
+ const restoredSettings = this.readAgentRuntimeSettings(config.name);
2730
+ if (restoredSettings) agent.restoreRuntimeSettings(restoredSettings);
2568
2731
  this.agents.set(config.name, agent);
2569
2732
  this.agentConfigs.set(config.name, config);
2570
2733
 
@@ -2619,6 +2782,9 @@ export class AgentFramework {
2619
2782
  // messages between tool_use and tool_result, causing a 400 error.
2620
2783
  if (event.type === 'tool-result') {
2621
2784
  const agent = this.agents.get(event.agentName);
2785
+ if (agent) {
2786
+ this.touchEphemeralRun(event.agentName, true);
2787
+ }
2622
2788
  if (!agent) {
2623
2789
  console.warn(
2624
2790
  `[framework] Dropping tool-result for unknown agent '${event.agentName}' (callId=${event.callId}). ` +
@@ -2669,16 +2835,51 @@ export class AgentFramework {
2669
2835
  }));
2670
2836
  agent.getContextManager().addMessage('user', toolResultContent);
2671
2837
 
2672
- // Flush any messages that were deferred while waiting for tool results.
2673
- // Route to the PRIMARY agent — deferred messages are framework-level
2674
- // (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 }> = [];
2675
2861
  if (this.deferredMessages.length > 0) {
2676
- const deferred = this.deferredMessages.splice(0);
2677
- const primary = this.primaryAgentName
2678
- ? this.agents.get(this.primaryAgentName)
2679
- : agent;
2680
- for (const msg of deferred) {
2681
- (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
+ }
2682
2883
  }
2683
2884
  }
2684
2885
 
@@ -2693,12 +2894,28 @@ export class AgentFramework {
2693
2894
  if (shouldEndTurn) {
2694
2895
  // endTurn: messages already stored above, cancel stream, reset to idle.
2695
2896
  if (currentState.stream) {
2696
- agent.cancelStream();
2897
+ this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'turn_ended');
2898
+ currentState.stream.cancel();
2697
2899
  }
2698
2900
  agent.reset();
2901
+ // The turn is over — release the gate here, not just in the abort
2902
+ // handler: the membrane's aborted event is asynchronous and the
2903
+ // agent must be wakeable the moment it goes idle. (Idempotent;
2904
+ // driveStream's finally is the backstop.)
2905
+ this.eventGate?.onInferenceEnded(agent.name);
2906
+ this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' });
2699
2907
  this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
2700
2908
  } else if (overBudget) {
2701
- // Context budget exceeded: break the stream, let compile() compress
2909
+ // Context budget exceeded: break the stream, let compile() compress.
2910
+ // Mark the cancel as framework-initiated BEFORE cancelling: the
2911
+ // membrane delivers `aborted` before the restart bumps streamId,
2912
+ // and without the marker the abort handler treats the restart as
2913
+ // a terminal failure — rejecting an ephemeral's promise mid-run
2914
+ // and emitting a spurious inference:exhausted (same race shape as
2915
+ // endTurn above).
2916
+ if (currentState.stream) {
2917
+ this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart');
2918
+ }
2702
2919
  agent.cancelStream();
2703
2920
  this.emitTrace({
2704
2921
  type: 'inference:stream_restarted',
@@ -2714,13 +2931,23 @@ export class AgentFramework {
2714
2931
  timestamp: Date.now(),
2715
2932
  });
2716
2933
  } else if (currentState.stream) {
2717
- // 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.
2718
2938
  const membraneResults = currentState.toolResults.map(tc =>
2719
2939
  this.toMembraneToolResult(tc.id, tc.result, maxChars)
2720
2940
  );
2721
- currentState.stream.provideToolResults(membraneResults);
2941
+ currentState.stream.provideToolResults(
2942
+ membraneResults,
2943
+ midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections } : undefined,
2944
+ );
2722
2945
  agent.setStreaming(currentState.stream);
2723
- 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
+ });
2724
2951
  } else {
2725
2952
  // Non-streaming fallback: schedule re-inference
2726
2953
  this.pendingRequests.push({
@@ -3174,6 +3401,15 @@ export class AgentFramework {
3174
3401
  * Convert an MCPL push event to a context message.
3175
3402
  */
3176
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
+
3177
3413
  const metadata: Record<string, unknown> = {
3178
3414
  ...event.origin,
3179
3415
  serverId: event.serverId,
@@ -3182,23 +3418,39 @@ export class AgentFramework {
3182
3418
  triggered: event.triggerInference ?? false,
3183
3419
  };
3184
3420
 
3185
- const id = this.addMessage('user', event.content, metadata);
3186
- this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
3187
-
3188
- // Some push events carry a channel of origin (Discord DMs arrive here, not
3189
- // via channels/incoming, because discord-mcpl forwards them with the channel
3190
- // closed). Register + open that channel so routeSpeech can resolve it, and
3191
- // thread it onto the wake request so the reply routes back to it instead of
3192
- // the global locus (item-3 redux, DM sub-case).
3193
- const triggerChannel = this.derivePushEventChannel(event.origin);
3194
- if (triggerChannel && this.channelRegistry) {
3195
- this.channelRegistry.ensureChannelRegistered(
3196
- event.serverId,
3197
- triggerChannel.channelId,
3198
- triggerChannel.label,
3199
- );
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;
3200
3449
  }
3201
3450
 
3451
+ const id = this.addMessage('user', content, metadata);
3452
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
3453
+
3202
3454
  if (event.triggerInference) {
3203
3455
  // Default broadcast excludes conversation forks (channel-driven).
3204
3456
  const targetAgents = event.targetAgents
@@ -3364,6 +3616,7 @@ export class AgentFramework {
3364
3616
  this.activeTriggerChannels.delete(agent.name);
3365
3617
  }
3366
3618
 
3619
+ this.touchEphemeralRun(agent.name, true);
3367
3620
  this.emitTrace({ type: 'inference:started', agentName: agent.name });
3368
3621
  this.eventGate?.onInferenceStarted(agent.name);
3369
3622
  this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
@@ -3428,17 +3681,23 @@ export class AgentFramework {
3428
3681
  await new Promise((resolve) => setTimeout(resolve, action.delayMs));
3429
3682
  await this.startAgentStream(agent, trigger, attempt + 1);
3430
3683
  } else {
3684
+ this.settleAgent(agent.name, {
3685
+ stopReason: 'exhausted',
3686
+ speech: '',
3687
+ error: err.message,
3688
+ });
3431
3689
  this.emitTrace({
3432
3690
  type: 'inference:exhausted',
3433
3691
  agentName: agent.name,
3434
3692
  error: err.message,
3435
- // Drives the poison-history breaker. `retryable` is kept for
3436
- // observability, but the breaker gates on `errorType` — only an
3437
- // `invalid_request` (a 400-class rejection of the history itself)
3438
- // means retrying the same context can never succeed. auth/abort/
3439
- // context_length are also non-retryable but must NOT cost history.
3440
- retryable: err instanceof MembraneError ? err.retryable : undefined,
3441
- errorType: err instanceof MembraneError ? err.type : undefined,
3693
+ // Drives the poison-history breaker (`invalid_request`: a 400-class
3694
+ // rejection of the history itself — retrying the same context can
3695
+ // never succeed; auth/abort/context_length are also non-retryable
3696
+ // but must NOT cost history) and the OverBudget drain breaker
3697
+ // (`over_budget`: compile refused to fit the hard budget — this is
3698
+ // the site that sees it, since compile runs before the stream
3699
+ // exists). `retryable` is kept for observability.
3700
+ ...this.classifyInferenceError(err),
3442
3701
  });
3443
3702
  this.eventGate?.onInferenceEnded(agent.name);
3444
3703
  if (action.emit) {
@@ -3460,15 +3719,72 @@ export class AgentFramework {
3460
3719
  const myStreamId = agent.streamId;
3461
3720
  let hadToolCalls = false;
3462
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
+
3463
3776
  // Typing indicator: show "<agent> is typing…" in the channel she's
3464
3777
  // responding to, for the whole duration of this turn. Started here (paired
3465
3778
  // with the finally below, so it can never leak) and refreshed on a 7s
3466
3779
  // interval by the ChannelRegistry until stopped on any exit path.
3467
- const typingChannel = this.channelRegistry?.getDefaultPublishChannel();
3780
+ const typingChannel = this.channelRegistry
3781
+ ? trigger?.channelId ?? this.channelRegistry.getDefaultPublishChannel()
3782
+ : null;
3468
3783
  if (typingChannel) this.channelRegistry!.startTyping(typingChannel);
3469
3784
 
3470
3785
  try {
3471
3786
  for await (const event of stream) {
3787
+ this.touchEphemeralRun(agent.name, true);
3472
3788
  switch (event.type) {
3473
3789
  case 'tokens':
3474
3790
  this.emitTrace({
@@ -3494,6 +3810,7 @@ export class AgentFramework {
3494
3810
 
3495
3811
  case 'tool-calls': {
3496
3812
  hadToolCalls = true;
3813
+ this.recordEphemeralToolCalls(agent.name, event.calls.length);
3497
3814
  this.emitTrace({
3498
3815
  type: 'inference:tool_calls_yielded',
3499
3816
  agentName: agent.name,
@@ -3535,6 +3852,43 @@ export class AgentFramework {
3535
3852
  for (const call of event.calls) {
3536
3853
  this.dispatchToolCall(agent.name, call);
3537
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
+ }
3538
3892
  // Stream's async iterator blocks on next() until provideToolResults() is called
3539
3893
  break;
3540
3894
  }
@@ -3593,13 +3947,15 @@ export class AgentFramework {
3593
3947
  b.type === 'tool_use' || b.type === 'tool_result' ? i : last,
3594
3948
  -1
3595
3949
  );
3950
+ const terminalContent = lastToolIdx >= 0
3951
+ ? response.content.slice(lastToolIdx + 1)
3952
+ : response.content;
3596
3953
  if (lastToolIdx >= 0) {
3597
- const trailingContent = response.content.slice(lastToolIdx + 1);
3598
- if (trailingContent.length > 0) {
3599
- agent.addAssistantResponse(trailingContent);
3954
+ if (terminalContent.length > 0) {
3955
+ agent.addAssistantResponse(terminalContent);
3600
3956
  }
3601
3957
  } else {
3602
- agent.addAssistantResponse(response.content);
3958
+ agent.addAssistantResponse(terminalContent);
3603
3959
  }
3604
3960
 
3605
3961
  // Run afterInference hooks (no-op if no MCPL servers)
@@ -3637,10 +3993,13 @@ export class AgentFramework {
3637
3993
  }
3638
3994
  }
3639
3995
 
3640
- // Separate speech from thoughts.
3641
- // When tools were used, ALL text is thoughts (the tools themselves are
3642
- // the agent's actions — surrounding text is just reasoning).
3643
- // 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.
3644
4003
  const isTextBlock = (block: ContentBlock): block is ContentBlock & { type: 'text' } =>
3645
4004
  block.type === 'text';
3646
4005
  const allText = response.content.filter(isTextBlock);
@@ -3658,17 +4017,20 @@ export class AgentFramework {
3658
4017
  }
3659
4018
  : undefined;
3660
4019
 
3661
- // Reset agent state BEFORE emitting inference:completed so that
3662
- // synchronous listeners (e.g. runEphemeralToCompletion) observe
3663
- // status === 'idle' the moment the event fires. Previously
3664
- // agent.reset() ran after `await dispatchSpeech`, which meant the
3665
- // ephemeral-completion listener saw status === 'streaming' at the
3666
- // trace boundary, failed its idle gate, and the promise hung
3667
- // forever — every "zombie subagent" in production tracked back
3668
- // here. Speech dispatch happens after but doesn't depend on the
3669
- // status field.
4020
+ // Reset agent state before emitting inference:completed. Traces are
4021
+ // observability-only, but external synchronous listeners should
4022
+ // still see the terminal state at the terminal trace boundary.
4023
+ // Speech dispatch happens after but doesn't depend on the status
4024
+ // field.
3670
4025
  agent.reset();
3671
4026
  this.eventGate?.onInferenceEnded(agent.name);
4027
+ this.settleAgent(agent.name, {
4028
+ stopReason: 'completed',
4029
+ speech: terminalContent
4030
+ .filter((block: ContentBlock): block is ContentBlock & { type: 'text' } => block.type === 'text')
4031
+ .map((block) => block.text)
4032
+ .join('\n'),
4033
+ });
3672
4034
 
3673
4035
  this.emitTrace({
3674
4036
  type: 'inference:completed',
@@ -3837,79 +4199,62 @@ export class AgentFramework {
3837
4199
  }
3838
4200
  }
3839
4201
  } else if (this.channelRegistry && hadToolCalls && allText.length > 0) {
3840
- // Tool-call turn that also produced prose. Per design, route the
3841
- // prose to the locus as a reply UNLESS the turn used an explicit
3842
- // send/publish tool (channel_publish, *--send_message,
3843
- // *--reply_message, *--send_dm) — already delivered, so routing
3844
- // again would double-post. `think` and other non-sending tools
3845
- // (shell, workspace, etc.) still let the agent speak in the same
3846
- // turn. The global speech/thoughts split is left untouched
3847
- // (module/TUI rendering unaffected) — this only governs what
3848
- // reaches the channel.
3849
- // Tools whose presence suppresses the trailing prose, for two
3850
- // distinct reasons:
3851
- // - `skip_reply` — the agent EXPLICITLY chose not to reply this
3852
- // turn (the deliberate "stay silent" signal).
3853
- // - explicit delivery tools (channel_publish / *send_message /
3854
- // *reply_message / *send_dm) — already sent the message, so
3855
- // routing it again would double-post.
3856
- // `think` is deliberately NOT here: it is silent *reasoning*, but
3857
- // prose written after a think is the agent's actual reply and must
3858
- // be delivered. (A think-only turn has no trailing prose and stays
3859
- // silent via the `!t` check below.)
3860
- const SILENCING = new Set([
3861
- 'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
3862
- ]);
3863
- 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.
3864
4216
  const toolNames = response.content
3865
4217
  .filter((b) => b.type === 'tool_use')
3866
4218
  .map((b) => (b as unknown as { name?: string }).name)
3867
4219
  .filter((n): n is string => typeof n === 'string');
3868
- const silenced = toolNames.some((n) => SILENCING.has(bare(n)));
3869
-
3870
- // Split the turn's content into ordered prose segments, broken at
3871
- // each tool_use / tool_result boundary. `response.content` holds the
3872
- // WHOLE turn's blocks in provider order — earlier tool rounds
3873
- // included (see the trailing-slice note above, which slices exactly
3874
- // because the full turn is present) — so a left-to-right walk
3875
- // reconstructs the emission order. Interleaved prose
3876
- // ("msgA → [tool] → msgB → [tool] → msgC") is then delivered as
3877
- // separate messages IN ORDER instead of being collapsed into one
3878
- // trailing post (item 4). The silencing rule stays turn-wide: an
3879
- // explicit send / skip suppresses ALL auto-routed prose this turn,
3880
- // preserving the double-post guard.
3881
- 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;
3882
4228
 
3883
4229
  if (silenced || segments.length === 0) {
3884
4230
  console.error(
3885
- `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> NOT routed ` +
3886
- `(${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'})`,
3887
4233
  );
3888
4234
  } else {
3889
- // Snapshot the locus ONCE. This loop dispatches AFTER the agent
3890
- // is idle (see PR #32 note below), so a queued inbound from
3891
- // another channel can start a NEW turn and overwrite the per-agent
3892
- // triggering channel (`activeTriggerChannels`) BETWEEN segments —
3893
- // splitting one turn's reply across channels (the residual
3894
- // multi-segment race left by 83dedf8's per-agent locus). Pin every
3895
- // segment of THIS turn to the channel resolved right now.
3896
- 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();
3897
4243
  console.error(
3898
- `[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)'}`,
3899
4245
  );
3900
4246
  // Deliver sequentially (await each) so the segments land in order.
3901
4247
  for (const seg of segments) {
3902
4248
  try {
3903
- await this.channelRegistry.routeSpeech(agent.name, seg, turnLocus);
4249
+ await this.channelRegistry.routeSpeech(agent.name, seg, locus);
3904
4250
  } catch (err) {
3905
4251
  console.error('speech routing failed:', err);
3906
4252
  }
3907
4253
  }
3908
4254
  }
3909
4255
  }
3910
- // NOTE: agent.reset() + onInferenceEnded() already ran above, BEFORE
3911
- // dispatchSpeech (main's zombie-subagent fix, PR #32 — sync listeners
3912
- // must see status === 'idle'). Locus routing is speech dispatch and
4256
+ // NOTE: agent.reset() + onInferenceEnded() already ran above,
4257
+ // BEFORE dispatchSpeech. Locus routing is speech dispatch and
3913
4258
  // doesn't depend on the status field, so it correctly runs after.
3914
4259
 
3915
4260
  break;
@@ -3945,16 +4290,20 @@ export class AgentFramework {
3945
4290
  await new Promise((resolve) => setTimeout(resolve, action.delayMs));
3946
4291
  await this.startAgentStream(agent, trigger, attempt + 1);
3947
4292
  } else {
4293
+ this.settleAgent(agent.name, {
4294
+ stopReason: 'exhausted',
4295
+ speech: '',
4296
+ error: err.message,
4297
+ });
3948
4298
  this.emitTrace({
3949
4299
  type: 'inference:exhausted',
3950
4300
  agentName: agent.name,
3951
4301
  error: err.message,
3952
4302
  // Drives the poison-history breaker. `retryable` is kept for
3953
- // observability, but the breaker gates on `errorType` — only an
4303
+ // observability, but the breakers gate on `errorType` — only an
3954
4304
  // `invalid_request` (a 400-class rejection of the history
3955
4305
  // itself) means retrying the same context can never succeed.
3956
- retryable: err instanceof MembraneError ? err.retryable : undefined,
3957
- errorType: err instanceof MembraneError ? err.type : undefined,
4306
+ ...this.classifyInferenceError(err),
3958
4307
  });
3959
4308
  this.eventGate?.onInferenceEnded(agent.name);
3960
4309
  if (action.emit) {
@@ -3965,12 +4314,28 @@ export class AgentFramework {
3965
4314
  }
3966
4315
 
3967
4316
  case 'aborted': {
4317
+ // Framework-initiated, non-terminal cancels (endTurn tool result,
4318
+ // context-budget restart) also surface here as `aborted` — but
4319
+ // the turn either already settled (endTurn) or a replacement
4320
+ // stream is queued (budget restart). Neither is a failure: no
4321
+ // reset, no settle, no spurious inference:exhausted (which would
4322
+ // reject an ephemeral's promise mid-run and bump the failure
4323
+ // streak). Gate release + stream teardown happen in `finally`.
4324
+ if (this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`)) {
4325
+ this.eventGate?.onInferenceEnded(agent.name);
4326
+ return;
4327
+ }
3968
4328
  const reason = event.reason ?? 'unknown';
3969
4329
  // Only reset if this is still the active stream (a budget restart
3970
4330
  // may have already started a new stream, bumping streamId)
3971
4331
  if (agent.streamId === myStreamId) {
3972
4332
  const durationMs = Date.now() - startTime;
3973
4333
  agent.reset();
4334
+ this.settleAgent(agent.name, {
4335
+ stopReason: 'exhausted',
4336
+ speech: '',
4337
+ error: `Stream aborted: ${reason}`,
4338
+ });
3974
4339
  this.emitTrace({
3975
4340
  type: 'inference:exhausted',
3976
4341
  agentName: agent.name,
@@ -4046,10 +4411,16 @@ export class AgentFramework {
4046
4411
  error: err.message,
4047
4412
  stack: err.stack,
4048
4413
  });
4414
+ this.settleAgent(agent.name, {
4415
+ stopReason: 'exhausted',
4416
+ speech: '',
4417
+ error: err.message,
4418
+ });
4049
4419
  this.emitTrace({
4050
4420
  type: 'inference:exhausted',
4051
4421
  agentName: agent.name,
4052
4422
  error: err.message,
4423
+ ...this.classifyInferenceError(err),
4053
4424
  });
4054
4425
  // Postmortem 2026-05-28 P2 #7: catch-path failures (stream itself
4055
4426
  // threw) were previously only visible via in-memory trace listeners.
@@ -4079,6 +4450,7 @@ export class AgentFramework {
4079
4450
  // Stop the typing indicator on every exit path (complete, error,
4080
4451
  // exhausted, abort) so it never sticks after the turn ends.
4081
4452
  this.channelRegistry?.stopTyping();
4453
+ this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
4082
4454
  this.activeStreams.delete(agent.name);
4083
4455
  this.pendingAssistantBlocks.delete(agent.name);
4084
4456
 
@@ -4298,6 +4670,12 @@ export class AgentFramework {
4298
4670
  return;
4299
4671
  }
4300
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
+
4301
4679
  // Route gate_status tool
4302
4680
  if (enrichedCall.name === 'gate_status' && this.eventGate) {
4303
4681
  this.dispatchGateToolCall(agentName, enrichedCall);
@@ -4416,12 +4794,19 @@ export class AgentFramework {
4416
4794
  throw new Error('No agents configured');
4417
4795
  }
4418
4796
 
4419
- // If any agent has pending assistant blocks (tool_use not yet flushed),
4420
- // 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.
4421
4806
  const hasToolResult = content.some(b => b.type === 'tool_result');
4422
- if (!hasToolResult && this.pendingAssistantBlocks.size > 0) {
4807
+ if (!hasToolResult && (this.pendingAssistantBlocks.size > 0 || this.activeStreams.has(agent.name))) {
4423
4808
  this.deferredMessages.push({ participant, content, metadata });
4424
- return '' as MessageId; // Deferred — will be added after tool_result flush
4809
+ return '' as MessageId; // Deferred — flushed at the next boundary
4425
4810
  }
4426
4811
 
4427
4812
  return agent.getContextManager().addMessage(participant, content, metadata);
@@ -4667,6 +5052,28 @@ export class AgentFramework {
4667
5052
  return streak;
4668
5053
  }
4669
5054
 
5055
+ /**
5056
+ * Classify a terminal inference error for the `inference:exhausted` trace.
5057
+ * Single-sourced for every emit site: the downstream breakers (poison-history
5058
+ * quarantine, OverBudget drain kick) gate on `errorType`, so classification
5059
+ * drift between sites would silently disable a safety net.
5060
+ *
5061
+ * context-manager's OverBudgetError is recognized by `err.name`: CM does not
5062
+ * export the class from its package root, so a cross-package `instanceof` is
5063
+ * unavailable — but `name` is set in its constructor and survives the package
5064
+ * boundary. Deliberately NOT a message match: the message wording belongs to
5065
+ * CM and can be reworded without warning.
5066
+ */
5067
+ private classifyInferenceError(err: Error): { retryable?: boolean; errorType?: string } {
5068
+ if (err instanceof MembraneError) {
5069
+ return { retryable: err.retryable, errorType: err.type };
5070
+ }
5071
+ if (err.name === 'OverBudgetError') {
5072
+ return { errorType: 'over_budget' };
5073
+ }
5074
+ return {};
5075
+ }
5076
+
4670
5077
  private noteInferenceExhausted(
4671
5078
  agentName: string,
4672
5079
  reason: string,
@@ -4707,6 +5114,41 @@ export class AgentFramework {
4707
5114
  }
4708
5115
  }
4709
5116
 
5117
+ // (2b) OverBudget deadlock breaker. When compile fails with
5118
+ // OverBudgetError, the normal compression drain never runs: it is driven
5119
+ // by successful activity, which the over-budget state prevents - a closed
5120
+ // loop with no internal exit. Field data (2026-07-10, resident agent):
5121
+ // 36 minutes hard-down, zero self-rescue; only an operator raising the
5122
+ // budget externally could break the loop. Break it here instead: kick the
5123
+ // strategy drain directly so folding/merging frees space for the next
5124
+ // compile. Bounded ticks, best-effort, never throws, one kick per agent
5125
+ // at a time (see overBudgetDrainInFlight).
5126
+ //
5127
+ // Gates on the classified errorType (see classifyInferenceError); the
5128
+ // message match is only a fallback for paths that lost the Error object
5129
+ // (e.g. a reason string that crossed a serialization boundary). It matches
5130
+ // CM's current OverBudgetError wording and MAY rot if CM rewords it — the
5131
+ // errorType gate is the one that's load-bearing.
5132
+ const overBudget = errorType === 'over_budget' || /exceed hard budget/i.test(reason);
5133
+ if (agent && overBudget && !this.overBudgetDrainInFlight.has(agentName)) {
5134
+ this.overBudgetDrainInFlight.add(agentName);
5135
+ void (async () => {
5136
+ let ticks = 0;
5137
+ try {
5138
+ const cm = agent.getContextManager();
5139
+ while (ticks < 8) {
5140
+ await cm.tick();
5141
+ ticks++;
5142
+ }
5143
+ console.error(`[inference-failed] drain kicked for ${agentName} (OverBudget breaker, ${ticks} ticks)`);
5144
+ } catch (err) {
5145
+ console.error(`[inference-failed] drain kick failed for ${agentName} after ${ticks} ticks:`, err);
5146
+ } finally {
5147
+ this.overBudgetDrainInFlight.delete(agentName);
5148
+ }
5149
+ })();
5150
+ }
5151
+
4710
5152
  // (3) Hard-down escalation on repeated identical failure.
4711
5153
  if (streak >= this.inferenceFailureEscalationThreshold) {
4712
5154
  console.error(
@@ -4852,6 +5294,7 @@ export class AgentFramework {
4852
5294
  (event) => this.pushEvent(event),
4853
5295
  (event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }),
4854
5296
  {
5297
+ store: this.store,
4855
5298
  sendTypingFn: (serverId, channelId, metadata, op) => {
4856
5299
  const server = this.mcplServerRegistry!.getServer(serverId);
4857
5300
  if (server) {
@@ -4956,8 +5399,11 @@ export class AgentFramework {
4956
5399
 
4957
5400
  // Record per-server channel subscription policy before the server
4958
5401
  // registers channels — handleRegister fires during the handshake.
4959
- if (config.channelSubscription !== undefined && this.channelRegistry) {
4960
- this.channelRegistry.setSubscriptionPolicy(config.id, config.channelSubscription);
5402
+ if (this.channelRegistry) {
5403
+ this.channelRegistry.setSubscriptionPolicy(
5404
+ config.id,
5405
+ config.channelSubscription ?? 'manual',
5406
+ );
4961
5407
  }
4962
5408
 
4963
5409
  const connection = await this.mcplServerRegistry.addServer(config, this.mcplHostCapabilities);
@@ -5782,6 +6228,88 @@ export class AgentFramework {
5782
6228
  });
5783
6229
  }
5784
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
+
5785
6313
  /** Aggregate the event-tag vocabulary: reserved chat:* core + each connected
5786
6314
  * server's declared tag ontology + gate.js status. */
5787
6315
  private buildEventTagsResult(): Record<string, unknown> {