@animalabs/agent-framework 0.6.0 → 0.6.2

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 (104) hide show
  1. package/dist/src/agent.d.ts +4 -0
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +15 -0
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +176 -0
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +911 -90
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/gate/event-gate.d.ts +20 -1
  10. package/dist/src/gate/event-gate.d.ts.map +1 -1
  11. package/dist/src/gate/event-gate.js +60 -4
  12. package/dist/src/gate/event-gate.js.map +1 -1
  13. package/dist/src/index.d.ts +3 -0
  14. package/dist/src/index.d.ts.map +1 -1
  15. package/dist/src/index.js +4 -0
  16. package/dist/src/index.js.map +1 -1
  17. package/dist/src/mcpl/channel-registry.d.ts +17 -1
  18. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  19. package/dist/src/mcpl/channel-registry.js +34 -3
  20. package/dist/src/mcpl/channel-registry.js.map +1 -1
  21. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  22. package/dist/src/mcpl/push-handler.js +4 -0
  23. package/dist/src/mcpl/push-handler.js.map +1 -1
  24. package/dist/src/mcpl/server-connection.d.ts +29 -0
  25. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  26. package/dist/src/mcpl/server-connection.js +102 -4
  27. package/dist/src/mcpl/server-connection.js.map +1 -1
  28. package/dist/src/mcpl/types.d.ts +17 -1
  29. package/dist/src/mcpl/types.d.ts.map +1 -1
  30. package/dist/src/mcpl/types.js.map +1 -1
  31. package/dist/src/module-registry.d.ts +15 -1
  32. package/dist/src/module-registry.d.ts.map +1 -1
  33. package/dist/src/module-registry.js +92 -4
  34. package/dist/src/module-registry.js.map +1 -1
  35. package/dist/src/types/agent.d.ts +37 -0
  36. package/dist/src/types/agent.d.ts.map +1 -1
  37. package/dist/src/types/module.d.ts +46 -0
  38. package/dist/src/types/module.d.ts.map +1 -1
  39. package/dist/src/types/trace.d.ts +42 -0
  40. package/dist/src/types/trace.d.ts.map +1 -1
  41. package/dist/test/agent-cache-ttl.test.d.ts +2 -0
  42. package/dist/test/agent-cache-ttl.test.d.ts.map +1 -0
  43. package/dist/test/agent-cache-ttl.test.js +25 -0
  44. package/dist/test/agent-cache-ttl.test.js.map +1 -0
  45. package/dist/test/conversation-fork-injection-scoping.test.d.ts +2 -0
  46. package/dist/test/conversation-fork-injection-scoping.test.d.ts.map +1 -0
  47. package/dist/test/conversation-fork-injection-scoping.test.js +184 -0
  48. package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -0
  49. package/dist/test/gather-context-timeout.test.d.ts +2 -0
  50. package/dist/test/gather-context-timeout.test.d.ts.map +1 -0
  51. package/dist/test/gather-context-timeout.test.js +120 -0
  52. package/dist/test/gather-context-timeout.test.js.map +1 -0
  53. package/dist/test/inference-exhaustion-rewind.test.d.ts +2 -0
  54. package/dist/test/inference-exhaustion-rewind.test.d.ts.map +1 -0
  55. package/dist/test/inference-exhaustion-rewind.test.js +181 -0
  56. package/dist/test/inference-exhaustion-rewind.test.js.map +1 -0
  57. package/dist/test/inference-failure-observability.test.js +4 -0
  58. package/dist/test/inference-failure-observability.test.js.map +1 -1
  59. package/dist/test/mcpl-reconnect-events.test.d.ts +2 -0
  60. package/dist/test/mcpl-reconnect-events.test.d.ts.map +1 -0
  61. package/dist/test/mcpl-reconnect-events.test.js +119 -0
  62. package/dist/test/mcpl-reconnect-events.test.js.map +1 -0
  63. package/dist/test/mcpl-reconnect-reregistration.test.d.ts +2 -0
  64. package/dist/test/mcpl-reconnect-reregistration.test.d.ts.map +1 -0
  65. package/dist/test/mcpl-reconnect-reregistration.test.js +181 -0
  66. package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -0
  67. package/dist/test/mcpl-request-timeout.test.d.ts +2 -0
  68. package/dist/test/mcpl-request-timeout.test.d.ts.map +1 -0
  69. package/dist/test/mcpl-request-timeout.test.js +123 -0
  70. package/dist/test/mcpl-request-timeout.test.js.map +1 -0
  71. package/dist/test/mcpl-runtime-lifecycle.test.d.ts +7 -0
  72. package/dist/test/mcpl-runtime-lifecycle.test.d.ts.map +1 -0
  73. package/dist/test/mcpl-runtime-lifecycle.test.js +142 -0
  74. package/dist/test/mcpl-runtime-lifecycle.test.js.map +1 -0
  75. package/dist/test/refusal-rewind.test.d.ts +2 -0
  76. package/dist/test/refusal-rewind.test.d.ts.map +1 -0
  77. package/dist/test/refusal-rewind.test.js +108 -0
  78. package/dist/test/refusal-rewind.test.js.map +1 -0
  79. package/dist/test/state-scaling-e2e.test.d.ts +22 -0
  80. package/dist/test/state-scaling-e2e.test.d.ts.map +1 -0
  81. package/dist/test/state-scaling-e2e.test.js +200 -0
  82. package/dist/test/state-scaling-e2e.test.js.map +1 -0
  83. package/dist/test/state-scaling.test.d.ts +12 -0
  84. package/dist/test/state-scaling.test.d.ts.map +1 -0
  85. package/dist/test/state-scaling.test.js +243 -0
  86. package/dist/test/state-scaling.test.js.map +1 -0
  87. package/dist/test/tool-result-content.test.d.ts +2 -0
  88. package/dist/test/tool-result-content.test.d.ts.map +1 -0
  89. package/dist/test/tool-result-content.test.js +37 -0
  90. package/dist/test/tool-result-content.test.js.map +1 -0
  91. package/dist/tsconfig.tsbuildinfo +1 -1
  92. package/package.json +3 -2
  93. package/src/agent.ts +16 -0
  94. package/src/framework.ts +1069 -99
  95. package/src/gate/event-gate.ts +56 -4
  96. package/src/index.ts +10 -0
  97. package/src/mcpl/channel-registry.ts +34 -3
  98. package/src/mcpl/push-handler.ts +4 -0
  99. package/src/mcpl/server-connection.ts +110 -4
  100. package/src/mcpl/types.ts +19 -1
  101. package/src/module-registry.ts +113 -4
  102. package/src/types/agent.ts +38 -0
  103. package/src/types/module.ts +52 -0
  104. package/src/types/trace.ts +51 -0
package/src/framework.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { join } from 'node:path';
2
+ import { appendFileSync, mkdirSync } from 'node:fs';
2
3
  import { JsStore } from '@animalabs/chronicle';
3
4
  import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane';
4
5
  import { MembraneError } from '@animalabs/membrane';
@@ -42,7 +43,7 @@ import type {
42
43
  } from './types/index.js';
43
44
  import { ProcessQueueImpl } from './queue.js';
44
45
  import { Agent } from './agent.js';
45
- import { ModuleRegistry } from './module-registry.js';
46
+ import { ModuleRegistry, isStateExistsError } from './module-registry.js';
46
47
  import { McplServerRegistry } from './mcpl/server-registry.js';
47
48
  import { FeatureSetManager } from './mcpl/feature-set-manager.js';
48
49
  import { ScopeManager } from './mcpl/scope-manager.js';
@@ -80,7 +81,8 @@ const FRAMEWORK_STATE_ID = 'framework/state';
80
81
  const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router';
81
82
  const INFERENCE_LOG_ID = 'framework/inference-log';
82
83
  const PROCESS_LOG_ID = 'framework/process-log';
83
- const TURN_CHECKPOINTS_ID = 'framework/turn-checkpoints';
84
+ const TURN_CHECKPOINTS_ID = 'framework/turn-checkpoints'; // legacy single-map layout, read-only fallback
85
+ const TURN_CHECKPOINTS_TREE_ID = 'framework/turn-checkpoints/tree';
84
86
 
85
87
  /** Maximum number of turn checkpoints to keep per agent. */
86
88
  const MAX_TURN_CHECKPOINTS = 20;
@@ -198,10 +200,34 @@ interface HostCommandParams {
198
200
  fromMessageId?: string;
199
201
  /** For the `hide` command: Discord message id ending an inclusive range. */
200
202
  toMessageId?: string;
203
+ /** For the `unstick` command: max rewind/retry attempts (default = the
204
+ * agent's refusalHandling.maxRewinds, else 3). */
205
+ maxRewinds?: number;
206
+ /** For the `unstick` command: raw channel id to post the outcome report to. */
207
+ channelId?: string;
201
208
  requesterId?: string;
202
209
  requesterName?: string;
203
210
  }
204
211
 
212
+ /** Descriptor of a single refusal-driven rewind (see rewindTriggeringTurn). */
213
+ interface RewindRecord {
214
+ /** tool = machine tool exchange; human = an ingested message; other = else. */
215
+ kind: 'tool' | 'human' | 'other';
216
+ /** Content-free, safe-to-replay description of what was withheld. */
217
+ descriptor: string;
218
+ /** All message ids removed in this shed (a tool exchange removes 2+). */
219
+ removedIds: MessageId[];
220
+ /** Discord (channelId, messageId) of the removed message, if it had one. */
221
+ discordRef?: { channelId: string; messageId: string };
222
+ }
223
+
224
+ /** Cap an API error message for inline use in a rewind marker: keep enough to
225
+ * identify the failure class without pasting a wall of provider JSON into the
226
+ * agent's context. */
227
+ function truncateReason(reason: string, max = 160): string {
228
+ return reason.length <= max ? reason : reason.slice(0, max) + '…';
229
+ }
230
+
205
231
  export class AgentFramework {
206
232
  private store: JsStore;
207
233
  private ownsStore: boolean;
@@ -227,6 +253,10 @@ export class AgentFramework {
227
253
  private traceListeners: TraceEventListener[] = [];
228
254
  private syncIntervalMs: number;
229
255
  private syncTimer: ReturnType<typeof setInterval> | null = null;
256
+ /** Last time we console-warned about stale (busy-requeued) inference requests, per agent. */
257
+ private staleWarnAt = new Map<string, number>();
258
+ /** Per-agent last inference activity (epoch ms), for /healthz + doctor tooling. */
259
+ private lastInferenceAt = new Map<string, { startedAt?: number; endedAt?: number; failedAt?: number; lastError?: string }>();
230
260
  private processLoggingPersist: boolean;
231
261
  private processLoggingBroadcast: boolean;
232
262
  private activeStreams: Map<string, Promise<void>> = new Map();
@@ -237,6 +267,28 @@ export class AgentFramework {
237
267
  /** N consecutive failed inferences ⇒ the agent is treated as hard-down and
238
268
  * escalated loudly to stderr. */
239
269
  private readonly inferenceFailureEscalationThreshold = 3;
270
+ /** Per-agent count of consecutive refusal-driven rewinds in the current turn
271
+ * chain (reset when a turn completes without a refusal). Bounds the auto
272
+ * rewind loop — see refusalHandling + rewindTriggeringTurn. */
273
+ private refusalRewinds: Map<string, number> = new Map();
274
+ /** Per-agent count of poison-history rewinds performed by the hard-down
275
+ * breaker (noteInferenceExhausted). Reset on any successful inference.
276
+ * Bounds the automatic quarantine loop the same way refusalRewinds bounds
277
+ * the refusal loop, so the breaker can never shed the whole history. */
278
+ private exhaustionRewinds: Map<string, number> = new Map();
279
+ /** Per-agent current rewind episode: the single consolidated marker's id and
280
+ * how many turns have been shed so far. One marker per episode, updated in
281
+ * place (see updateRewindMarker); cleared when the episode ends. */
282
+ private rewindEpisode: Map<string, { markerId: MessageId; count: number; category: string }> = new Map();
283
+ /** Active `/unstick` sessions: an admin-forced rewind-until-clean loop that
284
+ * runs even when the agent's autoRewind toggle is off. Tracks the remaining
285
+ * budget, what was shed (for the report), and where to post the outcome. */
286
+ private forcedRewind: Map<string, {
287
+ remaining: number;
288
+ removed: RewindRecord[];
289
+ serverId: string;
290
+ channelId: string;
291
+ }> = new Map();
240
292
  /** Name of the primary (non-ephemeral) agent for routing framework messages. */
241
293
  private primaryAgentName: string | null = null;
242
294
 
@@ -283,6 +335,13 @@ export class AgentFramework {
283
335
  private mcplPrefixMap: Map<string, string> = new Map();
284
336
  /** Maps serverId → McplServerConfig for prefix lookup. */
285
337
  private mcplServerConfigs: Map<string, import('./mcpl/types.js').McplServerConfig> = new Map();
338
+ /** Host capabilities advertised during the MCP handshake — stored so servers
339
+ * can be connected at runtime (connectMcplServer) after initialization. */
340
+ private mcplHostCapabilities: McplHostCapabilities | null = null;
341
+ /** Inference routing policy from FrameworkConfig — stored so the MCPL
342
+ * subsystem can be lazily initialized by connectMcplServer when the
343
+ * framework started with zero configured servers. */
344
+ private mcplInferenceRoutingConfig: import('./mcpl/types.js').InferenceRoutingPolicy | null = null;
286
345
 
287
346
  // EventGate (null when FrameworkConfig.gate is omitted)
288
347
  private eventGate: EventGate | null = null;
@@ -363,10 +422,12 @@ export class AgentFramework {
363
422
  // Already registered
364
423
  }
365
424
 
425
+ // The legacy single-map checkpoint state (TURN_CHECKPOINTS_ID) is no longer
426
+ // registered for new stores — it's read-only fallback data in old ones.
366
427
  try {
367
- store.registerState({ id: TURN_CHECKPOINTS_ID, strategy: 'snapshot' });
368
- } catch {
369
- // Already registered
428
+ store.registerState({ id: TURN_CHECKPOINTS_TREE_ID, strategy: 'tree' });
429
+ } catch (error) {
430
+ if (!isStateExistsError(error)) throw error;
370
431
  }
371
432
 
372
433
  // Process logging config (default: disabled)
@@ -472,6 +533,10 @@ export class AgentFramework {
472
533
  framework.livenessWatchdog.start();
473
534
  }
474
535
 
536
+ // Stored for lazy MCPL initialization (connectMcplServer on a framework
537
+ // that started with zero configured servers).
538
+ framework.mcplInferenceRoutingConfig = config.inferenceRouting ?? null;
539
+
475
540
  // Initialize MCPL subsystems if configured
476
541
  if (config.mcplServers && config.mcplServers.length > 0) {
477
542
  // Validate tool prefixes: no collisions with module names or between servers
@@ -496,6 +561,31 @@ export class AgentFramework {
496
561
  await framework.initializeMcpl(config.mcplServers, config.inferenceRouting);
497
562
  }
498
563
 
564
+ // Diagnostics: `kill -USR2 <pid>` dumps live wake/inference state to stderr
565
+ // (journal) without a restart — for catching the wake-wedge on the running
566
+ // process. Shows the gate's `inferring` set + buffered-event count (the
567
+ // wedge signature), active streams, and pending inference requests.
568
+ try {
569
+ process.on('SIGUSR2', () => {
570
+ try {
571
+ const gate = framework.eventGate?.inferenceDiagnostics() ?? null;
572
+ console.error(
573
+ '[diagnostics] ' + JSON.stringify({
574
+ at: new Date().toISOString(),
575
+ gate,
576
+ activeStreams: [...framework.activeStreams.keys()],
577
+ pendingRequests: framework.pendingRequests.length,
578
+ agents: [...framework.agents.keys()],
579
+ }),
580
+ );
581
+ } catch (err) {
582
+ console.error('[diagnostics] dump failed:', err instanceof Error ? err.message : err);
583
+ }
584
+ });
585
+ } catch {
586
+ // SIGUSR2 not available on this platform — non-fatal.
587
+ }
588
+
499
589
  return framework;
500
590
  }
501
591
 
@@ -749,8 +839,9 @@ export class AgentFramework {
749
839
  // Module gatherContext (fail-open, matches startAgentStream)
750
840
  try {
751
841
  const moduleInjections = await this.moduleRegistry.gatherContext(agentName);
752
- if (moduleInjections.length > 0) {
753
- injections = moduleInjections;
842
+ const scopedModuleInjections = this.scopeInjectionsForAgent(agentName, moduleInjections);
843
+ if (scopedModuleInjections.length > 0) {
844
+ injections = scopedModuleInjections;
754
845
  }
755
846
  } catch (error) {
756
847
  console.error('Module gatherContext error (preview):', error);
@@ -761,7 +852,10 @@ export class AgentFramework {
761
852
  if (this.hookOrchestrator) {
762
853
  try {
763
854
  const hookParams = this.buildBeforeInferenceParams(agent);
764
- const hookInjections = await this.hookOrchestrator.beforeInference(hookParams);
855
+ const hookInjections = this.scopeInjectionsForAgent(
856
+ agentName,
857
+ await this.hookOrchestrator.beforeInference(hookParams),
858
+ );
765
859
  if (hookInjections.length > 0) {
766
860
  injections = injections ? [...injections, ...hookInjections] : hookInjections;
767
861
  }
@@ -951,6 +1045,7 @@ export class AgentFramework {
951
1045
  clearInterval(completionWatchdog);
952
1046
  this.offTrace(traceListener);
953
1047
  this.agents.delete(agent.name);
1048
+ this.evictTurnCheckpoints(agent.name);
954
1049
  };
955
1050
 
956
1051
  // Watchdog: if inference never starts within 30s, the agent is a zombie.
@@ -1309,6 +1404,194 @@ export class AgentFramework {
1309
1404
  }
1310
1405
  }
1311
1406
 
1407
+ /**
1408
+ * Rewind the single turn that fed a refused generation: redact the newest
1409
+ * message the agent did NOT author (a tool result, or an incoming message)
1410
+ * and inject a metadata-only marker in its place. The marker carries a
1411
+ * *description* of what was withheld (kind + size), never the content — so it
1412
+ * cannot itself re-trip the classifier — while the raw record survives in the
1413
+ * chronicle log for forensics. Returns a record describing the removal, or
1414
+ * null if there is nothing eligible to rewind (e.g. only the agent's own
1415
+ * turns, or a prior system marker, remain).
1416
+ *
1417
+ * Shared by the auto path (on `stop_reason: refusal`) and the `/rewind`
1418
+ * host command.
1419
+ */
1420
+ private shedNewestTurn(agent: Agent): RewindRecord | null {
1421
+ const cm = agent.getContextManager();
1422
+ const all = cm.getAllMessages();
1423
+ const typeOf = (b: unknown) => (b as { type?: string }).type;
1424
+ const hasBlock = (m: { content?: unknown } | undefined, t: string) =>
1425
+ Array.isArray(m?.content) && (m!.content as unknown[]).some((b) => typeOf(b) === t);
1426
+
1427
+ // Newest message that is not our own episode marker. We shed strictly
1428
+ // newest-first, in sequence — INCLUDING the agent's own turns (poison lives
1429
+ // in tool_use/narration turns too), because newest-first reaches whatever is
1430
+ // poisoning the context if allowed to run deep enough. The one invariant:
1431
+ // shed COMPLETE exchanges. Removing a `tool_result` also removes its paired
1432
+ // `tool_use` assistant turn, so we never leave an orphaned tool_use / signed
1433
+ // `thinking` block — which the API rejects with a 400 ("thinking blocks in
1434
+ // the latest assistant message cannot be modified"). We do NOT add a marker
1435
+ // here; the caller keeps one consolidated marker (updateRewindMarker).
1436
+ let idx = -1;
1437
+ for (let i = all.length - 1; i >= 0; i--) {
1438
+ const meta = (all[i].metadata ?? {}) as { system?: unknown };
1439
+ if (meta.system) continue;
1440
+ idx = i; break;
1441
+ }
1442
+ if (idx < 0) return null;
1443
+ const msg = all[idx];
1444
+ const md = (msg.metadata ?? {}) as {
1445
+ messageId?: unknown; channelId?: unknown; system?: unknown;
1446
+ };
1447
+
1448
+ const content = Array.isArray(msg.content) ? msg.content : [];
1449
+ const toolBlocks = content.filter((b) => typeOf(b) === 'tool_result');
1450
+ const images = content.filter((b) => typeOf(b) === 'image').length;
1451
+ const textLen = content
1452
+ .filter((b) => typeOf(b) === 'text')
1453
+ .reduce((n, b) => n + String((b as { text?: string }).text ?? '').length, 0);
1454
+
1455
+ // Determine the complete exchange to remove.
1456
+ const removedIds: MessageId[] = [msg.id];
1457
+ let kind: RewindRecord['kind'];
1458
+ let descriptor: string;
1459
+ if (toolBlocks.length > 0) {
1460
+ kind = 'tool';
1461
+ // Pair the tool_result with the tool_use assistant turn right before it.
1462
+ if (idx - 1 >= 0 && hasBlock(all[idx - 1], 'tool_use')) {
1463
+ removedIds.push(all[idx - 1].id);
1464
+ }
1465
+ const sz = toolBlocks.reduce(
1466
+ (n, b) => n + String((b as { content?: unknown }).content ?? '').length, 0);
1467
+ descriptor = `a tool exchange (~${Math.max(1, Math.round(sz / 1024))}KB` +
1468
+ `${images ? `, ${images} image(s)` : ''})`;
1469
+ } else if (md.messageId) {
1470
+ kind = 'human';
1471
+ descriptor = `an incoming message from ${msg.participant} ` +
1472
+ `(${textLen} chars${images ? `, ${images} image(s)` : ''})`;
1473
+ } else {
1474
+ kind = 'other';
1475
+ descriptor = `a ${msg.participant} turn ` +
1476
+ `(${textLen} chars${images ? `, ${images} image(s)` : ''})`;
1477
+ }
1478
+ const discordRef = md.messageId && md.channelId
1479
+ ? { channelId: String(md.channelId), messageId: String(md.messageId) }
1480
+ : undefined;
1481
+
1482
+ for (const id of removedIds) cm.removeMessage(id);
1483
+ return { kind, descriptor, removedIds, discordRef };
1484
+ }
1485
+
1486
+ /**
1487
+ * Maintain exactly ONE consolidated marker for the current rewind episode,
1488
+ * updated in place as more turns are shed. Six rewinds ⇒ one message that
1489
+ * says "the 6 most recent turns were set aside", not six separate notes — so
1490
+ * the context converges (shed N, add 1) instead of growing, and the marker
1491
+ * sits at the tail giving the model something actionable to answer once the
1492
+ * refusal clears. Returns the running count.
1493
+ */
1494
+ private updateRewindMarker(
1495
+ agent: Agent,
1496
+ category: string,
1497
+ cause: 'refusal' | 'inference-failure' = 'refusal',
1498
+ ): number {
1499
+ const cm = agent.getContextManager();
1500
+ const ep = this.rewindEpisode.get(agent.name);
1501
+ const count = (ep?.count ?? 0) + 1;
1502
+ const why = cause === 'refusal'
1503
+ ? `the model refused on them (content filter: ${category}). Their content is ` +
1504
+ `not reproduced here (so this note can't re-trigger the filter); the ` +
1505
+ `originals remain in the raw record`
1506
+ : `the model API kept rejecting the conversation on them (${category}). ` +
1507
+ `Their content is not reproduced here (so this note can't re-trigger the ` +
1508
+ `rejection); the originals remain in the raw record`;
1509
+ const text =
1510
+ `[refusal-rewind] The ${count} most recent turn(s) were set aside because ` +
1511
+ `${why}. You are clear to continue — if you ` +
1512
+ `were mid-task, take a different approach; otherwise carry on with whatever ` +
1513
+ `is now in front of you, or briefly acknowledge the gap and ask what's next.`;
1514
+ const blocks: ContentBlock[] = [{ type: 'text', text }];
1515
+ if (ep) {
1516
+ cm.editMessage(ep.markerId, blocks);
1517
+ this.rewindEpisode.set(agent.name, { markerId: ep.markerId, count, category });
1518
+ } else {
1519
+ const id = cm.addMessage('user', blocks, {
1520
+ system: true, kind: 'refusal-rewind', category, count, cause,
1521
+ });
1522
+ this.rewindEpisode.set(agent.name, { markerId: id, count, category });
1523
+ }
1524
+ return count;
1525
+ }
1526
+
1527
+ /**
1528
+ * Announce a refusal-rewind on the conversational surface (Discord), used
1529
+ * when the withheld turn was a *human* message so it isn't dropped silently.
1530
+ * Best-effort; mirrors reactToRefusal's locus resolution.
1531
+ */
1532
+ private async announceRewind(
1533
+ agentName: string,
1534
+ rec: RewindRecord,
1535
+ category: string,
1536
+ ): Promise<void> {
1537
+ try {
1538
+ const incoming = this.channelRegistry?.buildChannelContext()?.incoming;
1539
+ if (!incoming) return;
1540
+ const parts = incoming.channelId.split(':');
1541
+ if (parts[0] !== 'discord') return;
1542
+ const channelId = parts[parts.length - 1];
1543
+ const serverId = this.channelRegistry?.getChannelServerId(incoming.channelId);
1544
+ const server = serverId ? this.mcplServerRegistry?.getServer(serverId) : null;
1545
+ if (!server) return;
1546
+ await server.sendToolsCall('send_message', {
1547
+ channelId,
1548
+ content:
1549
+ `⚠️ I had to set aside ${rec.descriptor} — it tripped a content ` +
1550
+ `filter (${category}), so it's withheld from my context and I'm ` +
1551
+ `continuing without it. If it was important, please rephrase or re-send.`,
1552
+ });
1553
+ } catch (err) {
1554
+ console.error(
1555
+ '[refusal-rewind] announce failed:',
1556
+ err instanceof Error ? err.message : err,
1557
+ );
1558
+ }
1559
+ }
1560
+
1561
+ /**
1562
+ * Conclude an active `/unstick` session: post the outcome (what was shed and
1563
+ * whether the model stopped refusing) to the channel the command came from,
1564
+ * then clear the session. Idempotent — a no-op if there's no session.
1565
+ */
1566
+ private finishUnstick(agentName: string, success: boolean, category?: string): void {
1567
+ const s = this.forcedRewind.get(agentName);
1568
+ if (!s) return;
1569
+ this.forcedRewind.delete(agentName);
1570
+ const n = s.removed.length;
1571
+ const list = n
1572
+ ? '\n' + s.removed.map((r) => `• ${r.descriptor}`).join('\n')
1573
+ : '';
1574
+ const content = success
1575
+ ? `🔧 Unstuck **${agentName}** — shed ${n} turn(s); the model responded.${list}`
1576
+ : `⚠️ Couldn't unstick **${agentName}** — still refusing after ${n} rewind(s)` +
1577
+ `${category ? ` (category=${category})` : ''}.${list}`;
1578
+ console.error(
1579
+ `[unstick] agent=${agentName} ${success ? 'succeeded' : 'gave up'} after ${n} rewind(s)`,
1580
+ );
1581
+ if (s.channelId) void this.postToChannel(s.serverId, s.channelId, content);
1582
+ }
1583
+
1584
+ /** Best-effort send_message to a raw channel via a named MCPL server. */
1585
+ private async postToChannel(serverId: string, channelId: string, content: string): Promise<void> {
1586
+ try {
1587
+ const server = this.mcplServerRegistry?.getServer(serverId);
1588
+ if (!server) return;
1589
+ await server.sendToolsCall('send_message', { channelId, content });
1590
+ } catch (err) {
1591
+ console.error('[unstick] report post failed:', err instanceof Error ? err.message : err);
1592
+ }
1593
+ }
1594
+
1312
1595
  /**
1313
1596
  * Handle a `host/command` request from an MCPL surface server (e.g. a
1314
1597
  * Discord slash command). Currently supports:
@@ -1332,8 +1615,12 @@ export class AgentFramework {
1332
1615
  * that carried one — so the surface can mark them with a reaction. */
1333
1616
  hiddenRefs?: Array<{ channelId: string; messageId: string }>;
1334
1617
  lastVisible?: { participant?: string; role?: string; preview?: string } | null;
1618
+ /** For `unstick`: acknowledges the forced-rewind loop has started; the
1619
+ * outcome report is posted to the channel asynchronously. */
1620
+ started?: boolean;
1621
+ cap?: number;
1335
1622
  }> {
1336
- if (params.command !== 'undo' && params.command !== 'hide') {
1623
+ if (params.command !== 'undo' && params.command !== 'hide' && params.command !== 'unstick') {
1337
1624
  return { ok: false, error: `Unknown host command: ${String(params.command)}` };
1338
1625
  }
1339
1626
 
@@ -1342,6 +1629,38 @@ export class AgentFramework {
1342
1629
  return { ok: false, error: `Unknown agent: ${String(agentName)}` };
1343
1630
  }
1344
1631
 
1632
+ // unstick: force the refusal-rewind loop on demand (even if the agent's
1633
+ // autoRewind toggle is off). Redacts the turn that fed the refusal and
1634
+ // re-runs, up to `cap` times, until the model stops refusing. Kicks the
1635
+ // loop and returns immediately; the outcome (what was shed + whether it
1636
+ // cleared) is posted to the channel when the chain resolves.
1637
+ if (params.command === 'unstick') {
1638
+ const agent = this.agents.get(agentName)!;
1639
+ if (agent.state.status !== 'idle') {
1640
+ return { ok: false, error: `Cannot unstick while agent is ${agent.state.status}` };
1641
+ }
1642
+ const cap = Math.max(1, Math.min(10,
1643
+ Math.floor(params.maxRewinds ?? agent.refusalHandling?.maxRewinds ?? 3)));
1644
+ this.forcedRewind.set(agentName, {
1645
+ remaining: cap,
1646
+ removed: [],
1647
+ serverId,
1648
+ channelId: params.channelId ?? '',
1649
+ });
1650
+ this.refusalRewinds.set(agentName, 0);
1651
+ this.pendingRequests.push({
1652
+ agentName,
1653
+ reason: 'unstick',
1654
+ source: 'framework',
1655
+ timestamp: Date.now(),
1656
+ });
1657
+ console.error(
1658
+ `[unstick] agent=${agentName} started cap=${cap} ` +
1659
+ `by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`,
1660
+ );
1661
+ return { ok: true, started: true, cap };
1662
+ }
1663
+
1345
1664
  // hide: redact a single message (or an inclusive range) from the active
1346
1665
  // branch, addressed by Discord message id. Unlike undo this is a
1347
1666
  // removal-in-place (chronicle redact), not a branch rewind.
@@ -1959,18 +2278,91 @@ export class AgentFramework {
1959
2278
  this.saveTurnCheckpoints(agentName, checkpoints);
1960
2279
  }
1961
2280
 
2281
+ /**
2282
+ * Checkpoints live in one Tree state (TURN_CHECKPOINTS_TREE_ID) keyed by agent
2283
+ * name, each key pointing at a JSON blob of that agent's ≤MAX_TURN_CHECKPOINTS
2284
+ * list. This kills two unbounded-in-agents-ever dimensions at once:
2285
+ *
2286
+ * - the original single Record<agentName, list> map was rewritten whole on
2287
+ * every turn of every agent and never evicted a departed subagent's key;
2288
+ * - the intermediate design (one state slot per agent) fixed the writes but
2289
+ * leaked a permanent chronicle state *registration* per spawn — chronicle
2290
+ * has no deregistration, and the state index is rewritten and fsynced on
2291
+ * every sync tick, so per-tick cost grew with fleet history anyway.
2292
+ *
2293
+ * A tree gives per-key O(entry) writes, real key removal (treeRemove), and
2294
+ * exactly one registration for the life of the store. The legacy map state
2295
+ * (TURN_CHECKPOINTS_ID) is kept as a read-only fallback for stores written
2296
+ * before the split; eviction tombstones legacy keys so a reused agent name
2297
+ * can't inherit a dead agent's checkpoints through the fallback.
2298
+ */
2299
+ private legacyCheckpointKeys: Set<string> | null = null;
2300
+
2301
+ private hasLegacyCheckpoints(agentName: string): boolean {
2302
+ if (!this.legacyCheckpointKeys) {
2303
+ const legacy = this.store.getStateJson(TURN_CHECKPOINTS_ID);
2304
+ this.legacyCheckpointKeys = new Set(
2305
+ legacy && typeof legacy === 'object' ? Object.keys(legacy) : []
2306
+ );
2307
+ }
2308
+ return this.legacyCheckpointKeys.has(agentName);
2309
+ }
2310
+
1962
2311
  private getTurnCheckpoints(agentName: string): TurnCheckpoint[] {
1963
- const data = this.store.getStateJson(TURN_CHECKPOINTS_ID);
1964
- if (!data || typeof data !== 'object') return [];
1965
- const allCheckpoints = data as Record<string, TurnCheckpoint[]>;
1966
- return Array.isArray(allCheckpoints[agentName]) ? [...allCheckpoints[agentName]] : [];
2312
+ const entry = this.store.treeGet(TURN_CHECKPOINTS_TREE_ID, agentName);
2313
+ if (entry) {
2314
+ const blob = this.store.getBlob(entry.blobHash);
2315
+ if (!blob) return [];
2316
+ try {
2317
+ const parsed = JSON.parse(blob.toString());
2318
+ return Array.isArray(parsed) ? parsed : [];
2319
+ } catch {
2320
+ return [];
2321
+ }
2322
+ }
2323
+ if (!this.hasLegacyCheckpoints(agentName)) return [];
2324
+ const legacy = this.store.getStateJson(TURN_CHECKPOINTS_ID) as
2325
+ | Record<string, TurnCheckpoint[]>
2326
+ | null;
2327
+ const list = legacy?.[agentName];
2328
+ return Array.isArray(list) ? [...list] : [];
1967
2329
  }
1968
2330
 
1969
2331
  private saveTurnCheckpoints(agentName: string, checkpoints: TurnCheckpoint[]): void {
1970
- const data = this.store.getStateJson(TURN_CHECKPOINTS_ID);
1971
- const allCheckpoints = (data && typeof data === 'object' ? data : {}) as Record<string, TurnCheckpoint[]>;
1972
- allCheckpoints[agentName] = checkpoints;
1973
- this.store.setStateJson(TURN_CHECKPOINTS_ID, allCheckpoints);
2332
+ const bytes = Buffer.from(JSON.stringify(checkpoints));
2333
+ const blobHash = this.store.storeBlob(bytes, 'application/json');
2334
+ this.store.treeSet(TURN_CHECKPOINTS_TREE_ID, agentName, {
2335
+ blobHash,
2336
+ size: bytes.length,
2337
+ mode: 0o644,
2338
+ });
2339
+ }
2340
+
2341
+ /**
2342
+ * Drop a departed agent's checkpoint tree key and turn/redo bookkeeping.
2343
+ * Without this, spawn-and-dispose agents leave a key (and in-memory map
2344
+ * entries) behind for the life of the store/session.
2345
+ */
2346
+ private evictTurnCheckpoints(agentName: string): void {
2347
+ this.turnCounters.delete(agentName);
2348
+ this.redoStacks.delete(agentName);
2349
+ // Diagnostics maps are also keyed by agent name and never evicted —
2350
+ // spawn-and-dispose fleets would grow them for the session's life.
2351
+ this.staleWarnAt.delete(agentName);
2352
+ this.lastInferenceAt.delete(agentName);
2353
+ const entry = this.store.treeGet(TURN_CHECKPOINTS_TREE_ID, agentName);
2354
+ if (this.hasLegacyCheckpoints(agentName)) {
2355
+ // A bare treeRemove would resurrect the legacy map's list through the
2356
+ // fallback next time this name is reused — shadow it with an empty
2357
+ // tombstone instead. size <= 2 ⇔ the blob is already "[]".
2358
+ if (!entry || entry.size > 2) {
2359
+ this.saveTurnCheckpoints(agentName, []);
2360
+ }
2361
+ return;
2362
+ }
2363
+ if (entry) {
2364
+ this.store.treeRemove(TURN_CHECKPOINTS_TREE_ID, agentName);
2365
+ }
1974
2366
  }
1975
2367
 
1976
2368
  /**
@@ -1980,6 +2372,9 @@ export class AgentFramework {
1980
2372
  async runUntilIdle(): Promise<void> {
1981
2373
  while (
1982
2374
  !this.queue.isEmpty ||
2375
+ // Direct inference requests (e.g. runEphemeralToCompletion) bypass the
2376
+ // event queue — without this the loop can exit before they're drained.
2377
+ this.pendingRequests.length > 0 ||
1983
2378
  this.activeStreams.size > 0 ||
1984
2379
  Array.from(this.agents.values()).some((a) => a.state.status !== 'idle')
1985
2380
  ) {
@@ -2512,6 +2907,7 @@ export class AgentFramework {
2512
2907
  this.agents.delete(agentName);
2513
2908
  this.agentConfigs.delete(agentName);
2514
2909
  this.conversationAgentHomes.delete(agentName);
2910
+ this.evictTurnCheckpoints(agentName);
2515
2911
  this.emitTrace({
2516
2912
  type: 'mcpl:conversation-disposed',
2517
2913
  agentName,
@@ -2718,6 +3114,7 @@ export class AgentFramework {
2718
3114
  requestCount: requests.length,
2719
3115
  oldestRequestAge: now - oldest,
2720
3116
  });
3117
+ console.error(`[inference-dropped] agent=${agentName} reason=agent_not_found requests=${requests.length}`);
2721
3118
  continue;
2722
3119
  }
2723
3120
 
@@ -2733,6 +3130,14 @@ export class AgentFramework {
2733
3130
  requestCount: requests.length,
2734
3131
  oldestRequestAge: now - oldest,
2735
3132
  });
3133
+ // Loud (but throttled) note that requests are waiting on a busy agent.
3134
+ if ((this.staleWarnAt.get(agentName) ?? 0) < now - 60_000) {
3135
+ this.staleWarnAt.set(agentName, now);
3136
+ console.error(
3137
+ `[inference-stale] agent=${agentName} busy (${agent.state.status}) — ` +
3138
+ `${requests.length} request(s) waiting ${Math.round((now - oldest) / 1000)}s`,
3139
+ );
3140
+ }
2736
3141
  }
2737
3142
  this.pendingRequests.push(...requests);
2738
3143
  continue;
@@ -2740,6 +3145,13 @@ export class AgentFramework {
2740
3145
 
2741
3146
  // Check policy
2742
3147
  if (!this.inferencePolicy.shouldInfer(agentName, requests, state)) {
3148
+ // Loud drop: a queued request that dies here is otherwise invisible —
3149
+ // the 2026-07-09 mythos "not responding" diagnosis burned hours on
3150
+ // exactly this class of silent drop. One stderr line per drop.
3151
+ console.error(
3152
+ `[inference-dropped] agent=${agentName} reason=policy-skip ` +
3153
+ `requests=${requests.length} triggers=${requests.map((r) => r.reason).join(',')}`,
3154
+ );
2743
3155
  continue;
2744
3156
  }
2745
3157
 
@@ -2782,6 +3194,7 @@ export class AgentFramework {
2782
3194
 
2783
3195
  this.emitTrace({ type: 'inference:started', agentName: agent.name });
2784
3196
  this.eventGate?.onInferenceStarted(agent.name);
3197
+ this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
2785
3198
 
2786
3199
  try {
2787
3200
  const allTools = this.getAllTools();
@@ -2791,14 +3204,16 @@ export class AgentFramework {
2791
3204
  // Both produce ContextInjection[] that get merged before inference.
2792
3205
  let injections: ContextInjection[] | undefined;
2793
3206
 
2794
- // Module gatherContext (fail-open, 5s timeout per module)
2795
- // NOTE: module injections are NOT channel-scoped — only the MCPL hook
2796
- // injections below pass through scopeInjectionsForAgent (channel
2797
- // context arrives via beforeInference hooks by adapter convention). A
2798
- // module that starts emitting per-channel context must scope it per
2799
- // agent itself, or conversation forks will see cross-channel content.
3207
+ // Module gatherContext (fail-open, per-module timeout — the module's
3208
+ // contextTimeoutMs, else the registry default). Injections are
3209
+ // channel-scoped via scopeInjectionsForAgent, matching the MCPL hook
3210
+ // injections below, so conversation forks don't see cross-channel
3211
+ // content.
2800
3212
  try {
2801
- const moduleInjections = await this.moduleRegistry.gatherContext(agent.name);
3213
+ const moduleInjections = this.scopeInjectionsForAgent(
3214
+ agent.name,
3215
+ await this.moduleRegistry.gatherContext(agent.name),
3216
+ );
2802
3217
  if (moduleInjections.length > 0) {
2803
3218
  injections = moduleInjections;
2804
3219
  }
@@ -2845,6 +3260,13 @@ export class AgentFramework {
2845
3260
  type: 'inference:exhausted',
2846
3261
  agentName: agent.name,
2847
3262
  error: err.message,
3263
+ // Drives the poison-history breaker. `retryable` is kept for
3264
+ // observability, but the breaker gates on `errorType` — only an
3265
+ // `invalid_request` (a 400-class rejection of the history itself)
3266
+ // means retrying the same context can never succeed. auth/abort/
3267
+ // context_length are also non-retryable but must NOT cost history.
3268
+ retryable: err instanceof MembraneError ? err.retryable : undefined,
3269
+ errorType: err instanceof MembraneError ? err.type : undefined,
2848
3270
  });
2849
3271
  this.eventGate?.onInferenceEnded(agent.name);
2850
3272
  if (action.emit) {
@@ -3121,7 +3543,84 @@ export class AgentFramework {
3121
3543
  `[inference-refusal] agent=${agent.name} category=${category}` +
3122
3544
  (stopDetails?.explanation ? ` explanation=${stopDetails.explanation}` : ''),
3123
3545
  );
3124
- void this.reactToRefusal(agent.name, category);
3546
+
3547
+ // Rewind: excise the turn that fed the refusal, drop a
3548
+ // metadata-only marker, and retry — keeping the agent on its own
3549
+ // model instead of substituting a fallback. Driven either by an
3550
+ // admin `/unstick` (forced session) or the agent's autoRewind
3551
+ // config; both are bounded.
3552
+ let handledByRewind = false;
3553
+ const rh = agent.refusalHandling;
3554
+ const forced = this.forcedRewind.get(agent.name);
3555
+ // Shed exactly one more (newest, in-sequence) turn and keep the
3556
+ // single episode marker current. `budgetLeft` bounds the loop.
3557
+ const doRewind = (
3558
+ budgetLeft: boolean,
3559
+ onStep: (count: number) => void,
3560
+ onGiveUp: () => void,
3561
+ ): void => {
3562
+ if (!budgetLeft) { onGiveUp(); return; }
3563
+ const rec = this.shedNewestTurn(agent);
3564
+ if (!rec) { onGiveUp(); return; }
3565
+ const count = this.updateRewindMarker(agent, category);
3566
+ onStep(count);
3567
+ if (forced) forced.removed.push(rec);
3568
+ this.pendingRequests.push({
3569
+ agentName: agent.name,
3570
+ reason: forced ? 'unstick-retry' : 'refusal-rewind-retry',
3571
+ source: 'framework',
3572
+ timestamp: Date.now(),
3573
+ });
3574
+ handledByRewind = true;
3575
+ };
3576
+
3577
+ if (forced) {
3578
+ doRewind(
3579
+ forced.remaining > 0,
3580
+ (count) => {
3581
+ forced.remaining -= 1;
3582
+ console.error(
3583
+ `[unstick] agent=${agent.name} shed ${count} turn(s) ` +
3584
+ `(remaining ${forced.remaining})`,
3585
+ );
3586
+ },
3587
+ () => this.finishUnstick(agent.name, false, category),
3588
+ );
3589
+ } else if (rh?.autoRewind) {
3590
+ const cap = Math.max(1, rh.maxRewinds ?? 3);
3591
+ const used = this.refusalRewinds.get(agent.name) ?? 0;
3592
+ doRewind(
3593
+ used < cap,
3594
+ (count) => {
3595
+ this.refusalRewinds.set(agent.name, used + 1);
3596
+ console.error(
3597
+ `[refusal-rewind] agent=${agent.name} shed ${count} turn(s) so far ` +
3598
+ `(cap ${cap})`,
3599
+ );
3600
+ },
3601
+ () => {
3602
+ console.error(
3603
+ `[refusal-rewind] agent=${agent.name} gave up: ` +
3604
+ `${used >= cap ? `cap ${cap} reached` : 'nothing left to shed'}`,
3605
+ );
3606
+ this.refusalRewinds.set(agent.name, 0);
3607
+ this.rewindEpisode.delete(agent.name);
3608
+ },
3609
+ );
3610
+ }
3611
+
3612
+ if (!handledByRewind) void this.reactToRefusal(agent.name, category);
3613
+ } else {
3614
+ // A turn that completed WITHOUT a refusal ends the rewind episode:
3615
+ // the model responded. Leave the consolidated marker in place as
3616
+ // the durable record; just clear the per-episode counters.
3617
+ if (this.forcedRewind.has(agent.name)) {
3618
+ this.finishUnstick(agent.name, true);
3619
+ }
3620
+ if (this.refusalRewinds.get(agent.name)) {
3621
+ this.refusalRewinds.set(agent.name, 0);
3622
+ }
3623
+ this.rewindEpisode.delete(agent.name);
3125
3624
  }
3126
3625
 
3127
3626
  // Dispatch speech (and thoughts if any)
@@ -3212,13 +3711,21 @@ export class AgentFramework {
3212
3711
  `(${silenced ? 'silencing tool / explicit send' : 'no prose'})`,
3213
3712
  );
3214
3713
  } else {
3714
+ // Snapshot the locus ONCE. This loop dispatches AFTER the agent
3715
+ // is idle (see PR #32 note below), so a queued inbound from
3716
+ // another channel can start a NEW turn and overwrite the per-agent
3717
+ // triggering channel (`activeTriggerChannels`) BETWEEN segments —
3718
+ // splitting one turn's reply across channels (the residual
3719
+ // multi-segment race left by 83dedf8's per-agent locus). Pin every
3720
+ // segment of THIS turn to the channel resolved right now.
3721
+ const turnLocus = this.channelRegistry.resolveLocus(agent.name);
3215
3722
  console.error(
3216
- `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} prose segment(s) in order`,
3723
+ `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} prose segment(s) in order -> ${turnLocus ?? '(default)'}`,
3217
3724
  );
3218
3725
  // Deliver sequentially (await each) so the segments land in order.
3219
3726
  for (const seg of segments) {
3220
3727
  try {
3221
- await this.channelRegistry.routeSpeech(agent.name, seg);
3728
+ await this.channelRegistry.routeSpeech(agent.name, seg, turnLocus);
3222
3729
  } catch (err) {
3223
3730
  console.error('speech routing failed:', err);
3224
3731
  }
@@ -3267,6 +3774,12 @@ export class AgentFramework {
3267
3774
  type: 'inference:exhausted',
3268
3775
  agentName: agent.name,
3269
3776
  error: err.message,
3777
+ // Drives the poison-history breaker. `retryable` is kept for
3778
+ // observability, but the breaker gates on `errorType` — only an
3779
+ // `invalid_request` (a 400-class rejection of the history
3780
+ // itself) means retrying the same context can never succeed.
3781
+ retryable: err instanceof MembraneError ? err.retryable : undefined,
3782
+ errorType: err instanceof MembraneError ? err.type : undefined,
3270
3783
  });
3271
3784
  this.eventGate?.onInferenceEnded(agent.name);
3272
3785
  if (action.emit) {
@@ -3281,19 +3794,58 @@ export class AgentFramework {
3281
3794
  // Only reset if this is still the active stream (a budget restart
3282
3795
  // may have already started a new stream, bumping streamId)
3283
3796
  if (agent.streamId === myStreamId) {
3797
+ const durationMs = Date.now() - startTime;
3284
3798
  agent.reset();
3285
3799
  this.emitTrace({
3286
3800
  type: 'inference:exhausted',
3287
3801
  agentName: agent.name,
3288
3802
  error: `Stream aborted: ${reason}`,
3289
3803
  });
3804
+ // Postmortem 2026-05-28 P2 #7: persist the abort to the
3805
+ // inference log so future investigations can attribute the
3806
+ // terminal cause without relying on live in-memory reducer
3807
+ // state. Without this, abort-terminated inferences are
3808
+ // invisible to forensic queries (only request-side telemetry
3809
+ // via llm-calls.jsonl shows them, and only by absence).
3810
+ this.logInference({
3811
+ timestamp: startTime,
3812
+ agentName: agent.name,
3813
+ requestId,
3814
+ success: false,
3815
+ error: `Stream aborted: ${reason}`,
3816
+ request: compiledRequest ?? { note: 'streaming request aborted' },
3817
+ durationMs,
3818
+ });
3290
3819
  this.eventGate?.onInferenceEnded(agent.name);
3291
3820
  }
3292
3821
  break;
3293
3822
  }
3294
3823
 
3295
- case 'usage':
3824
+ case 'usage': {
3296
3825
  agent.lastStreamInputTokens = event.usage.inputTokens;
3826
+
3827
+ // Closed-loop estimator calibration (2026-07-12). Sample the REAL
3828
+ // prefix size of THIS API call (fresh + cache write + cache read)
3829
+ // and hand it to the context strategy, which accepts exactly one
3830
+ // sample per compile (its arm-once gate) and rejects out-of-band
3831
+ // ratios. It must be sampled HERE, per call: `response.details.
3832
+ // usage` at turn completion is CUMULATIVE across the tool-use
3833
+ // loop (5 calls x ~160k reported as 884k), which is not a
3834
+ // window-shaped number and drove the multiplier to 2.37 before
3835
+ // the guards caught it.
3836
+ try {
3837
+ const realTotal =
3838
+ (event.usage.inputTokens ?? 0) +
3839
+ (event.usage.cacheCreationTokens ?? 0) +
3840
+ (event.usage.cacheReadTokens ?? 0);
3841
+ const strat = (agent as unknown as {
3842
+ getContextManager?: () => { getStrategy?: () => unknown };
3843
+ }).getContextManager?.()?.getStrategy?.() as
3844
+ | { reportRealInputTokens?: (n: number) => void }
3845
+ | undefined;
3846
+ strat?.reportRealInputTokens?.(realTotal);
3847
+ } catch { /* calibration is best-effort */ }
3848
+
3297
3849
  this.emitTrace({
3298
3850
  type: 'inference:usage',
3299
3851
  agentName: agent.name,
@@ -3305,12 +3857,14 @@ export class AgentFramework {
3305
3857
  },
3306
3858
  });
3307
3859
  break;
3860
+ }
3308
3861
  }
3309
3862
  }
3310
3863
  } catch (error) {
3311
3864
  // Stream itself threw (unexpected) — no retry path here, so also emit
3312
3865
  // inference:exhausted so ephemeral agent promises can settle.
3313
3866
  const err = error instanceof Error ? error : new Error(String(error));
3867
+ const durationMs = Date.now() - startTime;
3314
3868
  this.emitTrace({
3315
3869
  type: 'inference:failed',
3316
3870
  agentName: agent.name,
@@ -3322,9 +3876,31 @@ export class AgentFramework {
3322
3876
  agentName: agent.name,
3323
3877
  error: err.message,
3324
3878
  });
3879
+ // Postmortem 2026-05-28 P2 #7: catch-path failures (stream itself
3880
+ // threw) were previously only visible via in-memory trace listeners.
3881
+ // Persist to inference log for forensic attribution.
3882
+ this.logInference({
3883
+ timestamp: startTime,
3884
+ agentName: agent.name,
3885
+ requestId,
3886
+ success: false,
3887
+ error: `Stream threw: ${err.message}`,
3888
+ request: compiledRequest ?? { note: 'streaming request threw' },
3889
+ durationMs,
3890
+ });
3325
3891
  agent.reset();
3326
3892
  this.eventGate?.onInferenceEnded(agent.name);
3327
3893
  } finally {
3894
+ // Clear the gate's inference flag on EVERY exit path (paired with the
3895
+ // onInferenceStarted in startAgentStream). The branch-level calls above
3896
+ // are kept but are best-effort; if any exit path bypassed them the agent
3897
+ // stayed stuck in the gate's `inferring` set, which permanently BUFFERS
3898
+ // all incoming events → the agent silently stops waking on messages
3899
+ // (typing still stops, compression still runs — matching the observed
3900
+ // wedge). onInferenceEnded is idempotent, so a redundant call is safe.
3901
+ this.eventGate?.onInferenceEnded(agent.name);
3902
+ this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
3903
+
3328
3904
  // Stop the typing indicator on every exit path (complete, error,
3329
3905
  // exhausted, abort) so it never sticks after the turn ends.
3330
3906
  this.channelRegistry?.stopTyping();
@@ -3414,7 +3990,10 @@ export class AgentFramework {
3414
3990
  if (blocks) {
3415
3991
  return { toolUseId: callId, content: blocks, isError: false };
3416
3992
  }
3417
- let content = JSON.stringify(afResult.data);
3993
+ // JSON.stringify returns the VALUE undefined (not a string) for undefined
3994
+ // input — a module tool returning `{ success: true }` with no data would
3995
+ // otherwise make `content.length` below throw a TypeError mid-turn.
3996
+ let content = JSON.stringify(afResult.data) ?? '';
3418
3997
  if (maxChars && content.length > maxChars) {
3419
3998
  content = safeSlice(content, 0, maxChars)
3420
3999
  + '\n\n[truncated — original was ' + content.length + ' chars]';
@@ -3486,11 +4065,11 @@ export class AgentFramework {
3486
4065
  }
3487
4066
  }
3488
4067
 
3489
- // Append to the inference log state
3490
- const data = this.store.getStateJson(INFERENCE_LOG_ID);
3491
- const entries = Array.isArray(data) ? data : [];
3492
- entries.push(entryToStore);
3493
- this.store.setStateJson(INFERENCE_LOG_ID, entries);
4068
+ // Append one record per inference. A read-modify-write via setStateJson
4069
+ // would emit a whole-array Set — record size then grows with accumulated
4070
+ // history (O(n²) aggregate disk) and the append_log strategy registered
4071
+ // for this state never sees an append.
4072
+ this.store.appendToStateJson(INFERENCE_LOG_ID, entryToStore);
3494
4073
  }
3495
4074
 
3496
4075
  private logProcessEvent(event: ProcessEvent, responses: ModuleProcessResponse[]): void {
@@ -3510,11 +4089,8 @@ export class AgentFramework {
3510
4089
  entryToStore.responses = { blobId };
3511
4090
  }
3512
4091
 
3513
- // Append to the process log state
3514
- const data = this.store.getStateJson(PROCESS_LOG_ID);
3515
- const entries = Array.isArray(data) ? data : [];
3516
- entries.push(entryToStore);
3517
- this.store.setStateJson(PROCESS_LOG_ID, entries);
4092
+ // Append one record per event — same rationale as logInference.
4093
+ this.store.appendToStateJson(PROCESS_LOG_ID, entryToStore);
3518
4094
  }
3519
4095
 
3520
4096
  /**
@@ -3744,14 +4320,20 @@ export class AgentFramework {
3744
4320
  this.noteInferenceExhausted(
3745
4321
  (event.agentName as string) ?? 'unknown',
3746
4322
  (event.error as string) ?? 'unknown error',
4323
+ event.retryable as boolean | undefined,
4324
+ event.errorType as string | undefined,
3747
4325
  );
3748
4326
  } else if (event.type === 'inference:completed') {
3749
4327
  // A successful response — even mid-turn between tool calls — proves the
3750
- // agent isn't hard-down; clear its consecutive-failure streak.
4328
+ // agent isn't hard-down; clear its consecutive-failure streak (and the
4329
+ // poison-history breaker's rewind budget).
3751
4330
  const name = event.agentName as string | undefined;
3752
4331
  if (name && this.consecutiveInferenceFailures.get(name)) {
3753
4332
  this.consecutiveInferenceFailures.set(name, 0);
3754
4333
  }
4334
+ if (name && this.exhaustionRewinds.get(name)) {
4335
+ this.exhaustionRewinds.set(name, 0);
4336
+ }
3755
4337
  }
3756
4338
 
3757
4339
  const traceEvent = {
@@ -3782,14 +4364,60 @@ export class AgentFramework {
3782
4364
  * inference, so this never causes a retry/wake loop.
3783
4365
  * 3. escalation — after N consecutive failures the agent is hard-down;
3784
4366
  * log that loudly (a repeated identical failure is the textbook signal).
4367
+ * 4. breaker — when the failures are an `invalid_request` (a 400-class
4368
+ * rejection of the history itself, e.g. corrupted tool_use/tool_result
4369
+ * pairing or an oversized attachment), retrying the same context can
4370
+ * never succeed: every new push event wakes the agent onto the same
4371
+ * poisoned history forever. At the hard-down threshold, automatically
4372
+ * quarantine: shed the newest complete exchange (the same forced-rewind
4373
+ * primitive `/unstick` uses — shedNewestTurn never orphans a
4374
+ * tool_use/thinking block) and retry, bounded by the same rewind cap so
4375
+ * the breaker can never eat the whole history.
4376
+ */
4377
+ /**
4378
+ * Live health snapshot for /healthz and doctor tooling: gate state, queued
4379
+ * work, per-agent status + last inference activity. Cheap and read-only.
3785
4380
  */
3786
- private noteInferenceExhausted(agentName: string, reason: string): void {
4381
+ healthSnapshot(): Record<string, unknown> {
4382
+ return {
4383
+ at: new Date().toISOString(),
4384
+ uptimeSec: Math.round(process.uptime()),
4385
+ gate: this.eventGate?.inferenceDiagnostics() ?? null,
4386
+ pendingRequests: this.pendingRequests.length,
4387
+ activeStreams: [...this.activeStreams.keys()],
4388
+ agents: [...this.agents.entries()].map(([name, agent]) => ({
4389
+ name,
4390
+ status: agent.state.status,
4391
+ consecutiveInferenceFailures: this.consecutiveInferenceFailures.get(name) ?? 0,
4392
+ lastInference: this.lastInferenceAt.get(name) ?? null,
4393
+ })),
4394
+ };
4395
+ }
4396
+
4397
+ private noteInferenceExhausted(
4398
+ agentName: string,
4399
+ reason: string,
4400
+ retryable?: boolean,
4401
+ errorType?: string,
4402
+ ): void {
3787
4403
  const streak = (this.consecutiveInferenceFailures.get(agentName) ?? 0) + 1;
3788
4404
  this.consecutiveInferenceFailures.set(agentName, streak);
4405
+ this.lastInferenceAt.set(agentName, { ...this.lastInferenceAt.get(agentName), failedAt: Date.now(), lastError: reason.slice(0, 300) });
3789
4406
 
3790
4407
  // (1) Durable stderr line — works in headless/daemon mode with no client.
3791
4408
  console.error(`[inference-failed] agent=${agentName} consecutive=${streak}: ${reason}`);
3792
4409
 
4410
+ // (1b) Machine-greppable durable record, independent of journald/unit log
4411
+ // redirects: logs/failures.log under the host's working directory. This is
4412
+ // what connectome-doctor reads.
4413
+ try {
4414
+ mkdirSync('logs', { recursive: true });
4415
+ appendFileSync(
4416
+ 'logs/failures.log',
4417
+ JSON.stringify({ at: new Date().toISOString(), agent: agentName, consecutive: streak, reason }) + '\n',
4418
+ );
4419
+ } catch { /* best-effort */ }
4420
+
3793
4421
  // (2) Agent-facing chronicle marker (no inference triggered → no loop).
3794
4422
  const agent = this.agents.get(agentName);
3795
4423
  if (agent && process.env.SUPPRESS_INFERENCE_FAILED_MARKER !== '1') {
@@ -3818,9 +4446,84 @@ export class AgentFramework {
3818
4446
  `[inference-hard-down] agent=${agentName} has FAILED ${streak} consecutive ` +
3819
4447
  `inferences — it cannot complete a turn. Last reason: ${reason}`,
3820
4448
  );
4449
+
4450
+ // Optional ops alert: set CONNECTOME_OPS_WEBHOOK to a Discord webhook URL.
4451
+ const hook = process.env.CONNECTOME_OPS_WEBHOOK;
4452
+ if (hook) {
4453
+ fetch(hook, {
4454
+ method: 'POST',
4455
+ headers: { 'content-type': 'application/json' },
4456
+ body: JSON.stringify({
4457
+ content: `\u{1F6A8} **${agentName}** hard-down: ${streak} consecutive inference failures.\nLast reason: ${reason.slice(0, 500)}`,
4458
+ }),
4459
+ }).catch((err) => console.error('[inference-hard-down] webhook post failed:', err instanceof Error ? err.message : err));
4460
+ }
4461
+
4462
+ // (4) Poison-history breaker: ONLY for `invalid_request` — a 400-class
4463
+ // rejection of the history itself. Deliberately NOT keyed on
4464
+ // `retryable === false`, which membrane also returns for auth (expired
4465
+ // key), abort (deliberate cancel), context_length (compression's job,
4466
+ // and shedding newest is the wrong direction), safety and unsupported —
4467
+ // none of which mean the history is poisoned, and all of which would
4468
+ // otherwise shed good exchanges and stamp a false "the API kept
4469
+ // rejecting your history" marker. `retryable` is kept only for the trace.
4470
+ void retryable;
4471
+ if (errorType === 'invalid_request' && agent) {
4472
+ this.quarantinePoisonedHistory(agent, reason);
4473
+ }
3821
4474
  }
3822
4475
  }
3823
4476
 
4477
+ /**
4478
+ * Automatic poison-history quarantine (the actual "breaker"). Sheds the
4479
+ * newest complete exchange from the agent's history — reusing the same
4480
+ * primitives as the refusal auto-rewind / `/unstick` (shedNewestTurn +
4481
+ * the single consolidated episode marker) — and queues a retry so the
4482
+ * rewound history is verified immediately instead of waiting for the next
4483
+ * push event to wake the agent onto the same poisoned context.
4484
+ *
4485
+ * Bounded: at most `refusalHandling.maxRewinds` (default 3, hard cap 10)
4486
+ * sheds per failure episode; the budget resets on any successful inference.
4487
+ * At the cap (or with nothing left to shed) it stops — the agent stays up
4488
+ * and hard-down logging continues, but no further history is consumed.
4489
+ */
4490
+ private quarantinePoisonedHistory(agent: Agent, reason: string): void {
4491
+ const cap = Math.max(1, Math.min(10, agent.refusalHandling?.maxRewinds ?? 3));
4492
+ const used = this.exhaustionRewinds.get(agent.name) ?? 0;
4493
+ if (used >= cap) {
4494
+ console.error(
4495
+ `[inference-rewind] agent=${agent.name} rewind cap ${cap} reached — ` +
4496
+ `not shedding further history. Manual repair (/unstick or /undo) needed.`,
4497
+ );
4498
+ return;
4499
+ }
4500
+
4501
+ const rec = this.shedNewestTurn(agent);
4502
+ if (!rec) {
4503
+ console.error(
4504
+ `[inference-rewind] agent=${agent.name} has nothing left to shed — ` +
4505
+ `history is only system markers. Manual repair needed.`,
4506
+ );
4507
+ return;
4508
+ }
4509
+
4510
+ this.exhaustionRewinds.set(agent.name, used + 1);
4511
+ const count = this.updateRewindMarker(agent, truncateReason(reason), 'inference-failure');
4512
+ console.error(
4513
+ `[inference-rewind] agent=${agent.name} auto-quarantined ${rec.descriptor} ` +
4514
+ `(${count} turn(s) shed this episode, cap ${cap}) after non-retryable ` +
4515
+ `inference failures — retrying on the rewound history.`,
4516
+ );
4517
+
4518
+ // Retry immediately on the repaired history (bounded by the cap above).
4519
+ this.pendingRequests.push({
4520
+ agentName: agent.name,
4521
+ reason: 'inference-failure-rewind-retry',
4522
+ source: 'framework',
4523
+ timestamp: Date.now(),
4524
+ });
4525
+ }
4526
+
3824
4527
  // ==========================================================================
3825
4528
  // MCPL subsystem wiring
3826
4529
  // ==========================================================================
@@ -3930,8 +4633,9 @@ export class AgentFramework {
3930
4633
  },
3931
4634
  );
3932
4635
 
3933
- // Host capabilities advertised during the MCP handshake
3934
- const hostCapabilities: McplHostCapabilities = {
4636
+ // Host capabilities advertised during the MCP handshake — stored so
4637
+ // servers can also be connected later at runtime (connectMcplServer).
4638
+ this.mcplHostCapabilities = {
3935
4639
  version: '0.4',
3936
4640
  pushEvents: true,
3937
4641
  contextHooks: {
@@ -3943,66 +4647,242 @@ export class AgentFramework {
3943
4647
 
3944
4648
  for (const config of serverConfigs) {
3945
4649
  try {
3946
- // Record per-server channel subscription policy before the server
3947
- // registers channels — handleRegister fires during the handshake.
3948
- if (config.channelSubscription !== undefined && this.channelRegistry) {
3949
- this.channelRegistry.setSubscriptionPolicy(config.id, config.channelSubscription);
3950
- }
4650
+ await this.connectMcplServerInternal(config);
4651
+ } catch (error) {
4652
+ // Fail-open: log and continue with remaining servers
4653
+ const err = error instanceof Error ? error : new Error(String(error));
4654
+ console.error(`Failed to connect MCPL server "${config.id}":`, err.message);
4655
+ // Reaching this catch means no stub was created, so nothing will
4656
+ // retry — except in the edge case where addServer succeeded (with a
4657
+ // reconnecting stub) and a later setup step threw; config.reconnect
4658
+ // is the best cheap approximation of that.
4659
+ this.emitTrace({
4660
+ type: 'mcpl:server-connect-failed',
4661
+ serverId: config.id,
4662
+ error: err.message,
4663
+ attempt: 0,
4664
+ willRetry: config.reconnect === true,
4665
+ });
4666
+ }
4667
+ }
3951
4668
 
3952
- const connection = await this.mcplServerRegistry.addServer(config, hostCapabilities);
3953
-
3954
- // Wire event listeners then flush any events that arrived during the
3955
- // handshake window (between setupMessageRouting and now).
3956
- this.wireMcplEvents(connection);
3957
- connection.ready();
3958
-
3959
- // Initialize feature sets if server advertises MCPL capabilities
3960
- if (connection.capabilities) {
3961
- const updateParams = this.featureSetManager.initializeServer(
3962
- config.id,
3963
- connection.capabilities,
3964
- {
3965
- enabledFeatureSets: config.enabledFeatureSets,
3966
- disabledFeatureSets: config.disabledFeatureSets,
3967
- },
3968
- );
4669
+ // Discover tools from all connected servers
4670
+ await this.refreshMcplTools();
4671
+ }
3969
4672
 
3970
- // Inform server which feature sets are enabled/disabled
3971
- if (updateParams.enabled?.length || updateParams.disabled?.length) {
3972
- connection.sendFeatureSetsUpdate(updateParams);
3973
- }
4673
+ /**
4674
+ * Connect a single MCPL server: register routing entries, open the
4675
+ * connection, wire events, and initialize feature sets / scopes /
4676
+ * checkpoints. Shared by startup (initializeMcpl) and the runtime
4677
+ * lifecycle API (connectMcplServer). Throws on connection failure.
4678
+ */
4679
+ private async connectMcplServerInternal(
4680
+ config: import('./mcpl/types.js').McplServerConfig,
4681
+ ): Promise<void> {
4682
+ if (!this.mcplServerRegistry || !this.mcplHostCapabilities) {
4683
+ throw new Error('MCPL subsystem is not initialized');
4684
+ }
3974
4685
 
3975
- // Configure scope whitelist/blacklist patterns
3976
- if (config.scopes) {
3977
- this.scopeManager.configureAll(config.scopes);
3978
- }
4686
+ // Register prefix + config for tool dispatch routing (idempotent with
4687
+ // the pre-registration pass in initializeMcpl).
4688
+ const prefix = config.toolPrefix ?? `mcpl--${config.id}`;
4689
+ this.mcplPrefixMap.set(prefix, config.id);
4690
+ this.mcplServerConfigs.set(config.id, config);
3979
4691
 
3980
- // Register stateful feature sets with checkpoint manager (Step 8)
3981
- if (this.checkpointManager) {
3982
- const declared = this.featureSetManager.getDeclaredFeatureSets(config.id);
3983
- if (declared) {
3984
- for (const [fsName, fsDecl] of Object.entries(declared)) {
3985
- if (fsDecl.rollback || fsDecl.hostState) {
3986
- this.checkpointManager.registerFeatureSet(config.id, fsName, {
3987
- hostState: fsDecl.hostState ?? false,
3988
- rollback: fsDecl.rollback ?? false,
3989
- });
3990
- }
3991
- }
3992
- }
4692
+ // Record per-server channel subscription policy before the server
4693
+ // registers channels — handleRegister fires during the handshake.
4694
+ if (config.channelSubscription !== undefined && this.channelRegistry) {
4695
+ this.channelRegistry.setSubscriptionPolicy(config.id, config.channelSubscription);
4696
+ }
4697
+
4698
+ const connection = await this.mcplServerRegistry.addServer(config, this.mcplHostCapabilities);
4699
+
4700
+ // Wire event listeners then flush any events that arrived during the
4701
+ // handshake window (between setupMessageRouting and now).
4702
+ this.wireMcplEvents(connection);
4703
+ connection.ready();
4704
+
4705
+ // Initialize feature sets if server advertises MCPL capabilities
4706
+ this.registerMcplServerFeatures(config, connection);
4707
+
4708
+ this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
4709
+ }
4710
+
4711
+ /**
4712
+ * (Re-)establish a server's host-side MCPL registration: feature-set state
4713
+ * (plus the featureSets/update sent to the server), scope patterns, and
4714
+ * stateful-feature-set checkpoint registration.
4715
+ *
4716
+ * Called on initial connect (connectMcplServerInternal) AND after every
4717
+ * auto-reconnect. The 'close' handler drops the feature-set registration, so
4718
+ * without the reconnect re-run FeatureSetManager.validateInbound would throw
4719
+ * "Unknown server" forever — every push event and inference request from the
4720
+ * revived server silently rejected until a full host restart.
4721
+ *
4722
+ * CheckpointManager.registerFeatureSet is idempotent (no-op for a key that
4723
+ * already has a tree), so checkpoint trees preserved across a transient
4724
+ * disconnect are resumed, not reset.
4725
+ */
4726
+ private registerMcplServerFeatures(
4727
+ config: import('./mcpl/types.js').McplServerConfig,
4728
+ connection: McplServerConnection,
4729
+ ): void {
4730
+ if (!connection.capabilities) return;
4731
+
4732
+ const updateParams = this.featureSetManager!.initializeServer(
4733
+ config.id,
4734
+ connection.capabilities,
4735
+ {
4736
+ enabledFeatureSets: config.enabledFeatureSets,
4737
+ disabledFeatureSets: config.disabledFeatureSets,
4738
+ },
4739
+ );
4740
+
4741
+ // Inform server which feature sets are enabled/disabled
4742
+ if (updateParams.enabled?.length || updateParams.disabled?.length) {
4743
+ connection.sendFeatureSetsUpdate(updateParams);
4744
+ }
4745
+
4746
+ // Configure scope whitelist/blacklist patterns
4747
+ if (config.scopes) {
4748
+ this.scopeManager!.configureAll(config.scopes);
4749
+ }
4750
+
4751
+ // Register stateful feature sets with checkpoint manager (Step 8)
4752
+ if (this.checkpointManager) {
4753
+ const declared = this.featureSetManager!.getDeclaredFeatureSets(config.id);
4754
+ if (declared) {
4755
+ for (const [fsName, fsDecl] of Object.entries(declared)) {
4756
+ if (fsDecl.rollback || fsDecl.hostState) {
4757
+ this.checkpointManager.registerFeatureSet(config.id, fsName, {
4758
+ hostState: fsDecl.hostState ?? false,
4759
+ rollback: fsDecl.rollback ?? false,
4760
+ });
3993
4761
  }
3994
4762
  }
3995
-
3996
- this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
3997
- } catch (error) {
3998
- // Fail-open: log and continue with remaining servers
3999
- const err = error instanceof Error ? error : new Error(String(error));
4000
- console.error(`Failed to connect MCPL server "${config.id}":`, err.message);
4001
4763
  }
4002
4764
  }
4765
+ }
4766
+
4767
+ // ==========================================================================
4768
+ // Runtime MCPL server lifecycle (agent-facing hot deploy/restart/unload)
4769
+ // ==========================================================================
4770
+
4771
+ /**
4772
+ * Connect a new MCPL server at runtime. Refreshes the tool list and
4773
+ * notifies the agent of newly available tools. Throws if the MCPL
4774
+ * subsystem is not initialized, the id is already connected, or the
4775
+ * connection/handshake fails.
4776
+ */
4777
+ async connectMcplServer(
4778
+ config: import('./mcpl/types.js').McplServerConfig,
4779
+ ): Promise<void> {
4780
+ // Lazily bring up the MCPL subsystem — a framework that started with zero
4781
+ // configured servers can still deploy its first one at runtime.
4782
+ if (!this.mcplServerRegistry || !this.mcplHostCapabilities) {
4783
+ await this.initializeMcpl([], this.mcplInferenceRoutingConfig ?? undefined);
4784
+ }
4785
+
4786
+ // Same collision rules create() enforces at startup: the tool prefix must
4787
+ // not shadow a module name or another server's prefix.
4788
+ const prefix = config.toolPrefix ?? `mcpl--${config.id}`;
4789
+ if (this.moduleRegistry.getAllModules().some(m => m.name === prefix)) {
4790
+ throw new Error(
4791
+ `MCPL server "${config.id}" toolPrefix "${prefix}" collides with module "${prefix}"`,
4792
+ );
4793
+ }
4794
+ const prefixOwner = this.mcplPrefixMap.get(prefix);
4795
+ if (prefixOwner && prefixOwner !== config.id) {
4796
+ throw new Error(
4797
+ `MCPL server "${config.id}" toolPrefix "${prefix}" collides with server "${prefixOwner}"`,
4798
+ );
4799
+ }
4800
+
4801
+ const oldToolNames = new Set(this.mcplTools.map(t => t.name));
4802
+ await this.connectMcplServerInternal(config);
4803
+ await this.refreshMcplTools();
4804
+ this.emitMcplToolDiff(oldToolNames, config.id);
4805
+ }
4806
+
4807
+ /**
4808
+ * Disconnect an MCPL server at runtime: close the connection, destroy its
4809
+ * feature-set and checkpoint state (permanent removal — unlike a transient
4810
+ * transport close, which preserves checkpoints for the reconnect), remove
4811
+ * its channels from the registry, drop routing entries, and refresh tools.
4812
+ * No-op-ish if the server is not connected (still clears routing state).
4813
+ */
4814
+ async disconnectMcplServer(id: string): Promise<void> {
4815
+ if (!this.mcplServerRegistry) {
4816
+ throw new Error('MCPL subsystem is not initialized');
4817
+ }
4818
+ const config = this.mcplServerConfigs.get(id);
4819
+ const oldToolNames = new Set(this.mcplTools.map(t => t.name));
4820
+
4821
+ await this.mcplServerRegistry.removeServer(id);
4822
+ this.channelRegistry?.removeServer(id);
4823
+
4824
+ // Permanent removal: also destroy feature-set and checkpoint state
4825
+ // explicitly. The 'close' handler usually does this, but a connection that
4826
+ // already transiently closed (reconnect pending) emits no second 'close'
4827
+ // from close(), and the transient path deliberately preserves checkpoints.
4828
+ this.featureSetManager?.removeServer(id);
4829
+ this.checkpointManager?.removeServer(id);
4830
+
4831
+ const prefix = config?.toolPrefix ?? `mcpl--${id}`;
4832
+ this.mcplPrefixMap.delete(prefix);
4833
+ this.mcplServerConfigs.delete(id);
4003
4834
 
4004
- // Discover tools from all connected servers
4005
4835
  await this.refreshMcplTools();
4836
+ this.emitMcplToolDiff(oldToolNames, id);
4837
+ }
4838
+
4839
+ /**
4840
+ * Restart an MCPL server at runtime: disconnect, then reconnect with the
4841
+ * same (or an updated) config. This actually respawns a stdio child —
4842
+ * unlike `reconnect: true`, which only retries after transport-level
4843
+ * failures. Throws if the server has no stored config and none is given.
4844
+ */
4845
+ async restartMcplServer(
4846
+ id: string,
4847
+ newConfig?: import('./mcpl/types.js').McplServerConfig,
4848
+ ): Promise<void> {
4849
+ const config = newConfig ?? this.mcplServerConfigs.get(id);
4850
+ if (!config) {
4851
+ throw new Error(`MCPL server "${id}" is not configured`);
4852
+ }
4853
+ await this.disconnectMcplServer(id);
4854
+ await this.connectMcplServer(config);
4855
+ }
4856
+
4857
+ /**
4858
+ * List configured MCPL servers with live connection status and the number
4859
+ * of tools each currently contributes.
4860
+ */
4861
+ listMcplServers(): Array<{
4862
+ id: string;
4863
+ connected: boolean;
4864
+ toolPrefix: string;
4865
+ toolCount: number;
4866
+ command?: string;
4867
+ url?: string;
4868
+ }> {
4869
+ const result: Array<{
4870
+ id: string; connected: boolean; toolPrefix: string; toolCount: number;
4871
+ command?: string; url?: string;
4872
+ }> = [];
4873
+ for (const [id, config] of this.mcplServerConfigs) {
4874
+ const prefix = config.toolPrefix ?? `mcpl--${id}`;
4875
+ const connection = this.mcplServerRegistry?.getServer(id) ?? null;
4876
+ result.push({
4877
+ id,
4878
+ connected: connection?.isConnected ?? false,
4879
+ toolPrefix: prefix,
4880
+ toolCount: this.mcplTools.filter(t => t.name.startsWith(`${prefix}--`)).length,
4881
+ command: config.command,
4882
+ url: config.url,
4883
+ });
4884
+ }
4885
+ return result;
4006
4886
  }
4007
4887
 
4008
4888
  /**
@@ -4100,15 +4980,105 @@ export class AgentFramework {
4100
4980
  this.handleToolsListChanged(connection.id);
4101
4981
  });
4102
4982
 
4103
- // Also refresh tools on reconnect (server may have different tools)
4104
- connection.on('reconnect', () => {
4983
+ // Re-establish full server registration on reconnect. The 'close' handler
4984
+ // removed the feature-set state, so pushes / inference requests from the
4985
+ // revived server would otherwise be rejected with "Unknown server" until a
4986
+ // host restart. Re-runs the same init as the initial connect path (the
4987
+ // fresh handshake refreshed connection.capabilities); checkpoint trees
4988
+ // preserved across the transient close are resumed by idempotent
4989
+ // registration. Then refresh tools (server may have different tools).
4990
+ connection.on('reconnect', (info?: { attempts?: number }) => {
4991
+ try {
4992
+ const config = this.mcplServerConfigs.get(connection.id);
4993
+ if (config) {
4994
+ this.registerMcplServerFeatures(config, connection);
4995
+ }
4996
+ } catch (error) {
4997
+ console.error(
4998
+ `MCPL server "${connection.id}" re-registration after reconnect failed:`,
4999
+ error instanceof Error ? error.message : error,
5000
+ );
5001
+ }
4105
5002
  this.handleToolsListChanged(connection.id);
5003
+ this.emitTrace({
5004
+ type: 'mcpl:server-reconnected',
5005
+ serverId: connection.id,
5006
+ attempts: info?.attempts ?? 0,
5007
+ });
5008
+ // Mirror the module:removed emitted on 'close' so module-lifecycle
5009
+ // consumers see the server come back, not just vanish.
5010
+ this.emitTrace({ type: 'module:added', moduleName: `mcpl:${connection.id}` });
4106
5011
  });
4107
5012
 
4108
- // Cleanup on disconnect
4109
- connection.on('close', () => {
5013
+ // Surface connect/reconnect failures. Before these traces existed the
5014
+ // only receipt was a console.error on the host's own stderr — invisible
5015
+ // unless someone ssh'd in and read the process log.
5016
+ connection.on('connect-failed', (params: { error: string; attempt: number }) => {
5017
+ this.emitTrace({
5018
+ type: 'mcpl:server-connect-failed',
5019
+ serverId: connection.id,
5020
+ error: params.error,
5021
+ attempt: params.attempt,
5022
+ willRetry: connection.willReconnect,
5023
+ });
5024
+ });
5025
+ connection.on('reconnect-failed', (params: { error: string; attempt: number }) => {
5026
+ this.emitTrace({
5027
+ type: 'mcpl:server-connect-failed',
5028
+ serverId: connection.id,
5029
+ error: params.error,
5030
+ attempt: params.attempt,
5031
+ willRetry: connection.willReconnect,
5032
+ });
5033
+ });
5034
+
5035
+ // A late response to a tools/call that already timed out, carrying
5036
+ // stateful data the server advanced to. The host can't re-inject it (the
5037
+ // dispatch context is gone), but surfacing it makes the checkpoint-tree
5038
+ // divergence greppable instead of a silent drift into stale state.
5039
+ connection.on('orphaned-response', (info: {
5040
+ id: string | number;
5041
+ hadState: boolean;
5042
+ hadCheckpoint: boolean;
5043
+ }) => {
5044
+ this.emitTrace({
5045
+ type: 'mcpl:orphaned-response',
5046
+ serverId: connection.id,
5047
+ responseId: info.id,
5048
+ hadState: info.hadState,
5049
+ hadCheckpoint: info.hadCheckpoint,
5050
+ });
5051
+ });
5052
+
5053
+ // Connection-level errors (e.g. child process 'error' after spawn).
5054
+ // Attaching this listener also keeps an unhandled EventEmitter 'error'
5055
+ // from crashing the host process.
5056
+ connection.on('error', (err: Error) => {
5057
+ this.emitTrace({
5058
+ type: 'mcpl:server-error',
5059
+ serverId: connection.id,
5060
+ error: err.message,
5061
+ });
5062
+ });
5063
+
5064
+ // Cleanup on disconnect. Feature-set state is always dropped (the server
5065
+ // can't be validated while down; reconnect re-registers it). Checkpoint
5066
+ // trees, however, are durable state: CheckpointManager.removeServer deletes
5067
+ // them AND persists the deletion to Chronicle. The close handler must
5068
+ // therefore NEVER destroy checkpoints — `willReconnect` is false on a clean
5069
+ // stop() too (reconnectEnabled is cleared before 'close' fires), so gating
5070
+ // on it would erase every checkpoint tree on an ordinary host restart while
5071
+ // a SIGKILL preserves them. Permanent removal is owned solely by
5072
+ // disconnectMcplServer, which deletes the trees explicitly.
5073
+ connection.on('close', (code?: number | null, signal?: string | null) => {
4110
5074
  this.featureSetManager?.removeServer(connection.id);
4111
- this.checkpointManager?.removeServer(connection.id);
5075
+ this.emitTrace({
5076
+ type: 'mcpl:server-closed',
5077
+ serverId: connection.id,
5078
+ code: code ?? null,
5079
+ signal: signal ?? null,
5080
+ willReconnect: connection.willReconnect,
5081
+ });
4112
5082
  this.emitTrace({ type: 'module:removed', moduleName: `mcpl:${connection.id}` });
4113
5083
  });
4114
5084
  }
@@ -4507,7 +5477,7 @@ export class AgentFramework {
4507
5477
  return;
4508
5478
  }
4509
5479
 
4510
- const { until } = gate.setSleep(seconds, input.message);
5480
+ const { until } = gate.setSleep(seconds, input.message, agentName);
4511
5481
  const announce = input.announce !== false; // default true
4512
5482
  const human =
4513
5483
  seconds >= 3600 ? `${(seconds / 3600).toFixed(1)}h`