@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.
- package/README.md +78 -0
- package/dist/src/agent.d.ts +5 -1
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +35 -1
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/mcp-server.js +0 -0
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +7 -2
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/framework.d.ts +91 -3
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +988 -82
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +9 -2
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +3 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +29 -15
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +36 -3
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +142 -28
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +12 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +661 -2
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +4 -0
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.d.ts +99 -0
- package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -0
- package/dist/src/recovery/discord-awareness-outbox.js +334 -0
- package/dist/src/recovery/discord-awareness-outbox.js.map +1 -0
- package/dist/src/recovery/offline-branch.d.ts +45 -0
- package/dist/src/recovery/offline-branch.d.ts.map +1 -0
- package/dist/src/recovery/offline-branch.js +362 -0
- package/dist/src/recovery/offline-branch.js.map +1 -0
- package/dist/src/recovery/recover-cli.d.ts +3 -0
- package/dist/src/recovery/recover-cli.d.ts.map +1 -0
- package/dist/src/recovery/recover-cli.js +129 -0
- package/dist/src/recovery/recover-cli.js.map +1 -0
- package/dist/src/types/agent.d.ts +10 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +14 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +31 -0
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +2 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/agent-runtime-settings.test.js +200 -93
- package/dist/test/agent-runtime-settings.test.js.map +1 -1
- package/dist/test/busy-agent-scheduler.test.d.ts +2 -0
- package/dist/test/busy-agent-scheduler.test.d.ts.map +1 -0
- package/dist/test/busy-agent-scheduler.test.js +62 -0
- package/dist/test/busy-agent-scheduler.test.js.map +1 -0
- package/dist/test/channel-lifecycle-provenance.test.d.ts +2 -0
- package/dist/test/channel-lifecycle-provenance.test.d.ts.map +1 -0
- package/dist/test/channel-lifecycle-provenance.test.js +47 -0
- package/dist/test/channel-lifecycle-provenance.test.js.map +1 -0
- package/dist/test/discord-awareness-outbox.test.d.ts +2 -0
- package/dist/test/discord-awareness-outbox.test.d.ts.map +1 -0
- package/dist/test/discord-awareness-outbox.test.js +248 -0
- package/dist/test/discord-awareness-outbox.test.js.map +1 -0
- package/dist/test/maintenance-timer.test.js +100 -0
- package/dist/test/maintenance-timer.test.js.map +1 -1
- package/dist/test/mcpl-awareness-barrier.test.d.ts +2 -0
- package/dist/test/mcpl-awareness-barrier.test.d.ts.map +1 -0
- package/dist/test/mcpl-awareness-barrier.test.js +618 -0
- package/dist/test/mcpl-awareness-barrier.test.js.map +1 -0
- package/dist/test/mcpl-reconnect-reregistration.test.js +22 -3
- package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
- package/dist/test/mcpl-request-timeout.test.js +15 -0
- package/dist/test/mcpl-request-timeout.test.js.map +1 -1
- package/dist/test/offline-recovery-branch.test.d.ts +2 -0
- package/dist/test/offline-recovery-branch.test.d.ts.map +1 -0
- package/dist/test/offline-recovery-branch.test.js +226 -0
- package/dist/test/offline-recovery-branch.test.js.map +1 -0
- package/dist/test/present-while-acting.test.d.ts +3 -2
- package/dist/test/present-while-acting.test.d.ts.map +1 -1
- package/dist/test/present-while-acting.test.js +31 -3
- package/dist/test/present-while-acting.test.js.map +1 -1
- package/dist/test/refusal-single-call.test.d.ts +2 -0
- package/dist/test/refusal-single-call.test.d.ts.map +1 -0
- package/dist/test/refusal-single-call.test.js +177 -0
- package/dist/test/refusal-single-call.test.js.map +1 -0
- package/dist/test/same-round-think-policy.test.d.ts +2 -0
- package/dist/test/same-round-think-policy.test.d.ts.map +1 -0
- package/dist/test/same-round-think-policy.test.js +853 -0
- package/dist/test/same-round-think-policy.test.js.map +1 -0
- package/dist/test/workspace-read-image.test.d.ts +2 -0
- package/dist/test/workspace-read-image.test.d.ts.map +1 -0
- package/dist/test/workspace-read-image.test.js +518 -0
- package/dist/test/workspace-read-image.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -3
- package/src/agent.ts +39 -2
- package/src/api/server.ts +8 -2
- package/src/framework.ts +1180 -79
- package/src/gate/event-gate.ts +15 -3
- package/src/index.ts +20 -0
- package/src/mcpl/channel-registry.ts +31 -16
- package/src/mcpl/server-connection.ts +163 -28
- package/src/modules/workspace/index.ts +759 -2
- package/src/modules/workspace/types.ts +5 -0
- package/src/recovery/discord-awareness-outbox.ts +459 -0
- package/src/recovery/offline-branch.ts +510 -0
- package/src/recovery/recover-cli.ts +130 -0
- package/src/types/agent.ts +14 -0
- package/src/types/framework.ts +17 -0
- package/src/types/module.ts +33 -0
- package/src/types/trace.ts +2 -0
package/src/gate/event-gate.ts
CHANGED
|
@@ -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 [
|
|
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.
|
|
232
|
-
'
|
|
233
|
-
'to work things out before (or instead of) speaking. To deliberately
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
1400
|
-
'
|
|
1401
|
-
|
|
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: '
|
|
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:
|
|
116
|
-
|
|
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.
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
174
|
-
* Lifecycle events
|
|
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 (
|
|
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
|
-
//
|
|
555
|
-
//
|
|
556
|
-
//
|
|
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(
|
|
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
|
-
|
|
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 ${
|
|
612
|
-
`
|
|
613
|
-
`tool may still have completed server-side
|
|
614
|
-
`
|
|
615
|
-
`
|
|
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
|
-
},
|
|
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.
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
//
|
|
730
|
-
|
|
731
|
-
|
|
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
|
|