@animalabs/agent-framework 0.6.6 → 0.6.8

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 (117) hide show
  1. package/README.md +78 -0
  2. package/dist/src/agent.d.ts +5 -1
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +35 -1
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server.js +0 -0
  7. package/dist/src/api/server.d.ts.map +1 -1
  8. package/dist/src/api/server.js +7 -2
  9. package/dist/src/api/server.js.map +1 -1
  10. package/dist/src/framework.d.ts +91 -3
  11. package/dist/src/framework.d.ts.map +1 -1
  12. package/dist/src/framework.js +988 -82
  13. package/dist/src/framework.js.map +1 -1
  14. package/dist/src/gate/event-gate.d.ts.map +1 -1
  15. package/dist/src/gate/event-gate.js +9 -2
  16. package/dist/src/gate/event-gate.js.map +1 -1
  17. package/dist/src/index.d.ts +4 -0
  18. package/dist/src/index.d.ts.map +1 -1
  19. package/dist/src/index.js +3 -0
  20. package/dist/src/index.js.map +1 -1
  21. package/dist/src/mcpl/channel-registry.d.ts +3 -1
  22. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  23. package/dist/src/mcpl/channel-registry.js +29 -15
  24. package/dist/src/mcpl/channel-registry.js.map +1 -1
  25. package/dist/src/mcpl/server-connection.d.ts +36 -3
  26. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  27. package/dist/src/mcpl/server-connection.js +142 -28
  28. package/dist/src/mcpl/server-connection.js.map +1 -1
  29. package/dist/src/modules/workspace/index.d.ts +12 -1
  30. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  31. package/dist/src/modules/workspace/index.js +661 -2
  32. package/dist/src/modules/workspace/index.js.map +1 -1
  33. package/dist/src/modules/workspace/types.d.ts +4 -0
  34. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  35. package/dist/src/modules/workspace/types.js.map +1 -1
  36. package/dist/src/recovery/discord-awareness-outbox.d.ts +99 -0
  37. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -0
  38. package/dist/src/recovery/discord-awareness-outbox.js +334 -0
  39. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -0
  40. package/dist/src/recovery/offline-branch.d.ts +45 -0
  41. package/dist/src/recovery/offline-branch.d.ts.map +1 -0
  42. package/dist/src/recovery/offline-branch.js +362 -0
  43. package/dist/src/recovery/offline-branch.js.map +1 -0
  44. package/dist/src/recovery/recover-cli.d.ts +3 -0
  45. package/dist/src/recovery/recover-cli.d.ts.map +1 -0
  46. package/dist/src/recovery/recover-cli.js +129 -0
  47. package/dist/src/recovery/recover-cli.js.map +1 -0
  48. package/dist/src/types/agent.d.ts +10 -0
  49. package/dist/src/types/agent.d.ts.map +1 -1
  50. package/dist/src/types/framework.d.ts +14 -0
  51. package/dist/src/types/framework.d.ts.map +1 -1
  52. package/dist/src/types/module.d.ts +31 -0
  53. package/dist/src/types/module.d.ts.map +1 -1
  54. package/dist/src/types/trace.d.ts +2 -0
  55. package/dist/src/types/trace.d.ts.map +1 -1
  56. package/dist/test/agent-runtime-settings.test.js +200 -93
  57. package/dist/test/agent-runtime-settings.test.js.map +1 -1
  58. package/dist/test/busy-agent-scheduler.test.d.ts +2 -0
  59. package/dist/test/busy-agent-scheduler.test.d.ts.map +1 -0
  60. package/dist/test/busy-agent-scheduler.test.js +62 -0
  61. package/dist/test/busy-agent-scheduler.test.js.map +1 -0
  62. package/dist/test/channel-lifecycle-provenance.test.d.ts +2 -0
  63. package/dist/test/channel-lifecycle-provenance.test.d.ts.map +1 -0
  64. package/dist/test/channel-lifecycle-provenance.test.js +47 -0
  65. package/dist/test/channel-lifecycle-provenance.test.js.map +1 -0
  66. package/dist/test/discord-awareness-outbox.test.d.ts +2 -0
  67. package/dist/test/discord-awareness-outbox.test.d.ts.map +1 -0
  68. package/dist/test/discord-awareness-outbox.test.js +248 -0
  69. package/dist/test/discord-awareness-outbox.test.js.map +1 -0
  70. package/dist/test/maintenance-timer.test.js +100 -0
  71. package/dist/test/maintenance-timer.test.js.map +1 -1
  72. package/dist/test/mcpl-awareness-barrier.test.d.ts +2 -0
  73. package/dist/test/mcpl-awareness-barrier.test.d.ts.map +1 -0
  74. package/dist/test/mcpl-awareness-barrier.test.js +618 -0
  75. package/dist/test/mcpl-awareness-barrier.test.js.map +1 -0
  76. package/dist/test/mcpl-reconnect-reregistration.test.js +22 -3
  77. package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
  78. package/dist/test/mcpl-request-timeout.test.js +15 -0
  79. package/dist/test/mcpl-request-timeout.test.js.map +1 -1
  80. package/dist/test/offline-recovery-branch.test.d.ts +2 -0
  81. package/dist/test/offline-recovery-branch.test.d.ts.map +1 -0
  82. package/dist/test/offline-recovery-branch.test.js +226 -0
  83. package/dist/test/offline-recovery-branch.test.js.map +1 -0
  84. package/dist/test/present-while-acting.test.d.ts +3 -2
  85. package/dist/test/present-while-acting.test.d.ts.map +1 -1
  86. package/dist/test/present-while-acting.test.js +31 -3
  87. package/dist/test/present-while-acting.test.js.map +1 -1
  88. package/dist/test/refusal-single-call.test.d.ts +2 -0
  89. package/dist/test/refusal-single-call.test.d.ts.map +1 -0
  90. package/dist/test/refusal-single-call.test.js +177 -0
  91. package/dist/test/refusal-single-call.test.js.map +1 -0
  92. package/dist/test/same-round-think-policy.test.d.ts +2 -0
  93. package/dist/test/same-round-think-policy.test.d.ts.map +1 -0
  94. package/dist/test/same-round-think-policy.test.js +853 -0
  95. package/dist/test/same-round-think-policy.test.js.map +1 -0
  96. package/dist/test/workspace-read-image.test.d.ts +2 -0
  97. package/dist/test/workspace-read-image.test.d.ts.map +1 -0
  98. package/dist/test/workspace-read-image.test.js +518 -0
  99. package/dist/test/workspace-read-image.test.js.map +1 -0
  100. package/dist/tsconfig.tsbuildinfo +1 -1
  101. package/package.json +5 -3
  102. package/src/agent.ts +39 -2
  103. package/src/api/server.ts +8 -2
  104. package/src/framework.ts +1180 -79
  105. package/src/gate/event-gate.ts +15 -3
  106. package/src/index.ts +20 -0
  107. package/src/mcpl/channel-registry.ts +31 -16
  108. package/src/mcpl/server-connection.ts +163 -28
  109. package/src/modules/workspace/index.ts +759 -2
  110. package/src/modules/workspace/types.ts +5 -0
  111. package/src/recovery/discord-awareness-outbox.ts +459 -0
  112. package/src/recovery/offline-branch.ts +510 -0
  113. package/src/recovery/recover-cli.ts +130 -0
  114. package/src/types/agent.ts +14 -0
  115. package/src/types/framework.ts +17 -0
  116. package/src/types/module.ts +33 -0
  117. package/src/types/trace.ts +2 -0
@@ -72,6 +72,10 @@ interface PendingEvent {
72
72
  * of a message the agent may have long since handled. */
73
73
  inContext: boolean;
74
74
  channelId?: string;
75
+ /** Human-readable channel name (e.g. Discord channel name) when the event's
76
+ * metadata carries one — batched-wake lines render this instead of the raw
77
+ * composite channel id. */
78
+ channelLabel?: string;
75
79
  }
76
80
 
77
81
  interface DebounceState {
@@ -1029,6 +1033,10 @@ export class EventGate {
1029
1033
  timestamp: Date.now(),
1030
1034
  inContext: info.eventType === 'mcpl:channel-incoming' || info.eventType === 'mcpl:push-event',
1031
1035
  channelId: info.channelId || undefined,
1036
+ channelLabel:
1037
+ typeof info.metadata?.channelName === 'string' && info.metadata.channelName
1038
+ ? (info.metadata.channelName as string)
1039
+ : undefined,
1032
1040
  };
1033
1041
 
1034
1042
  const existing = this.debounceTimers.get(policy.name);
@@ -1087,15 +1095,19 @@ export class EventGate {
1087
1095
 
1088
1096
  const lines: string[] = quoted.map(e => `- [${e.policyName}] (${e.eventType}): ${e.content}`);
1089
1097
  if (referenced.length > 0) {
1090
- const byChannel = new Map<string, { count: number; oldest: number }>();
1098
+ const byChannel = new Map<string, { count: number; oldest: number; label?: string }>();
1091
1099
  for (const e of referenced) {
1092
1100
  const key = e.channelId ?? '(unknown channel)';
1093
- const entry = byChannel.get(key) ?? { count: 0, oldest: e.timestamp };
1101
+ const entry = byChannel.get(key) ?? { count: 0, oldest: e.timestamp, label: e.channelLabel };
1094
1102
  entry.count += 1;
1095
1103
  entry.oldest = Math.min(entry.oldest, e.timestamp);
1104
+ entry.label = entry.label ?? e.channelLabel;
1096
1105
  byChannel.set(key, entry);
1097
1106
  }
1098
- for (const [channel, { count, oldest }] of byChannel) {
1107
+ for (const [channelId, { count, oldest, label }] of byChannel) {
1108
+ // Prefer the human-readable name; keep the id parenthesized so the
1109
+ // agent can still address tools that want the composite id.
1110
+ const channel = label ? `#${label} (${channelId})` : channelId;
1099
1111
  const age = Math.round((Date.now() - oldest) / 1000);
1100
1112
  lines.push(
1101
1113
  `- ${count} message${count > 1 ? 's' : ''} in ${channel} ` +
package/src/index.ts CHANGED
@@ -91,3 +91,23 @@ export type { ContextManager, ContextStrategy, TokenBudget } from '@animalabs/co
91
91
  export { PassthroughStrategy, AutobiographicalStrategy, KnowledgeStrategy } from '@animalabs/context-manager';
92
92
  export type { KnowledgeConfig, PhaseType } from '@animalabs/context-manager';
93
93
  export type { Membrane, NormalizedMessage, NormalizedRequest, ContentBlock } from '@animalabs/membrane';
94
+
95
+ // Offline outage recovery and branch-independent Discord awareness markers
96
+ export {
97
+ DiscordAwarenessOutbox,
98
+ DEFAULT_DISCORD_AWARENESS_EMOJI,
99
+ defaultDiscordAwarenessOutboxPath,
100
+ extractDiscordAwarenessRefs,
101
+ } from './recovery/discord-awareness-outbox.js';
102
+ export type {
103
+ DiscordAwarenessBatch,
104
+ DiscordAwarenessEntry,
105
+ DiscordAwarenessOperation,
106
+ DiscordAwarenessRef,
107
+ DiscordSuppressionInterval,
108
+ } from './recovery/discord-awareness-outbox.js';
109
+ export { createOfflineRecoveryBranch } from './recovery/offline-branch.js';
110
+ export type {
111
+ OfflineRecoveryBranchOptions,
112
+ OfflineRecoveryBranchResult,
113
+ } from './recovery/offline-branch.js';
@@ -228,10 +228,10 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
228
228
  name: 'think',
229
229
  description:
230
230
  'Reason privately. The content stays in your own context and is NOT sent to any ' +
231
- 'channel or surface. This does NOT end or silence your turn: if you also write ' +
232
- 'plain text this turn, that text is still posted as your reply. Use think() purely ' +
233
- 'to work things out before (or instead of) speaking. To deliberately NOT reply this ' +
234
- 'turn, call skip_reply instead.',
231
+ 'channel or surface. Same-round routing of ordinary text beside think() depends on ' +
232
+ 'your current same_round_think_text_policy; inspect or change it with agent_settings. ' +
233
+ 'Use think() purely to work things out before (or instead of) speaking. To deliberately ' +
234
+ 'NOT reply this turn, call skip_reply instead.',
235
235
  inputSchema: {
236
236
  type: 'object' as const,
237
237
  properties: {
@@ -356,8 +356,10 @@ export class ChannelRegistry {
356
356
  * server keeps getting the same routing hints (e.g. Zulip topic). */
357
357
  private typingMetadata = new Map<string, Record<string, unknown>>();
358
358
 
359
- /** Chronicle-projected desired lifecycle state, keyed by server + channel. */
360
- private desiredStates = new Map<string, DesiredChannelState>();
359
+ /** Chronicle-projected desired lifecycle state, keyed by server + channel.
360
+ * Provenance is kept so reconcile can tell a pure default (nobody ever
361
+ * decided) from a real decision (agent-tool, invitation-declined, …). */
362
+ private desiredStates = new Map<string, { state: DesiredChannelState; source: string }>();
361
363
 
362
364
  /** One-time migration inputs from the retired recipe auto-open policy. */
363
365
  private legacyPolicies = new Map<string, 'auto' | 'manual' | string[]>();
@@ -779,7 +781,7 @@ export class ChannelRegistry {
779
781
  }
780
782
 
781
783
  getDesiredState(serverId: string, channelId: string): DesiredChannelState | undefined {
782
- return this.desiredStates.get(this.lifecycleKey(serverId, channelId));
784
+ return this.desiredStates.get(this.lifecycleKey(serverId, channelId))?.state;
783
785
  }
784
786
 
785
787
  /**
@@ -976,7 +978,7 @@ export class ChannelRegistry {
976
978
  ) {
977
979
  this.desiredStates.set(
978
980
  this.lifecycleKey(event.serverId, event.channelId),
979
- event.desired,
981
+ { state: event.desired, source: typeof event.source === "string" ? event.source : "unknown" },
980
982
  );
981
983
  } else if (event.kind === 'legacy-policy-migrated') {
982
984
  this.migratedLegacyPolicies.add(event.serverId);
@@ -995,8 +997,8 @@ export class ChannelRegistry {
995
997
  source: string,
996
998
  ): void {
997
999
  const key = this.lifecycleKey(serverId, channelId);
998
- if (this.desiredStates.get(key) === desired) return;
999
- this.desiredStates.set(key, desired);
1000
+ if (this.desiredStates.get(key)?.state === desired) return;
1001
+ this.desiredStates.set(key, { state: desired, source });
1000
1002
  this.appendLifecycleEvent({
1001
1003
  kind: 'desired-state',
1002
1004
  serverId,
@@ -1036,7 +1038,16 @@ export class ChannelRegistry {
1036
1038
  }
1037
1039
 
1038
1040
  private ensureInitialDesiredState(serverId: string, channel: ChannelDescriptor): void {
1039
- if (this.getDesiredState(serverId, channel.id)) return;
1041
+ const existing = this.desiredStates.get(this.lifecycleKey(serverId, channel.id));
1042
+ if (existing) {
1043
+ // A server upgrading its descriptor to initiallyOpen may lift a pure
1044
+ // default — 'default-closed' means nobody ever decided. Real decisions
1045
+ // (agent-tool, invitation-declined, legacy migration, …) always stick.
1046
+ if (channel.initiallyOpen === true && existing.state === 'closed' && existing.source === 'default-closed') {
1047
+ this.setDesiredState(serverId, channel.id, 'open', 'server-bootstrap');
1048
+ }
1049
+ return;
1050
+ }
1040
1051
  this.setDesiredState(
1041
1052
  serverId,
1042
1053
  channel.id,
@@ -1393,12 +1404,14 @@ export class ChannelRegistry {
1393
1404
  private handleToolThink(input: { content?: string }): ToolResult {
1394
1405
  return {
1395
1406
  success: true,
1407
+ // Same echo-avoidance as skip_reply: the thought text is already in the
1408
+ // tool_use block; don't duplicate it in the result.
1396
1409
  data: {
1397
1410
  noted: true,
1398
1411
  note:
1399
- 'Thought recorded (private — not sent anywhere). This did NOT silence your turn: ' +
1400
- 'write plain text to reply, or call skip_reply to end the turn without replying.',
1401
- content: typeof input?.content === 'string' ? input.content : undefined,
1412
+ 'Thought recorded (private — not sent anywhere). Same-round text routing depends on ' +
1413
+ 'your current same_round_think_text_policy; use agent_settings get to inspect it, or ' +
1414
+ 'call skip_reply to end the turn without replying.',
1402
1415
  },
1403
1416
  };
1404
1417
  }
@@ -1419,10 +1432,12 @@ export class ChannelRegistry {
1419
1432
  // again: observed as a 40+ round skip_reply loop on Fable 5, burning a
1420
1433
  // round-trip + ~70 tokens per iteration until something kills the turn.
1421
1434
  endTurn: true,
1435
+ // Deliberately terse: the agent's `reason` is already in the tool_use
1436
+ // block — echoing it back doubled every skip turn's footprint (observed
1437
+ // on Fable: ~60% of skip_reply result bytes were verbatim input echo).
1422
1438
  data: {
1423
1439
  skipped: true,
1424
- note: 'Ended the turn without replying — nothing was sent to any channel.',
1425
- reason: typeof input?.reason === 'string' ? input.reason : undefined,
1440
+ note: 'Turn ended; nothing sent.',
1426
1441
  },
1427
1442
  };
1428
1443
  }
@@ -69,6 +69,13 @@ interface PendingRequest {
69
69
  timer?: ReturnType<typeof setTimeout>;
70
70
  }
71
71
 
72
+ interface RequestOptions {
73
+ /** Override the connection-wide timeout for this request only. */
74
+ timeoutMs?: number;
75
+ /** Surface a late response even when it carries no checkpoint state. */
76
+ surfaceOrphanedResponse?: boolean;
77
+ }
78
+
72
79
  /**
73
80
  * McplServerConnection manages a single JSON-RPC 2.0 connection to an
74
81
  * MCPL server over stdio. Use the static `connect()` factory to create
@@ -107,13 +114,20 @@ export class McplServerConnection extends EventEmitter {
107
114
 
108
115
  private nextRequestId = 1;
109
116
  private pendingRequests = new Map<string | number, PendingRequest>();
117
+ /** Metadata retained after selected request deadlines so a late response is
118
+ * observable even though its original promise has already been rejected. */
119
+ private orphanedRequests = new Map<string | number, { method: string }>();
110
120
  private closed = false;
111
121
 
112
122
  /** Per-request timeout in ms (0 disables). See McplServerConfig.requestTimeoutMs. */
113
123
  private requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;
114
124
 
115
- // Event buffering: events emitted before ready() are queued, not lost
116
- private readyFlag = false;
125
+ // Event buffering: control-plane traffic can be released independently from
126
+ // inference-bearing data-plane traffic. This lets a server finish its own
127
+ // registration (and answer host tools/call requests) while startup/reconnect
128
+ // reconciliation keeps agent wakes behind a durable barrier.
129
+ private controlPlaneReady = false;
130
+ private dataPlaneReady = false;
117
131
  private bufferedEvents: Array<{ event: string; args: unknown[] }> = [];
118
132
 
119
133
  // Reconnect state (adapted from Anarchid/agent-framework@mcpl-module-proto)
@@ -162,26 +176,87 @@ export class McplServerConnection extends EventEmitter {
162
176
  * the caller has attached listeners via wireMcplEvents).
163
177
  */
164
178
  ready(): void {
165
- this.readyFlag = true;
166
- for (const { event, args } of this.bufferedEvents) {
167
- super.emit(event, ...args);
179
+ this.controlPlaneReady = true;
180
+ this.dataPlaneReady = true;
181
+ const pending = this.bufferedEvents;
182
+ this.bufferedEvents = [];
183
+ for (const { event, args } of pending) {
184
+ // Re-enter the gate for every buffered item. A control handler (notably
185
+ // tools/list_changed) may install a newer data-plane barrier while this
186
+ // snapshot is being flushed; later data items must observe that pause.
187
+ this.emit(event, ...args);
168
188
  }
189
+ }
190
+
191
+ /**
192
+ * Release only server-establishment and lifecycle/control traffic. Responses
193
+ * to host requests are never event-buffered, so tools/list and tools/call
194
+ * responses remain live as well. Inference-bearing requests stay queued in
195
+ * their original relative order until {@link ready} is called.
196
+ */
197
+ readyControlPlane(): void {
198
+ this.controlPlaneReady = true;
199
+ const pending = this.bufferedEvents;
200
+ const deferred: Array<{ event: string; args: unknown[] }> = [];
169
201
  this.bufferedEvents = [];
202
+ for (const item of pending) {
203
+ if (McplServerConnection.isControlPlaneEvent(item.event)) {
204
+ super.emit(item.event, ...item.args);
205
+ } else {
206
+ deferred.push(item);
207
+ }
208
+ }
209
+ // Data events emitted by control handlers while the snapshot was flushing
210
+ // were appended to the new buffer. Preserve their order behind older data.
211
+ this.bufferedEvents = [...deferred, ...this.bufferedEvents];
212
+ }
213
+
214
+ /**
215
+ * Close the data plane synchronously while leaving an already-released
216
+ * control plane live. Used before reconnect adoption and list-change
217
+ * reconciliation so no new inference event can beat barrier installation.
218
+ */
219
+ pauseDataPlane(): void {
220
+ this.dataPlaneReady = false;
170
221
  }
171
222
 
172
223
  /**
173
- * Override emit to buffer server→host events until ready() is called.
174
- * Lifecycle events ('close', 'error', 'reconnect') always pass through.
224
+ * Override emit to buffer server→host events until the corresponding plane
225
+ * is ready. Lifecycle events always pass through so reconnect can install a
226
+ * new data-plane gate before the fresh transport is exposed.
175
227
  */
176
228
  override emit(event: string | symbol, ...args: unknown[]): boolean {
177
229
  const name = typeof event === 'string' ? event : '';
178
- if (this.readyFlag || name === 'close' || name === 'error' || name === 'reconnect') {
230
+ if (
231
+ McplServerConnection.isLifecycleEvent(name)
232
+ || (McplServerConnection.isControlPlaneEvent(name)
233
+ ? this.controlPlaneReady
234
+ : this.dataPlaneReady)
235
+ ) {
179
236
  return super.emit(event, ...args);
180
237
  }
181
238
  this.bufferedEvents.push({ event: name, args });
182
239
  return true;
183
240
  }
184
241
 
242
+ private static isControlPlaneEvent(event: string): boolean {
243
+ return event === 'stderr'
244
+ || event === 'scope-elevate'
245
+ || event === 'channels-register'
246
+ || event === 'channels-changed'
247
+ || event === 'feature-sets-changed'
248
+ || event === 'tools-list-changed'
249
+ || event === 'connect-failed'
250
+ || event === 'reconnect-failed'
251
+ || event === 'orphaned-response';
252
+ }
253
+
254
+ private static isLifecycleEvent(event: string): boolean {
255
+ return event === 'close'
256
+ || event === 'error'
257
+ || event === 'reconnect';
258
+ }
259
+
185
260
  // ==========================================================================
186
261
  // Static factory
187
262
  // ==========================================================================
@@ -473,6 +548,26 @@ export class McplServerConnection extends EventEmitter {
473
548
  return this.sendRequest('tools/call', params) as Promise<McpToolCallResult>;
474
549
  }
475
550
 
551
+ /**
552
+ * Send a tools/call with a mandatory request-local deadline. This is used by
553
+ * durable external-side-effect accounting, whose safety bound must remain in
554
+ * force even when the server's general requestTimeoutMs is configured as 0.
555
+ */
556
+ sendToolsCallWithDeadline(
557
+ name: string,
558
+ args: Record<string, unknown>,
559
+ timeoutMs: number,
560
+ ): Promise<McpToolCallResult> {
561
+ const params: Record<string, unknown> = { name, arguments: args };
562
+ const mandatoryTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0
563
+ ? Math.max(1, Math.floor(timeoutMs))
564
+ : 1;
565
+ return this.sendRequest('tools/call', params, {
566
+ timeoutMs: mandatoryTimeoutMs,
567
+ surfaceOrphanedResponse: true,
568
+ }) as Promise<McpToolCallResult>;
569
+ }
570
+
476
571
  // ==========================================================================
477
572
  // Lifecycle
478
573
  // ==========================================================================
@@ -497,6 +592,7 @@ export class McplServerConnection extends EventEmitter {
497
592
  pending.reject(new Error(`Connection to MCPL server "${this.id}" closed while awaiting response for ${pending.method} (id=${id})`));
498
593
  }
499
594
  this.pendingRequests.clear();
595
+ this.orphanedRequests.clear();
500
596
 
501
597
  // Tear down the transport (kills the child / closes the socket). The
502
598
  // transport's own 'close' event is short-circuited by `this.closed` in
@@ -508,6 +604,27 @@ export class McplServerConnection extends EventEmitter {
508
604
  this.emit('close');
509
605
  }
510
606
 
607
+ /**
608
+ * Tear down a live transport after an awareness-accounting failure without
609
+ * disabling configured auto-reconnect. The normal transport close handler
610
+ * rejects requests, emits lifecycle state, and schedules the next bounded
611
+ * reconnect attempt. With reconnect disabled this leaves the server closed.
612
+ */
613
+ async reconnectAfterFailure(): Promise<void> {
614
+ this.pauseDataPlane();
615
+ if (this.closed) {
616
+ this.scheduleReconnect();
617
+ return;
618
+ }
619
+ if (this.transport) {
620
+ await this.transport.close();
621
+ return;
622
+ }
623
+ this.closed = true;
624
+ this.emit('close');
625
+ this.scheduleReconnect();
626
+ }
627
+
511
628
  /**
512
629
  * Schedule a background reconnection attempt.
513
630
  *
@@ -551,16 +668,17 @@ export class McplServerConnection extends EventEmitter {
551
668
  this.hostCapabilities,
552
669
  );
553
670
 
554
- // Adopt the new transport. Listeners were attached to external consumers
555
- // on the first wire, so mark ready immediately (re-wire re-binds the
556
- // transport-level handlers below).
671
+ // Close the data plane before the new transport can emit. The reconnect
672
+ // lifecycle event bypasses buffering; its framework listener installs the
673
+ // awareness barrier synchronously, then releases control traffic needed
674
+ // to establish the server while data remains held.
675
+ this.pauseDataPlane();
557
676
  this.transport = transport;
558
677
  this.capabilities = capabilities;
559
678
  this.closed = false;
560
679
  this.nextRequestId = 1;
561
680
  this.pendingRequests.clear();
562
681
  this.wireTransport(transport);
563
- this.readyFlag = true;
564
682
 
565
683
  console.error(`MCPL server "${this.id}" reconnected successfully`);
566
684
  this.reconnectAttempts = 0;
@@ -588,7 +706,11 @@ export class McplServerConnection extends EventEmitter {
588
706
  * the framework maps the rejection to an isError tool_result, so a hung tool
589
707
  * surfaces as a normal tool error and the turn completes.
590
708
  */
591
- private sendRequest(method: string, params: Record<string, unknown>): Promise<unknown> {
709
+ private sendRequest(
710
+ method: string,
711
+ params: Record<string, unknown>,
712
+ options: RequestOptions = {},
713
+ ): Promise<unknown> {
592
714
  if (this.closed) {
593
715
  return Promise.reject(new Error(`Cannot send request: connection to "${this.id}" is closed`));
594
716
  }
@@ -603,18 +725,22 @@ export class McplServerConnection extends EventEmitter {
603
725
 
604
726
  return new Promise((resolve, reject) => {
605
727
  const pending: PendingRequest = { resolve, reject, method };
606
- if (this.requestTimeoutMs > 0) {
728
+ const timeoutMs = options.timeoutMs ?? this.requestTimeoutMs;
729
+ if (timeoutMs > 0) {
607
730
  pending.timer = setTimeout(() => {
608
731
  this.pendingRequests.delete(id);
732
+ if (options.surfaceOrphanedResponse) {
733
+ this.orphanedRequests.set(id, { method });
734
+ }
609
735
  reject(new Error(
610
736
  `MCPL server "${this.id}" did not respond to ${method} (id=${id}) ` +
611
- `within ${this.requestTimeoutMs}ms — the server may be hung. ` +
612
- `The request was abandoned; the connection remains open. Note the ` +
613
- `tool may still have completed server-side (this is only a response ` +
614
- `timeout, not a cancellation) — verify state before retrying, as a ` +
615
- `blind retry of a stateful/side-effecting tool may duplicate it.`,
737
+ `within ${timeoutMs}ms — the server may be hung. The response ` +
738
+ `outcome is unknown: the request was abandoned locally but was not ` +
739
+ `cancelled, so the tool may still have completed server-side; verify ` +
740
+ `state before retrying; a blind retry of a stateful/side-effecting ` +
741
+ `tool may duplicate it.`,
616
742
  ));
617
- }, this.requestTimeoutMs);
743
+ }, timeoutMs);
618
744
  // Don't hold the event loop open for the watchdog alone.
619
745
  pending.timer.unref?.();
620
746
  }
@@ -722,23 +848,31 @@ export class McplServerConnection extends EventEmitter {
722
848
  const pending = this.pendingRequests.get(response.id);
723
849
  if (!pending) {
724
850
  // Orphaned response — the request already timed out (or was settled) and
725
- // was removed from pendingRequests. Stateless late results are harmless to
726
- // drop, but a late STATEFUL result carries a `state`/`checkpoint` the
727
- // server has already advanced to: dropping it silently diverges the host's
728
- // checkpoint tree from the server's, and every subsequent call then sends
729
- // stale state. We can't safely re-inject it here (the dispatch context is
730
- // gone), but we surface it so the divergence is greppable instead of
731
- // invisible.
851
+ // was removed from pendingRequests. A late STATEFUL result must be
852
+ // surfaced because dropping it silently diverges the checkpoint tree.
853
+ // Selected side-effecting calls are also surfaced even without state so
854
+ // their outcome-unknown receipt remains visible. We cannot safely
855
+ // re-inject either result because the original dispatch context is gone.
856
+ const orphaned = this.orphanedRequests.get(response.id);
857
+ this.orphanedRequests.delete(response.id);
732
858
  const result = response.result;
733
859
  if (result && typeof result === 'object') {
734
860
  const r = result as { state?: unknown; checkpoint?: unknown };
735
- if (r.state !== undefined || r.checkpoint !== undefined) {
861
+ if (orphaned || r.state !== undefined || r.checkpoint !== undefined) {
736
862
  this.emit('orphaned-response', {
737
863
  id: response.id,
864
+ method: orphaned?.method,
738
865
  hadState: r.state !== undefined,
739
866
  hadCheckpoint: r.checkpoint !== undefined,
740
867
  });
741
868
  }
869
+ } else if (orphaned) {
870
+ this.emit('orphaned-response', {
871
+ id: response.id,
872
+ method: orphaned.method,
873
+ hadState: false,
874
+ hadCheckpoint: false,
875
+ });
742
876
  }
743
877
  return; // Orphaned response — ignore
744
878
  }
@@ -805,6 +939,7 @@ export class McplServerConnection extends EventEmitter {
805
939
  );
806
940
  }
807
941
  this.pendingRequests.clear();
942
+ this.orphanedRequests.clear();
808
943
 
809
944
  this.emit('close', info.code ?? null, info.signal ?? null);
810
945