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