@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
@@ -344,12 +344,16 @@ export class EventGate {
344
344
  private defaultSkipped = 0;
345
345
  private defaultByEventType = new Map<string, { triggered: number; skipped: number }>();
346
346
 
347
- // Sleep state (set via the `sleep` tool). Passive: evaluate() suppresses
348
- // non-privileged wakes while now() < sleepUntil. No timer — after expiry the
349
- // next event evaluates normally.
347
+ // Sleep state (set via the `sleep` tool). evaluate() suppresses non-privileged
348
+ // wakes while now() < sleepUntil. A self-wake timer (sleepTimer) fires an
349
+ // inference when the window expires, so `sleep(N)` means "wake me in N
350
+ // seconds" rather than merely lifting suppression and waiting for an external
351
+ // event.
350
352
  private sleepUntil = 0;
351
353
  private sleepNote: string | undefined;
352
354
  private sleepSuppressed = 0;
355
+ private sleepTimer: ReturnType<typeof setTimeout> | null = null;
356
+ private sleepAgent: string | undefined;
353
357
 
354
358
  // Privileged users who may wake the agent through sleep. Hot-reloaded from
355
359
  // privilegedUsersPath on change.
@@ -1098,6 +1102,18 @@ export class EventGate {
1098
1102
  }
1099
1103
  }
1100
1104
 
1105
+ /** Live inference-gating snapshot for diagnostics (e.g. a SIGUSR2 dump).
1106
+ * A non-empty `inferring` with a stale timestamp + growing `bufferedEvents`
1107
+ * is the signature of the wake-wedge: an agent stuck mid-"inference" so all
1108
+ * incoming events buffer and never trigger. */
1109
+ inferenceDiagnostics(): { inferring: string[]; bufferedEvents: number; sleepUntil: number } {
1110
+ return {
1111
+ inferring: [...this.inferring],
1112
+ bufferedEvents: this.inferenceBuffer.length,
1113
+ sleepUntil: this.sleepUntil,
1114
+ };
1115
+ }
1116
+
1101
1117
  // =========================================================================
1102
1118
  // Inference buffer cap
1103
1119
  // =========================================================================
@@ -1121,11 +1137,17 @@ export class EventGate {
1121
1137
  // =========================================================================
1122
1138
 
1123
1139
  /** Begin (or extend) a sleep window of `seconds`. Returns the wake time. */
1124
- setSleep(seconds: number, note?: string): { until: number } {
1140
+ setSleep(seconds: number, note?: string, agentName?: string): { until: number } {
1125
1141
  const ms = Math.max(0, Math.floor(seconds * 1000));
1126
1142
  this.sleepUntil = this.now() + ms;
1127
1143
  this.sleepNote = note;
1144
+ this.sleepAgent = agentName;
1128
1145
  this.sleepSuppressed = 0;
1146
+ // Arm the self-wake: when the window elapses, clear sleep and request an
1147
+ // inference so the agent resumes on its own (not dependent on a heartbeat
1148
+ // tick or an incoming message). Cancelled by clearSleep()/`wake`.
1149
+ if (this.sleepTimer) clearTimeout(this.sleepTimer);
1150
+ this.sleepTimer = setTimeout(() => this.wakeFromSleep('sleep-expired'), ms);
1129
1151
  this.reloadPrivilegedIfChanged();
1130
1152
  this.emitTrace({
1131
1153
  type: 'gate:decision',
@@ -1143,9 +1165,39 @@ export class EventGate {
1143
1165
  const wasAsleep = this.sleepUntil > this.now();
1144
1166
  this.sleepUntil = 0;
1145
1167
  this.sleepNote = undefined;
1168
+ this.sleepAgent = undefined;
1169
+ if (this.sleepTimer) { clearTimeout(this.sleepTimer); this.sleepTimer = null; }
1146
1170
  return wasAsleep;
1147
1171
  }
1148
1172
 
1173
+ /**
1174
+ * Self-wake handler: fired by the sleepTimer when a sleep window elapses.
1175
+ * Clears the sleep state and requests an inference so the agent wakes on its
1176
+ * own — this is what makes `sleep(N)` a real "wake me in N seconds" rather
1177
+ * than just a suppression window. No-op if sleep was already cleared (via
1178
+ * `wake`) or re-armed to a later time by a fresh `sleep` call.
1179
+ */
1180
+ private wakeFromSleep(reason: string): void {
1181
+ this.sleepTimer = null;
1182
+ if (this.sleepUntil === 0 || this.sleepUntil > this.now()) return;
1183
+ const note = this.sleepNote;
1184
+ const agent = this.sleepAgent;
1185
+ this.sleepUntil = 0;
1186
+ this.sleepNote = undefined;
1187
+ this.sleepAgent = undefined;
1188
+ this.emitTrace({
1189
+ type: 'gate:decision',
1190
+ eventType: 'sleep:expired',
1191
+ matchedPolicy: 'asleep',
1192
+ trigger: true,
1193
+ behavior: 'always',
1194
+ timestamp: this.now(),
1195
+ });
1196
+ const detail = note ? `${reason}: ${note}` : reason;
1197
+ const targets = agent ? [agent] : this.getAgentNamesFn();
1198
+ for (const a of targets) this.requestInferenceFn(a, detail, 'sleep');
1199
+ }
1200
+
1149
1201
  /** Current sleep state, or null if awake. */
1150
1202
  getSleepState(): { until: number; remainingMs: number; note?: string; suppressed: number } | null {
1151
1203
  const remainingMs = this.sleepUntil - this.now();
package/src/index.ts CHANGED
@@ -63,6 +63,16 @@ export type { GateConfig, GateOptions, GatePolicy, GatePolicyMatch, GateBehavior
63
63
  // MCPL channel registry (exposed for modules that need channel-level operations)
64
64
  export { ChannelRegistry } from './mcpl/index.js';
65
65
 
66
+ // MCPL server config (exposed for hosts that manage servers at runtime via
67
+ // connectMcplServer / disconnectMcplServer / restartMcplServer)
68
+ export type { McplServerConfig } from './mcpl/index.js';
69
+
70
+ // MCPL client connection (exposed so external tooling — server playtests,
71
+ // health probes, protocol harnesses — can dial an MCPL server exactly the
72
+ // way the framework host does, over stdio or WebSocket)
73
+ export { McplServerConnection } from './mcpl/index.js';
74
+ export type { McplHostCapabilities } from './mcpl/index.js';
75
+
66
76
  // Per-channel conversation routing
67
77
  export { ConversationRouter, DEFAULT_CLOSURE_PROMPT } from './mcpl/index.js';
68
78
  export type {
@@ -823,6 +823,25 @@ export class ChannelRegistry {
823
823
  // Lifecycle
824
824
  // ==========================================================================
825
825
 
826
+ /**
827
+ * Remove all channel state belonging to a single server. Called by the
828
+ * framework when an MCPL server is disconnected at runtime, so a dead
829
+ * server's channels don't linger and route speech into the void.
830
+ */
831
+ removeServer(serverId: string): void {
832
+ for (const [key, entry] of this.channels) {
833
+ if (entry.serverId !== serverId) continue;
834
+ this.channels.delete(key);
835
+ this.stopTyping(entry.descriptor.id);
836
+ if (this.defaultPublishChannel === entry.descriptor.id) {
837
+ this.defaultPublishChannel = null;
838
+ this.defaultPublishMessageId = null;
839
+ this.defaultPublishThreadId = undefined;
840
+ }
841
+ }
842
+ this.subscriptionPolicies.delete(serverId);
843
+ }
844
+
826
845
  /**
827
846
  * Stop all typing intervals and clear all channel registrations.
828
847
  */
@@ -1121,9 +1140,23 @@ export class ChannelRegistry {
1121
1140
  * is no locus / the channel or its server can't be resolved (in which case
1122
1141
  * the speech simply stays in chronicle + module surfaces).
1123
1142
  */
1143
+ /** Resolve the outbound locus (fork HOME → this-turn's TRIGGERING channel →
1144
+ * process-global default). Public so a multi-segment caller can snapshot it
1145
+ * ONCE and pin every segment to it via routeSpeech's `overrideChannelId`. */
1146
+ resolveLocus(conversationId: string): string | null {
1147
+ const home = this.homeChannelResolver?.(conversationId);
1148
+ return home ?? this.activeChannelResolver?.(conversationId) ?? this.defaultPublishChannel ?? null;
1149
+ }
1150
+
1124
1151
  async routeSpeech(
1125
1152
  conversationId: string,
1126
1153
  text: string,
1154
+ /** Pin the outbound locus, skipping resolution. Callers delivering MULTIPLE
1155
+ * segments of ONE turn snapshot resolveLocus() once and pass it here, so a
1156
+ * new turn starting mid-dispatch (segments run after the agent is idle,
1157
+ * PR #32) can't overwrite the per-agent triggering channel between segments
1158
+ * and split one reply across channels. */
1159
+ overrideChannelId?: string | null,
1127
1160
  ): Promise<{ delivered: boolean; channelId: string } | null> {
1128
1161
  // Surface a routing failure: emit a trace AND notify the host (which drops
1129
1162
  // a `[discord-send-failed]` marker into chronicle) so the agent learns her
@@ -1153,9 +1186,7 @@ export class ChannelRegistry {
1153
1186
  // Using the global for a fork or a concurrent trunk turn is the item-3 bug:
1154
1187
  // it tracks the most-recent inbound across ALL channels, so a reply lands
1155
1188
  // wherever a message last happened to arrive rather than where it belongs.
1156
- const home = this.homeChannelResolver?.(conversationId);
1157
- const channelId =
1158
- home ?? this.activeChannelResolver?.(conversationId) ?? this.defaultPublishChannel;
1189
+ const channelId = overrideChannelId ?? this.resolveLocus(conversationId);
1159
1190
  if (!channelId) {
1160
1191
  // Reached only when the agent has no home, no active triggering channel,
1161
1192
  // AND no global inbound was ever seen.
@@ -171,12 +171,16 @@ export class PushHandler {
171
171
  const reason = err instanceof McplFeatureSetError
172
172
  ? err.message
173
173
  : 'Feature set validation failed';
174
+ // Loud rejection — a rejected push event is an agent that silently
175
+ // never hears the message. (2026-07-09 diagnosability pass.)
176
+ console.error(`[push-event-rejected] server=${serverId} eventId=${params.eventId} reason=${reason}`);
174
177
  responder?.respond({ accepted: false, reason });
175
178
  return;
176
179
  }
177
180
 
178
181
  // 2. Deduplicate by eventId
179
182
  if (this.dedup.checkAndAdd(params.eventId)) {
183
+ console.error(`[push-event-rejected] server=${serverId} eventId=${params.eventId} reason=duplicate`);
180
184
  responder?.respond({ accepted: false, reason: 'duplicate' });
181
185
  return;
182
186
  }
@@ -46,6 +46,13 @@ import { McplMethod } from './types.js';
46
46
  * Spring Boot + JDA servers can take 5-10s to boot, so 30s is safe. */
47
47
  const INITIALIZE_TIMEOUT_MS = 30_000;
48
48
 
49
+ /** Default per-request timeout in milliseconds (see McplServerConfig.requestTimeoutMs).
50
+ * A live-but-wedged server (accepts requests, never answers, never closes its
51
+ * transport) would otherwise freeze the awaiting agent turn forever — no
52
+ * endTurn, no further wakes, zero errors anywhere. 60s is generous for real
53
+ * tool work while still bounding the hang. */
54
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
55
+
49
56
  /** MCP protocol version used in the initialize handshake. */
50
57
  const MCP_PROTOCOL_VERSION = '2024-11-05';
51
58
 
@@ -56,6 +63,8 @@ interface PendingRequest {
56
63
  resolve: (result: unknown) => void;
57
64
  reject: (error: Error) => void;
58
65
  method: string;
66
+ /** Per-request timeout timer; cleared on response/close/timeout. */
67
+ timer?: ReturnType<typeof setTimeout>;
59
68
  }
60
69
 
61
70
  /**
@@ -73,6 +82,9 @@ interface PendingRequest {
73
82
  * - `'feature-sets-changed'` — Server sent `featureSets/changed`
74
83
  * - `'error'` — Connection-level error
75
84
  * - `'close'` — Connection closed
85
+ * - `'connect-failed'` — Initial connect failed; background retry scheduled
86
+ * - `'reconnect-failed'` — A background reconnect attempt failed; next retry scheduled
87
+ * - `'reconnect'` — Background reconnect succeeded
76
88
  */
77
89
  export class McplServerConnection extends EventEmitter {
78
90
  /** Unique server identifier from config. */
@@ -84,10 +96,20 @@ export class McplServerConnection extends EventEmitter {
84
96
  /** The active transport (stdio child or WebSocket). Null for a disconnected
85
97
  * stub awaiting its first background reconnect. */
86
98
  private transport: McplTransport | null;
99
+
100
+ /** Whether the connection currently has a live transport. False for closed
101
+ * connections and disconnected reconnect-stubs awaiting a retry. */
102
+ get isConnected(): boolean {
103
+ return !this.closed && this.transport !== null;
104
+ }
105
+
87
106
  private nextRequestId = 1;
88
107
  private pendingRequests = new Map<string | number, PendingRequest>();
89
108
  private closed = false;
90
109
 
110
+ /** Per-request timeout in ms (0 disables). See McplServerConfig.requestTimeoutMs. */
111
+ private requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;
112
+
91
113
  // Event buffering: events emitted before ready() are queued, not lost
92
114
  private readyFlag = false;
93
115
  private bufferedEvents: Array<{ event: string; args: unknown[] }> = [];
@@ -97,7 +119,17 @@ export class McplServerConnection extends EventEmitter {
97
119
  private hostCapabilities: McplHostCapabilities | null = null;
98
120
  private reconnectEnabled = false;
99
121
  private reconnectIntervalMs = 5000;
122
+ private reconnectMaxIntervalMs = 300_000;
100
123
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
124
+ /** Consecutive failed connect attempts since the last successful handshake.
125
+ * Drives the exponential backoff and is reported on reconnect events. */
126
+ private reconnectAttempts = 0;
127
+
128
+ /** Whether a background reconnect loop will revive this connection after a
129
+ * drop. False once close() has been called (explicit close stops retrying). */
130
+ get willReconnect(): boolean {
131
+ return this.reconnectEnabled;
132
+ }
101
133
 
102
134
  /**
103
135
  * Private constructor. Use `McplServerConnection.connect()` instead.
@@ -167,6 +199,7 @@ export class McplServerConnection extends EventEmitter {
167
199
  const { transport, capabilities } = await McplServerConnection.handshake(config, hostCapabilities);
168
200
 
169
201
  const connection = new McplServerConnection(config.id, capabilities, transport);
202
+ connection.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
170
203
 
171
204
  // Store config for reconnection
172
205
  if (config.reconnect) {
@@ -174,6 +207,7 @@ export class McplServerConnection extends EventEmitter {
174
207
  connection.hostCapabilities = hostCapabilities;
175
208
  connection.reconnectEnabled = true;
176
209
  connection.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000;
210
+ connection.reconnectMaxIntervalMs = config.reconnectMaxIntervalMs ?? 300_000;
177
211
  }
178
212
 
179
213
  return connection;
@@ -281,7 +315,12 @@ export class McplServerConnection extends EventEmitter {
281
315
 
282
316
  // Non-blocking start: create a disconnected stub that will reconnect in background
283
317
  console.error(`MCPL server "${config.id}" initial connect failed, will retry:`, (error as Error).message);
284
- return McplServerConnection.createDisconnectedStub(config, hostCapabilities);
318
+ const stub = McplServerConnection.createDisconnectedStub(config, hostCapabilities);
319
+ // Surface the initial failure. The event is buffered by the emit()
320
+ // override until the host wires listeners and calls ready(), so it is
321
+ // not lost in the window before wireMcplEvents runs.
322
+ stub.emit('connect-failed', { error: (error as Error).message, attempt: 0 });
323
+ return stub;
285
324
  }
286
325
  }
287
326
 
@@ -297,10 +336,13 @@ export class McplServerConnection extends EventEmitter {
297
336
  // Null transport — the stub is closed until its first background reconnect.
298
337
  const stub = new McplServerConnection(config.id, null, null);
299
338
  stub.closed = true;
339
+ stub.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
300
340
  stub.config = config;
301
341
  stub.hostCapabilities = hostCapabilities;
302
342
  stub.reconnectEnabled = true;
303
343
  stub.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000;
344
+ stub.reconnectMaxIntervalMs = config.reconnectMaxIntervalMs ?? 300_000;
345
+ stub.reconnectAttempts = 1; // the initial connect already failed
304
346
 
305
347
  // Schedule background reconnect
306
348
  stub.scheduleReconnect();
@@ -441,6 +483,7 @@ export class McplServerConnection extends EventEmitter {
441
483
 
442
484
  // Reject all pending requests
443
485
  for (const [id, pending] of this.pendingRequests) {
486
+ if (pending.timer) clearTimeout(pending.timer);
444
487
  pending.reject(new Error(`Connection to MCPL server "${this.id}" closed while awaiting response for ${pending.method} (id=${id})`));
445
488
  }
446
489
  this.pendingRequests.clear();
@@ -457,14 +500,26 @@ export class McplServerConnection extends EventEmitter {
457
500
 
458
501
  /**
459
502
  * Schedule a background reconnection attempt.
503
+ *
504
+ * Delay grows exponentially with consecutive failures (base
505
+ * `reconnectIntervalMs`, doubling per failure, capped at
506
+ * `reconnectMaxIntervalMs`) with ±25% jitter so a fleet of servers that
507
+ * died together doesn't thundering-herd the same instant. The counter
508
+ * resets on a successful handshake, so a later disconnect starts over at
509
+ * the base interval.
460
510
  */
461
511
  private scheduleReconnect(): void {
462
512
  if (!this.reconnectEnabled || this.reconnectTimer) return;
463
513
 
514
+ const exponent = Math.max(0, Math.min(this.reconnectAttempts - 1, 30));
515
+ const uncapped = this.reconnectIntervalMs * 2 ** exponent;
516
+ const capped = Math.min(uncapped, this.reconnectMaxIntervalMs);
517
+ const delay = capped * (0.75 + Math.random() * 0.5);
518
+
464
519
  this.reconnectTimer = setTimeout(() => {
465
520
  this.reconnectTimer = null;
466
521
  this.attemptReconnect();
467
- }, this.reconnectIntervalMs);
522
+ }, delay);
468
523
  }
469
524
 
470
525
  /**
@@ -476,6 +531,10 @@ export class McplServerConnection extends EventEmitter {
476
531
  private async attemptReconnect(): Promise<void> {
477
532
  if (!this.reconnectEnabled || !this.config || !this.hostCapabilities) return;
478
533
 
534
+ // Ordinal of this attempt: 0 was the initial connect, so the Nth retry
535
+ // reports attempt N (reconnectAttempts counts failures so far).
536
+ const attempt = Math.max(1, this.reconnectAttempts);
537
+
479
538
  try {
480
539
  const { transport, capabilities } = await McplServerConnection.handshake(
481
540
  this.config,
@@ -494,9 +553,12 @@ export class McplServerConnection extends EventEmitter {
494
553
  this.readyFlag = true;
495
554
 
496
555
  console.error(`MCPL server "${this.id}" reconnected successfully`);
497
- this.emit('reconnect');
556
+ this.reconnectAttempts = 0;
557
+ this.emit('reconnect', { attempts: attempt });
498
558
  } catch (error) {
499
559
  console.error(`MCPL server "${this.id}" reconnect failed:`, (error as Error).message);
560
+ this.reconnectAttempts = attempt + 1;
561
+ this.emit('reconnect-failed', { error: (error as Error).message, attempt });
500
562
  this.scheduleReconnect();
501
563
  }
502
564
  }
@@ -508,6 +570,13 @@ export class McplServerConnection extends EventEmitter {
508
570
  /**
509
571
  * Send a JSON-RPC request and return a promise that resolves with the result
510
572
  * or rejects with a JSON-RPC error.
573
+ *
574
+ * Every request carries a timeout (default 60s, configurable via
575
+ * McplServerConfig.requestTimeoutMs, 0 disables): a live-but-stuck server
576
+ * that accepts a request and never answers rejects the pending promise with
577
+ * a descriptive error instead of freezing the caller forever. For tools/call
578
+ * the framework maps the rejection to an isError tool_result, so a hung tool
579
+ * surfaces as a normal tool error and the turn completes.
511
580
  */
512
581
  private sendRequest(method: string, params: Record<string, unknown>): Promise<unknown> {
513
582
  if (this.closed) {
@@ -523,7 +592,23 @@ export class McplServerConnection extends EventEmitter {
523
592
  };
524
593
 
525
594
  return new Promise((resolve, reject) => {
526
- this.pendingRequests.set(id, { resolve, reject, method });
595
+ const pending: PendingRequest = { resolve, reject, method };
596
+ if (this.requestTimeoutMs > 0) {
597
+ pending.timer = setTimeout(() => {
598
+ this.pendingRequests.delete(id);
599
+ reject(new Error(
600
+ `MCPL server "${this.id}" did not respond to ${method} (id=${id}) ` +
601
+ `within ${this.requestTimeoutMs}ms — the server may be hung. ` +
602
+ `The request was abandoned; the connection remains open. Note the ` +
603
+ `tool may still have completed server-side (this is only a response ` +
604
+ `timeout, not a cancellation) — verify state before retrying, as a ` +
605
+ `blind retry of a stateful/side-effecting tool may duplicate it.`,
606
+ ));
607
+ }, this.requestTimeoutMs);
608
+ // Don't hold the event loop open for the watchdog alone.
609
+ pending.timer.unref?.();
610
+ }
611
+ this.pendingRequests.set(id, pending);
527
612
  this.transport!.writeLine(JSON.stringify(request));
528
613
  });
529
614
  }
@@ -626,10 +711,30 @@ export class McplServerConnection extends EventEmitter {
626
711
  private handleResponse(response: JsonRpcResponse): void {
627
712
  const pending = this.pendingRequests.get(response.id);
628
713
  if (!pending) {
714
+ // Orphaned response — the request already timed out (or was settled) and
715
+ // was removed from pendingRequests. Stateless late results are harmless to
716
+ // drop, but a late STATEFUL result carries a `state`/`checkpoint` the
717
+ // server has already advanced to: dropping it silently diverges the host's
718
+ // checkpoint tree from the server's, and every subsequent call then sends
719
+ // stale state. We can't safely re-inject it here (the dispatch context is
720
+ // gone), but we surface it so the divergence is greppable instead of
721
+ // invisible.
722
+ const result = response.result;
723
+ if (result && typeof result === 'object') {
724
+ const r = result as { state?: unknown; checkpoint?: unknown };
725
+ if (r.state !== undefined || r.checkpoint !== undefined) {
726
+ this.emit('orphaned-response', {
727
+ id: response.id,
728
+ hadState: r.state !== undefined,
729
+ hadCheckpoint: r.checkpoint !== undefined,
730
+ });
731
+ }
732
+ }
629
733
  return; // Orphaned response — ignore
630
734
  }
631
735
 
632
736
  this.pendingRequests.delete(response.id);
737
+ if (pending.timer) clearTimeout(pending.timer);
633
738
 
634
739
  if (response.error) {
635
740
  pending.reject(
@@ -682,6 +787,7 @@ export class McplServerConnection extends EventEmitter {
682
787
 
683
788
  // Reject all pending requests
684
789
  for (const [id, pending] of this.pendingRequests) {
790
+ if (pending.timer) clearTimeout(pending.timer);
685
791
  pending.reject(
686
792
  new Error(
687
793
  `MCPL server "${this.id}" disconnected unexpectedly (code=${info.code ?? 'n/a'}, signal=${info.signal ?? 'n/a'}, reason=${info.reason ?? 'unknown'}) while awaiting ${pending.method} (id=${id})`,
package/src/mcpl/types.ts CHANGED
@@ -234,11 +234,29 @@ export interface McplServerConfig {
234
234
  reconnect?: boolean;
235
235
 
236
236
  /**
237
- * Interval between reconnection attempts in milliseconds.
237
+ * Base interval between reconnection attempts in milliseconds.
238
+ * The actual delay doubles with each consecutive failure (with ±25% jitter)
239
+ * up to `reconnectMaxIntervalMs`, and resets after a successful handshake.
238
240
  * Default: 5000 (5 seconds).
239
241
  */
240
242
  reconnectIntervalMs?: number;
241
243
 
244
+ /**
245
+ * Ceiling for the exponential reconnect backoff in milliseconds.
246
+ * Default: 300000 (5 minutes).
247
+ */
248
+ reconnectMaxIntervalMs?: number;
249
+
250
+ /**
251
+ * Per-request timeout in milliseconds for outbound JSON-RPC requests
252
+ * (tools/call, tools/list, channels/*, hooks). A live-but-stuck server that
253
+ * accepts a request and never responds would otherwise freeze the awaiting
254
+ * agent turn forever; with the timeout the pending request rejects with a
255
+ * descriptive error, which the framework surfaces as a normal isError
256
+ * tool_result. Set 0 to disable. Default: 60000 (60 seconds).
257
+ */
258
+ requestTimeoutMs?: number;
259
+
242
260
  /**
243
261
  * Optional callback to filter which incoming channel messages should trigger
244
262
  * agent inference. Receives the text content and message metadata.
@@ -27,6 +27,25 @@ import type { Agent } from './agent.js';
27
27
 
28
28
  const MODULE_STATE_PREFIX = 'modules/';
29
29
 
30
+ /**
31
+ * True when a registerState failure means "this state id already exists" —
32
+ * the one failure that is safe to swallow on an idempotent re-registration.
33
+ * Anything else (closed store, IO error) must propagate: swallowing it defers
34
+ * the real error to a later write with far worse context.
35
+ */
36
+ export function isStateExistsError(err: unknown): boolean {
37
+ return err instanceof Error && err.message.includes('State already exists');
38
+ }
39
+
40
+ /**
41
+ * Hard ceiling for a single module's gatherContext budget, regardless of the
42
+ * `contextTimeoutMs` it declares. gatherContext runs on the critical path of
43
+ * every inference turn, so an unbounded self-declared budget would let one
44
+ * wedged module stall every agent. 30s is generous for legitimate work (e.g. a
45
+ * retrieval module's sequential LLM calls) while keeping the worst case bounded.
46
+ */
47
+ const MAX_CONTEXT_TIMEOUT_MS = 30_000;
48
+
30
49
  /**
31
50
  * Registered speech handler.
32
51
  */
@@ -302,20 +321,44 @@ export class ModuleRegistry {
302
321
  /**
303
322
  * Gather context injections from all modules before inference.
304
323
  * Calls each module's gatherContext() in parallel with a per-module timeout.
305
- * Fail-open: timed-out or erroring modules are skipped silently.
324
+ * Fail-open: timed-out or erroring modules are skipped (with a stderr line).
325
+ *
326
+ * The per-module budget is `module.contextTimeoutMs` when declared, else the
327
+ * `timeoutMs` default (15s). Modules whose gatherContext does real work —
328
+ * e.g. a retrieval module making sequential LLM calls with backoff — should
329
+ * declare a larger `contextTimeoutMs` rather than silently losing their
330
+ * injection every turn to the shared default. The declared value is clamped
331
+ * to MAX_CONTEXT_TIMEOUT_MS so no single module can hold every turn hostage.
306
332
  * Adapted from Anarchid/agent-framework@mcpl-module-proto.
307
333
  */
308
- async gatherContext(agentName: string, timeoutMs = 5000): Promise<ContextInjection[]> {
334
+ async gatherContext(agentName: string, timeoutMs = 15_000): Promise<ContextInjection[]> {
309
335
  const injections: ContextInjection[] = [];
310
336
  const promises: Promise<void>[] = [];
311
337
 
312
338
  for (const module of this.modules.values()) {
313
339
  if (!module.gatherContext) continue;
314
340
 
341
+ // Clamp the per-module budget. A module declaring `contextTimeoutMs`
342
+ // legitimately needs more than the shared default (e.g. a retrieval
343
+ // module doing sequential LLM calls), but it must not be able to hold
344
+ // EVERY inference turn of EVERY agent hostage: the fail-open contract is
345
+ // "a wedged module never blocks inference for long", so cap the wait.
346
+ const budgetMs = Math.min(
347
+ module.contextTimeoutMs ?? timeoutMs,
348
+ MAX_CONTEXT_TIMEOUT_MS,
349
+ );
350
+ let timer: ReturnType<typeof setTimeout> | undefined;
315
351
  const promise = Promise.race([
316
352
  module.gatherContext(agentName).then(r => injections.push(...r)),
317
- new Promise<void>((_, rej) => setTimeout(() => rej(new Error('timeout')), timeoutMs)),
318
- ]).catch(err => console.error(`[${module.name}] gatherContext:`, err));
353
+ new Promise<void>((_, rej) => {
354
+ timer = setTimeout(
355
+ () => rej(new Error(`gatherContext timed out after ${budgetMs}ms`)),
356
+ budgetMs,
357
+ );
358
+ }),
359
+ ])
360
+ .catch(err => console.error(`[${module.name}] gatherContext:`, err))
361
+ .finally(() => clearTimeout(timer));
319
362
 
320
363
  promises.push(promise as Promise<void>);
321
364
  }
@@ -435,6 +478,72 @@ class ModuleContextImpl implements ModuleContext {
435
478
  this.store.setStateJson(this.stateId, fullState);
436
479
  }
437
480
 
481
+ /** Log-state names this context has registered (registerLogState is
482
+ * contract-required in start(), so this is populated before any use). */
483
+ private registeredLogStates = new Set<string>();
484
+
485
+ private logStateId(name: string): string {
486
+ if (!name || name === 'state' || name.includes('/')) {
487
+ throw new Error(
488
+ `Invalid module log state name '${name}': must be non-empty, not 'state', and contain no '/'`
489
+ );
490
+ }
491
+ return `${MODULE_STATE_PREFIX}${this.moduleName}/${name}`;
492
+ }
493
+
494
+ /**
495
+ * Like logStateId, but only for names that went through registerLogState.
496
+ * Chronicle happily appends to an unregistered state — it just never gets a
497
+ * snapshot strategy, so every reconstruction replays the full op chain from
498
+ * record one. A forgotten registration or a typo'd name in one call site
499
+ * must be an immediate, attributable error, not a silent O(n²)-read trap.
500
+ */
501
+ private registeredLogStateId(name: string): string {
502
+ if (!this.registeredLogStates.has(name)) {
503
+ throw new Error(
504
+ `Module log state '${name}' is not registered for module '${this.moduleName}' — ` +
505
+ `call registerLogState('${name}') in start() before using it`
506
+ );
507
+ }
508
+ return this.logStateId(name);
509
+ }
510
+
511
+ registerLogState(
512
+ name: string,
513
+ opts?: { deltaSnapshotEvery?: number; fullSnapshotEvery?: number }
514
+ ): void {
515
+ const id = this.logStateId(name);
516
+ try {
517
+ this.store.registerState({
518
+ id,
519
+ strategy: 'append_log',
520
+ deltaSnapshotEvery: opts?.deltaSnapshotEvery ?? 100,
521
+ fullSnapshotEvery: opts?.fullSnapshotEvery ?? 20,
522
+ });
523
+ } catch (err) {
524
+ if (!isStateExistsError(err)) throw err;
525
+ // Already registered (previous run) — idempotent path.
526
+ }
527
+ this.registeredLogStates.add(name);
528
+ }
529
+
530
+ appendToLog<T>(name: string, item: T): void {
531
+ this.store.appendToStateJson(this.registeredLogStateId(name), item);
532
+ }
533
+
534
+ editLogItem<T>(name: string, index: number, item: T): void {
535
+ this.store.editStateItem(this.registeredLogStateId(name), index, Buffer.from(JSON.stringify(item)));
536
+ }
537
+
538
+ getLog<T>(name: string): T[] {
539
+ const data = this.store.getStateJson(this.registeredLogStateId(name));
540
+ return Array.isArray(data) ? (data as T[]) : [];
541
+ }
542
+
543
+ getLogLength(name: string): number {
544
+ return this.store.getStateLen(this.registeredLogStateId(name)) ?? 0;
545
+ }
546
+
438
547
  getModule<T extends Module>(name: string): T | null {
439
548
  return this.registry.getModule<T>(name);
440
549
  }
@@ -45,6 +45,22 @@ export interface AgentConfig {
45
45
  /** Per-agent context compile budget (input tokens). When unset, the
46
46
  * ContextManager's built-in default (100k) applies. */
47
47
  contextBudgetTokens?: number;
48
+
49
+ /**
50
+ * Prompt-cache TTL forwarded to the provider (Anthropic `cache_control.ttl`).
51
+ * '5m' (provider default) or '1h'. When unset, Membrane's default applies.
52
+ *
53
+ * Why you'd set '1h': for persistent agents whose conversational cadence is
54
+ * slower than 5 minutes, the default TTL expires between turns and the full
55
+ * context is re-WRITTEN to cache on nearly every call. Cache writes carry a
56
+ * premium (1.25x base input for 5m, 2x for 1h) while reads cost 0.1x — so a
57
+ * chatty-but-not-rapid agent pays the write premium over and over. With '1h'
58
+ * the write happens once per idle-hour and subsequent turns hit cache reads;
59
+ * in practice cache writes can dominate spend for slow-cadence agents.
60
+ * Keep '5m' (or unset) for high-frequency loops with sub-5-minute cadence,
61
+ * where the cheaper write premium wins.
62
+ */
63
+ cacheTtl?: '5m' | '1h';
48
64
  /**
49
65
  * Extended thinking config. When `enabled: true`, the agent runs with
50
66
  * Anthropic's native extended thinking; responses include `thinking` blocks
@@ -63,6 +79,28 @@ export interface AgentConfig {
63
79
  */
64
80
  display?: 'summarized' | 'omitted';
65
81
  };
82
+ /**
83
+ * How to handle a model content-policy refusal (`stop_reason: refusal`).
84
+ * When `autoRewind` is on, a refused turn triggers an automatic rewind: the
85
+ * framework redacts the triggering turn (the tool result or message that
86
+ * tripped the classifier), injects a metadata-only marker in its place (which
87
+ * carries none of the offending content, so it cannot itself re-trip), and
88
+ * re-runs — up to `maxRewinds` times before giving up. This keeps the agent
89
+ * on its own model (no fallback-model substitution) while self-healing around
90
+ * a poison turn. Default: off (a refusal just surfaces a marker + reaction).
91
+ */
92
+ refusalHandling?: {
93
+ /** Auto-rewind the triggering turn on refusal and retry. Default false. */
94
+ autoRewind?: boolean;
95
+ /** Max consecutive rewinds before giving up a turn. Default 3. */
96
+ maxRewinds?: number;
97
+ /**
98
+ * When the rewound turn is a *human* message (not a machine tool result),
99
+ * announce the withholding on the conversational surface (Discord) rather
100
+ * than dropping it silently. Default true.
101
+ */
102
+ announceHumanTurns?: boolean;
103
+ };
66
104
  }
67
105
 
68
106
  /**