@animalabs/agent-framework 0.5.7 → 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 +243 -0
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1378 -114
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +64 -2
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +212 -12
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/gate-script-worker.d.ts +2 -0
- package/dist/src/gate/gate-script-worker.d.ts.map +1 -0
- package/dist/src/gate/gate-script-worker.js +69 -0
- package/dist/src/gate/gate-script-worker.js.map +1 -0
- package/dist/src/gate/gate-script.d.ts +44 -0
- package/dist/src/gate/gate-script.d.ts.map +1 -0
- package/dist/src/gate/gate-script.js +164 -0
- package/dist/src/gate/gate-script.js.map +1 -0
- package/dist/src/gate/types.d.ts +18 -3
- package/dist/src/gate/types.d.ts.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 +60 -2
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +114 -9
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/checkpoint-manager.d.ts +7 -0
- package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
- package/dist/src/mcpl/checkpoint-manager.js +15 -0
- package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +5 -0
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +64 -30
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +247 -210
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/transport.d.ts +98 -0
- package/dist/src/mcpl/transport.d.ts.map +1 -0
- package/dist/src/mcpl/transport.js +281 -0
- package/dist/src/mcpl/transport.js.map +1 -0
- package/dist/src/mcpl/types.d.ts +59 -3
- 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 +17 -1
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +101 -6
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/prose-segments.d.ts +18 -0
- package/dist/src/prose-segments.d.ts.map +1 -0
- package/dist/src/prose-segments.js +39 -0
- package/dist/src/prose-segments.js.map +1 -0
- package/dist/src/runtime/liveness-watchdog-worker.d.ts +2 -0
- package/dist/src/runtime/liveness-watchdog-worker.d.ts.map +1 -0
- package/dist/src/runtime/liveness-watchdog-worker.js +57 -0
- package/dist/src/runtime/liveness-watchdog-worker.js.map +1 -0
- package/dist/src/runtime/liveness-watchdog.d.ts +36 -0
- package/dist/src/runtime/liveness-watchdog.d.ts.map +1 -0
- package/dist/src/runtime/liveness-watchdog.js +106 -0
- package/dist/src/runtime/liveness-watchdog.js.map +1 -0
- package/dist/src/types/agent.d.ts +47 -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 +58 -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/channel-registry-routing.test.js +108 -1
- package/dist/test/channel-registry-routing.test.js.map +1 -1
- package/dist/test/checkpoint-attribution.test.d.ts +2 -0
- package/dist/test/checkpoint-attribution.test.d.ts.map +1 -0
- package/dist/test/checkpoint-attribution.test.js +40 -0
- package/dist/test/checkpoint-attribution.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/event-gate-policy-mutation.test.d.ts +2 -0
- package/dist/test/event-gate-policy-mutation.test.d.ts.map +1 -0
- package/dist/test/event-gate-policy-mutation.test.js +137 -0
- package/dist/test/event-gate-policy-mutation.test.js.map +1 -0
- package/dist/test/event-gate-script.test.d.ts +2 -0
- package/dist/test/event-gate-script.test.d.ts.map +1 -0
- package/dist/test/event-gate-script.test.js +95 -0
- package/dist/test/event-gate-script.test.js.map +1 -0
- package/dist/test/event-gate-tags.test.d.ts +2 -0
- package/dist/test/event-gate-tags.test.d.ts.map +1 -0
- package/dist/test/event-gate-tags.test.js +76 -0
- package/dist/test/event-gate-tags.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/liveness-watchdog.test.d.ts +2 -0
- package/dist/test/liveness-watchdog.test.d.ts.map +1 -0
- package/dist/test/liveness-watchdog.test.js +78 -0
- package/dist/test/liveness-watchdog.test.js.map +1 -0
- 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/mcpl-ws-transport.test.d.ts +2 -0
- package/dist/test/mcpl-ws-transport.test.d.ts.map +1 -0
- package/dist/test/mcpl-ws-transport.test.js +155 -0
- package/dist/test/mcpl-ws-transport.test.js.map +1 -0
- package/dist/test/prose-segments.test.d.ts +2 -0
- package/dist/test/prose-segments.test.d.ts.map +1 -0
- package/dist/test/prose-segments.test.js +52 -0
- package/dist/test/prose-segments.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/test/trunk-channel-routing.test.d.ts +19 -0
- package/dist/test/trunk-channel-routing.test.d.ts.map +1 -0
- package/dist/test/trunk-channel-routing.test.js +175 -0
- package/dist/test/trunk-channel-routing.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -3
- package/src/agent.ts +16 -0
- package/src/framework.ts +1576 -121
- package/src/gate/event-gate.ts +209 -12
- package/src/gate/gate-script-worker.ts +73 -0
- package/src/gate/gate-script.ts +180 -0
- package/src/gate/types.ts +19 -3
- package/src/index.ts +10 -0
- package/src/mcpl/channel-registry.ts +143 -8
- package/src/mcpl/checkpoint-manager.ts +16 -0
- package/src/mcpl/push-handler.ts +5 -0
- package/src/mcpl/server-connection.ts +262 -227
- package/src/mcpl/transport.ts +307 -0
- package/src/mcpl/types.ts +68 -3
- package/src/module-registry.ts +126 -6
- package/src/prose-segments.ts +42 -0
- package/src/runtime/liveness-watchdog-worker.ts +66 -0
- package/src/runtime/liveness-watchdog.ts +122 -0
- package/src/types/agent.ts +48 -0
- package/src/types/framework.ts +15 -0
- package/src/types/module.ts +65 -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';
|
|
@@ -53,6 +54,7 @@ import { ChannelRegistry } from './mcpl/channel-registry.js';
|
|
|
53
54
|
import { ConversationRouter } from './mcpl/conversation-router.js';
|
|
54
55
|
import { safeSlice } from './safe-slice.js';
|
|
55
56
|
import { toolResultDataToHistoryString } from './tool-result-history.js';
|
|
57
|
+
import { splitProseSegments } from './prose-segments.js';
|
|
56
58
|
import { CheckpointManager } from './mcpl/checkpoint-manager.js';
|
|
57
59
|
import { isToolAllowed } from './mcpl/tool-policy.js';
|
|
58
60
|
import { EventGate } from './gate/event-gate.js';
|
|
@@ -79,7 +81,8 @@ const FRAMEWORK_STATE_ID = 'framework/state';
|
|
|
79
81
|
const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router';
|
|
80
82
|
const INFERENCE_LOG_ID = 'framework/inference-log';
|
|
81
83
|
const PROCESS_LOG_ID = 'framework/process-log';
|
|
82
|
-
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';
|
|
83
86
|
|
|
84
87
|
/** Maximum number of turn checkpoints to keep per agent. */
|
|
85
88
|
const MAX_TURN_CHECKPOINTS = 20;
|
|
@@ -197,10 +200,34 @@ interface HostCommandParams {
|
|
|
197
200
|
fromMessageId?: string;
|
|
198
201
|
/** For the `hide` command: Discord message id ending an inclusive range. */
|
|
199
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;
|
|
200
208
|
requesterId?: string;
|
|
201
209
|
requesterName?: string;
|
|
202
210
|
}
|
|
203
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
|
+
|
|
204
231
|
export class AgentFramework {
|
|
205
232
|
private store: JsStore;
|
|
206
233
|
private ownsStore: boolean;
|
|
@@ -211,11 +238,25 @@ export class AgentFramework {
|
|
|
211
238
|
private inferencePolicy: InferencePolicy;
|
|
212
239
|
private errorPolicy: ErrorPolicy;
|
|
213
240
|
private pendingRequests: InferenceRequest[] = [];
|
|
241
|
+
/**
|
|
242
|
+
* Per-agent channel that triggered the agent's CURRENT inference turn, if any
|
|
243
|
+
* (item-3 redux). Read by the ChannelRegistry's `activeChannelResolver` to
|
|
244
|
+
* route a single-trunk agent's plain-text speech back to the channel it is
|
|
245
|
+
* answering, instead of the process-global most-recent-inbound locus that a
|
|
246
|
+
* concurrent message elsewhere can hijack. Set at turn start (or cleared for a
|
|
247
|
+
* heartbeat / no-trigger turn) in startAgentStream; overwritten by the next
|
|
248
|
+
* turn. Never read between turns (a given agent runs one turn at a time).
|
|
249
|
+
*/
|
|
250
|
+
private activeTriggerChannels: Map<string, string> = new Map();
|
|
214
251
|
private running = false;
|
|
215
252
|
private loopPromise: Promise<void> | null = null;
|
|
216
253
|
private traceListeners: TraceEventListener[] = [];
|
|
217
254
|
private syncIntervalMs: number;
|
|
218
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 }>();
|
|
219
260
|
private processLoggingPersist: boolean;
|
|
220
261
|
private processLoggingBroadcast: boolean;
|
|
221
262
|
private activeStreams: Map<string, Promise<void>> = new Map();
|
|
@@ -226,6 +267,28 @@ export class AgentFramework {
|
|
|
226
267
|
/** N consecutive failed inferences ⇒ the agent is treated as hard-down and
|
|
227
268
|
* escalated loudly to stderr. */
|
|
228
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();
|
|
229
292
|
/** Name of the primary (non-ephemeral) agent for routing framework messages. */
|
|
230
293
|
private primaryAgentName: string | null = null;
|
|
231
294
|
|
|
@@ -237,6 +300,9 @@ export class AgentFramework {
|
|
|
237
300
|
private turnCounters: Map<string, number> = new Map(); // agentName → next turnIndex
|
|
238
301
|
private redoStacks: Map<string, RedoEntry[]> = new Map(); // agentName → redo entries
|
|
239
302
|
|
|
303
|
+
/** Liveness watchdog (fail hard on a wedged main thread). Null unless enabled. */
|
|
304
|
+
private livenessWatchdog: import('./runtime/liveness-watchdog.js').LivenessWatchdog | null = null;
|
|
305
|
+
|
|
240
306
|
// MCPL subsystems (null when no mcplServers configured)
|
|
241
307
|
private mcplServerRegistry: McplServerRegistry | null = null;
|
|
242
308
|
private featureSetManager: FeatureSetManager | null = null;
|
|
@@ -269,6 +335,13 @@ export class AgentFramework {
|
|
|
269
335
|
private mcplPrefixMap: Map<string, string> = new Map();
|
|
270
336
|
/** Maps serverId → McplServerConfig for prefix lookup. */
|
|
271
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;
|
|
272
345
|
|
|
273
346
|
// EventGate (null when FrameworkConfig.gate is omitted)
|
|
274
347
|
private eventGate: EventGate | null = null;
|
|
@@ -309,6 +382,7 @@ export class AgentFramework {
|
|
|
309
382
|
queryMessages: (filter) => this.queryMessages(filter),
|
|
310
383
|
pushEvent: (event) => this.pushEvent(event),
|
|
311
384
|
onTrace: (listener) => this.onTrace(listener),
|
|
385
|
+
callTool: (call) => this.executeToolCall(call),
|
|
312
386
|
});
|
|
313
387
|
}
|
|
314
388
|
|
|
@@ -348,10 +422,12 @@ export class AgentFramework {
|
|
|
348
422
|
// Already registered
|
|
349
423
|
}
|
|
350
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.
|
|
351
427
|
try {
|
|
352
|
-
store.registerState({ id:
|
|
353
|
-
} catch {
|
|
354
|
-
|
|
428
|
+
store.registerState({ id: TURN_CHECKPOINTS_TREE_ID, strategy: 'tree' });
|
|
429
|
+
} catch (error) {
|
|
430
|
+
if (!isStateExistsError(error)) throw error;
|
|
355
431
|
}
|
|
356
432
|
|
|
357
433
|
// Process logging config (default: disabled)
|
|
@@ -444,6 +520,23 @@ export class AgentFramework {
|
|
|
444
520
|
});
|
|
445
521
|
}
|
|
446
522
|
|
|
523
|
+
// Liveness watchdog: fail hard if the main thread wedges (opt-in).
|
|
524
|
+
if (config.watchdog?.enabled) {
|
|
525
|
+
const { LivenessWatchdog } = await import('./runtime/liveness-watchdog.js');
|
|
526
|
+
framework.livenessWatchdog = new LivenessWatchdog({
|
|
527
|
+
enabled: true,
|
|
528
|
+
thresholdMs: config.watchdog.thresholdMs,
|
|
529
|
+
action: config.watchdog.action,
|
|
530
|
+
reportPath: config.watchdog.reportPath
|
|
531
|
+
?? (config.storePath ? join(config.storePath, 'watchdog-wedge.jsonl') : undefined),
|
|
532
|
+
});
|
|
533
|
+
framework.livenessWatchdog.start();
|
|
534
|
+
}
|
|
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
|
+
|
|
447
540
|
// Initialize MCPL subsystems if configured
|
|
448
541
|
if (config.mcplServers && config.mcplServers.length > 0) {
|
|
449
542
|
// Validate tool prefixes: no collisions with module names or between servers
|
|
@@ -468,6 +561,31 @@ export class AgentFramework {
|
|
|
468
561
|
await framework.initializeMcpl(config.mcplServers, config.inferenceRouting);
|
|
469
562
|
}
|
|
470
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
|
+
|
|
471
589
|
return framework;
|
|
472
590
|
}
|
|
473
591
|
|
|
@@ -529,6 +647,7 @@ export class AgentFramework {
|
|
|
529
647
|
|
|
530
648
|
// Dispose EventGate (clear debounce timers)
|
|
531
649
|
this.eventGate?.dispose();
|
|
650
|
+
this.livenessWatchdog?.stop();
|
|
532
651
|
|
|
533
652
|
// Stop modules and MCPL servers in parallel
|
|
534
653
|
const shutdownPromises: Promise<void>[] = [this.moduleRegistry.stopAll()];
|
|
@@ -658,7 +777,12 @@ export class AgentFramework {
|
|
|
658
777
|
const moduleTools = this.moduleRegistry.getAllTools();
|
|
659
778
|
const channelTools = this.channelRegistry?.getChannelTools() ?? [];
|
|
660
779
|
const gateTools = this.eventGate
|
|
661
|
-
? [
|
|
780
|
+
? [
|
|
781
|
+
this.eventGate.getToolDefinition(),
|
|
782
|
+
...AgentFramework.SLEEP_TOOLS,
|
|
783
|
+
...AgentFramework.WAKE_RULE_TOOLS,
|
|
784
|
+
AgentFramework.EVENT_TAGS_TOOL,
|
|
785
|
+
]
|
|
662
786
|
: [];
|
|
663
787
|
if (this.mcplTools.length === 0 && channelTools.length === 0 && gateTools.length === 0) {
|
|
664
788
|
return moduleTools;
|
|
@@ -715,8 +839,9 @@ export class AgentFramework {
|
|
|
715
839
|
// Module gatherContext (fail-open, matches startAgentStream)
|
|
716
840
|
try {
|
|
717
841
|
const moduleInjections = await this.moduleRegistry.gatherContext(agentName);
|
|
718
|
-
|
|
719
|
-
|
|
842
|
+
const scopedModuleInjections = this.scopeInjectionsForAgent(agentName, moduleInjections);
|
|
843
|
+
if (scopedModuleInjections.length > 0) {
|
|
844
|
+
injections = scopedModuleInjections;
|
|
720
845
|
}
|
|
721
846
|
} catch (error) {
|
|
722
847
|
console.error('Module gatherContext error (preview):', error);
|
|
@@ -727,7 +852,10 @@ export class AgentFramework {
|
|
|
727
852
|
if (this.hookOrchestrator) {
|
|
728
853
|
try {
|
|
729
854
|
const hookParams = this.buildBeforeInferenceParams(agent);
|
|
730
|
-
const hookInjections =
|
|
855
|
+
const hookInjections = this.scopeInjectionsForAgent(
|
|
856
|
+
agentName,
|
|
857
|
+
await this.hookOrchestrator.beforeInference(hookParams),
|
|
858
|
+
);
|
|
731
859
|
if (hookInjections.length > 0) {
|
|
732
860
|
injections = injections ? [...injections, ...hookInjections] : hookInjections;
|
|
733
861
|
}
|
|
@@ -763,6 +891,45 @@ export class AgentFramework {
|
|
|
763
891
|
return this.moduleRegistry.getModule(name);
|
|
764
892
|
}
|
|
765
893
|
|
|
894
|
+
// =========================================================================
|
|
895
|
+
// Wake-rule surface (gate policy mutation) — backs wake_add_rule /
|
|
896
|
+
// wake_remove_rule and lets host modules compose higher-level wake modes
|
|
897
|
+
// (e.g. an "every-message-debounced" channel mode) without reaching into
|
|
898
|
+
// the private EventGate.
|
|
899
|
+
// =========================================================================
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Add or replace (upsert) a gate policy at runtime. Validated and persisted
|
|
903
|
+
* to gate.json; hot-applied in memory. Throws if no gate is configured or the
|
|
904
|
+
* policy is invalid. `position: 'prepend'` puts a wake rule ahead of broad
|
|
905
|
+
* defer/debounce rules (first match wins).
|
|
906
|
+
*/
|
|
907
|
+
addGatePolicy(
|
|
908
|
+
rawPolicy: unknown,
|
|
909
|
+
options?: { position?: 'append' | 'prepend' },
|
|
910
|
+
): import('./gate/types.js').GatePolicy {
|
|
911
|
+
if (!this.eventGate) {
|
|
912
|
+
throw new Error('No EventGate configured (FrameworkConfig.gate is unset).');
|
|
913
|
+
}
|
|
914
|
+
return this.eventGate.addPolicy(rawPolicy, options);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Remove a gate policy by name at runtime. Returns false if it didn't exist.
|
|
919
|
+
* Throws if no gate is configured.
|
|
920
|
+
*/
|
|
921
|
+
removeGatePolicy(name: string): boolean {
|
|
922
|
+
if (!this.eventGate) {
|
|
923
|
+
throw new Error('No EventGate configured (FrameworkConfig.gate is unset).');
|
|
924
|
+
}
|
|
925
|
+
return this.eventGate.removePolicy(name);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** Current gate policy names (freshest on-disk view). Empty when no gate. */
|
|
929
|
+
getGatePolicyNames(): string[] {
|
|
930
|
+
return this.eventGate?.listPolicyNames() ?? [];
|
|
931
|
+
}
|
|
932
|
+
|
|
766
933
|
/**
|
|
767
934
|
* Get the Membrane instance.
|
|
768
935
|
*/
|
|
@@ -878,6 +1045,7 @@ export class AgentFramework {
|
|
|
878
1045
|
clearInterval(completionWatchdog);
|
|
879
1046
|
this.offTrace(traceListener);
|
|
880
1047
|
this.agents.delete(agent.name);
|
|
1048
|
+
this.evictTurnCheckpoints(agent.name);
|
|
881
1049
|
};
|
|
882
1050
|
|
|
883
1051
|
// Watchdog: if inference never starts within 30s, the agent is a zombie.
|
|
@@ -1037,6 +1205,154 @@ export class AgentFramework {
|
|
|
1037
1205
|
},
|
|
1038
1206
|
];
|
|
1039
1207
|
|
|
1208
|
+
/** Synthesized wake-rule tools: add/remove a gate.json policy at runtime.
|
|
1209
|
+
* Present when a gate is wired. They write validated policies into the
|
|
1210
|
+
* hot-reloaded gate.json (same validation as load), so a rule takes effect
|
|
1211
|
+
* immediately and survives restart. */
|
|
1212
|
+
private static readonly WAKE_RULE_TOOLS: import('./types/index.js').ToolDefinition[] = [
|
|
1213
|
+
{
|
|
1214
|
+
name: 'wake_add_rule',
|
|
1215
|
+
description:
|
|
1216
|
+
'Add or replace a wake rule (a gate.json policy) at runtime — no need to ' +
|
|
1217
|
+
'hand-edit the file. The rule is validated and hot-applied immediately. ' +
|
|
1218
|
+
'A rule with the same `name` replaces the existing one in place. Use ' +
|
|
1219
|
+
'`position: "prepend"` to put a wake rule ahead of broad defer/debounce ' +
|
|
1220
|
+
'rules (first match wins). Two common shapes:\n' +
|
|
1221
|
+
'• Watch a FILE/workspace path: match on `mount` + `pathGlob`, e.g. ' +
|
|
1222
|
+
'`{ name: "watch-notes", match: { scope: ["workspace:modified"], mount: "project", pathGlob: "notes/*.md" }, behavior: "always" }`.\n' +
|
|
1223
|
+
'• Watch a CHANNEL: match on `source` + `channel` (and/or `tagsAny: ["chat:ambient"]`), ' +
|
|
1224
|
+
'e.g. `{ name: "watch-cairn", match: { source: "discord", channel: "discord:*:12345", tagsAny: ["chat:ambient"] }, behavior: { debounce: 60000 } }` ' +
|
|
1225
|
+
'(the channel must be subscribed for ambient events to arrive — see channel mode).',
|
|
1226
|
+
inputSchema: {
|
|
1227
|
+
type: 'object',
|
|
1228
|
+
properties: {
|
|
1229
|
+
name: { type: 'string', description: 'Unique rule name. Reusing a name replaces that rule.' },
|
|
1230
|
+
match: {
|
|
1231
|
+
type: 'object',
|
|
1232
|
+
description: 'Match criteria (all AND together). Omitted fields match anything.',
|
|
1233
|
+
properties: {
|
|
1234
|
+
scope: { type: 'array', items: { type: 'string' }, description: 'Event types, e.g. ["mcpl:channel-incoming","workspace:modified"].' },
|
|
1235
|
+
source: { type: 'string', description: 'Integration/serverId, glob ok (e.g. "discord").' },
|
|
1236
|
+
channel: { type: 'string', description: 'Channel id, glob ok (e.g. "discord:*:12345").' },
|
|
1237
|
+
mount: { type: 'string', description: 'Workspace mount name, glob ok (workspace:* events).' },
|
|
1238
|
+
pathGlob: { type: 'string', description: 'Glob over touched paths (workspace:* events).' },
|
|
1239
|
+
tagsAny: { type: 'array', items: { type: 'string' }, description: 'Match if ANY tag matches (globs ok).' },
|
|
1240
|
+
tagsAll: { type: 'array', items: { type: 'string' }, description: 'Match only if EVERY tag matches.' },
|
|
1241
|
+
tagsNone: { type: 'array', items: { type: 'string' }, description: 'Match only if NONE match.' },
|
|
1242
|
+
metadataTrue: { type: 'array', items: { type: 'string' }, description: 'Match if ANY listed metadata field is truthy.' },
|
|
1243
|
+
filter: {
|
|
1244
|
+
type: 'object',
|
|
1245
|
+
description: 'Content filter.',
|
|
1246
|
+
properties: {
|
|
1247
|
+
type: { type: 'string', enum: ['text', 'regex'] },
|
|
1248
|
+
pattern: { type: 'string' },
|
|
1249
|
+
},
|
|
1250
|
+
required: ['type', 'pattern'],
|
|
1251
|
+
},
|
|
1252
|
+
},
|
|
1253
|
+
},
|
|
1254
|
+
behavior: {
|
|
1255
|
+
type: 'string',
|
|
1256
|
+
enum: ['always', 'defer', 'skip'],
|
|
1257
|
+
description:
|
|
1258
|
+
'Simple behavior: "always" (wake now) or "defer" (don\'t wake; still enters ' +
|
|
1259
|
+
'context). For debounce / rate-limit / sampling, use debounceMs / rateLimit / ' +
|
|
1260
|
+
'passiveSample below instead of this field. Exactly one behavior must be given.',
|
|
1261
|
+
},
|
|
1262
|
+
debounceMs: {
|
|
1263
|
+
type: 'number',
|
|
1264
|
+
description: 'Shorthand for { debounce: ms }: wake once after ms of quiet (100–300000).',
|
|
1265
|
+
},
|
|
1266
|
+
rateLimit: {
|
|
1267
|
+
type: 'object',
|
|
1268
|
+
description: 'Token-bucket wake: at most `tokens` wakes per window; refills one per refillIntervalMs.',
|
|
1269
|
+
properties: {
|
|
1270
|
+
tokens: { type: 'number', description: 'Bucket capacity (> 0).' },
|
|
1271
|
+
refillIntervalMs: { type: 'number', description: 'Ms between token refills (> 0).' },
|
|
1272
|
+
keyBy: { type: 'string', description: 'Metadata field to partition buckets by (e.g. "channelId").' },
|
|
1273
|
+
},
|
|
1274
|
+
required: ['tokens', 'refillIntervalMs'],
|
|
1275
|
+
},
|
|
1276
|
+
passiveSample: {
|
|
1277
|
+
type: 'object',
|
|
1278
|
+
description: 'Wake every Nth matching event.',
|
|
1279
|
+
properties: {
|
|
1280
|
+
every: { type: 'number', description: 'Fire every N matches (positive integer).' },
|
|
1281
|
+
keyBy: { type: 'string', description: 'Metadata field for separate per-key counters.' },
|
|
1282
|
+
},
|
|
1283
|
+
required: ['every'],
|
|
1284
|
+
},
|
|
1285
|
+
position: {
|
|
1286
|
+
type: 'string',
|
|
1287
|
+
enum: ['append', 'prepend'],
|
|
1288
|
+
description: 'Where to insert a NEW rule (ignored when replacing by name). Default "append".',
|
|
1289
|
+
},
|
|
1290
|
+
resets: {
|
|
1291
|
+
type: 'array',
|
|
1292
|
+
items: { type: 'string' },
|
|
1293
|
+
description: 'Names of other rules whose runtime state to clear when this rule fires.',
|
|
1294
|
+
},
|
|
1295
|
+
},
|
|
1296
|
+
required: ['name'],
|
|
1297
|
+
},
|
|
1298
|
+
},
|
|
1299
|
+
{
|
|
1300
|
+
name: 'wake_remove_rule',
|
|
1301
|
+
description:
|
|
1302
|
+
'Remove a wake rule (gate.json policy) by name at runtime. Any pending ' +
|
|
1303
|
+
'debounce batch for that rule is delivered first. Returns whether a rule ' +
|
|
1304
|
+
'was removed (false if no rule had that name).',
|
|
1305
|
+
inputSchema: {
|
|
1306
|
+
type: 'object',
|
|
1307
|
+
properties: {
|
|
1308
|
+
name: { type: 'string', description: 'The rule name to remove.' },
|
|
1309
|
+
},
|
|
1310
|
+
required: ['name'],
|
|
1311
|
+
},
|
|
1312
|
+
},
|
|
1313
|
+
];
|
|
1314
|
+
|
|
1315
|
+
/** Discoverability for event tags (MCPL RFC-001): the reserved chat:* core,
|
|
1316
|
+
* each connected server's declared tag ontology, and gate.js status. */
|
|
1317
|
+
private static readonly EVENT_TAGS_TOOL: import('./types/index.js').ToolDefinition = {
|
|
1318
|
+
name: 'event_tags',
|
|
1319
|
+
description:
|
|
1320
|
+
'List the event tags available for gating: the reserved cross-platform ' +
|
|
1321
|
+
'chat:* core, each connected server\'s declared tag ontology (descriptions, ' +
|
|
1322
|
+
'implications, suggested treatments), and the status of your programmable ' +
|
|
1323
|
+
'gate (gate.js). Use these tag names in gate.json policies (tagsAny / ' +
|
|
1324
|
+
'tagsAll / tagsNone) or in gate.js.',
|
|
1325
|
+
inputSchema: { type: 'object' },
|
|
1326
|
+
};
|
|
1327
|
+
|
|
1328
|
+
/** Reserved chat:* core vocabulary (MCPL RFC-001 §4) — short descriptions so
|
|
1329
|
+
* the agent can author rules without reading the spec. */
|
|
1330
|
+
private static readonly CHAT_CORE_TAGS: Record<string, string> = {
|
|
1331
|
+
'chat:addressed': 'Directed at you (umbrella: dm/mention/reply)',
|
|
1332
|
+
'chat:mention': 'You were explicitly @-mentioned',
|
|
1333
|
+
'chat:reply': 'A reply to your own message',
|
|
1334
|
+
'chat:dm': 'A direct/private message to you',
|
|
1335
|
+
'chat:ambient': 'Overheard in a followed channel; not addressed',
|
|
1336
|
+
'chat:broadcast': 'Channel-wide ping (@everyone / channel post)',
|
|
1337
|
+
'chat:to-self': 'Acts on your own content (reaction/reply to you)',
|
|
1338
|
+
'chat:from-human': 'Authored by a human',
|
|
1339
|
+
'chat:from-bot': 'Authored by a bot/automation',
|
|
1340
|
+
'chat:from-self': 'Your own message, echoed back',
|
|
1341
|
+
'chat:from-agent': 'Authored by another persona/agent',
|
|
1342
|
+
'chat:edited': 'An edit of an existing message',
|
|
1343
|
+
'chat:deleted': 'A deletion',
|
|
1344
|
+
'chat:reaction': 'An emoji reaction was added',
|
|
1345
|
+
'chat:reaction-remove': 'A reaction was removed',
|
|
1346
|
+
'chat:has-image': 'Has an image attachment',
|
|
1347
|
+
'chat:has-audio': 'Has an audio attachment',
|
|
1348
|
+
'chat:has-file': 'Has a file attachment',
|
|
1349
|
+
'chat:has-link': 'Contains a link',
|
|
1350
|
+
'chat:command': 'A slash/bot command invocation',
|
|
1351
|
+
'chat:private': 'Private conversation',
|
|
1352
|
+
'chat:group': 'Group (multi-party) conversation',
|
|
1353
|
+
'chat:thread': 'Occurred in a thread',
|
|
1354
|
+
};
|
|
1355
|
+
|
|
1040
1356
|
/** Refusal category → Discord reaction emoji. Unknown categories get 🛑. */
|
|
1041
1357
|
private static readonly REFUSAL_REACTIONS: Record<string, string> = {
|
|
1042
1358
|
bio: '☣️',
|
|
@@ -1088,6 +1404,194 @@ export class AgentFramework {
|
|
|
1088
1404
|
}
|
|
1089
1405
|
}
|
|
1090
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
|
+
|
|
1091
1595
|
/**
|
|
1092
1596
|
* Handle a `host/command` request from an MCPL surface server (e.g. a
|
|
1093
1597
|
* Discord slash command). Currently supports:
|
|
@@ -1111,8 +1615,12 @@ export class AgentFramework {
|
|
|
1111
1615
|
* that carried one — so the surface can mark them with a reaction. */
|
|
1112
1616
|
hiddenRefs?: Array<{ channelId: string; messageId: string }>;
|
|
1113
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;
|
|
1114
1622
|
}> {
|
|
1115
|
-
if (params.command !== 'undo' && params.command !== 'hide') {
|
|
1623
|
+
if (params.command !== 'undo' && params.command !== 'hide' && params.command !== 'unstick') {
|
|
1116
1624
|
return { ok: false, error: `Unknown host command: ${String(params.command)}` };
|
|
1117
1625
|
}
|
|
1118
1626
|
|
|
@@ -1121,6 +1629,38 @@ export class AgentFramework {
|
|
|
1121
1629
|
return { ok: false, error: `Unknown agent: ${String(agentName)}` };
|
|
1122
1630
|
}
|
|
1123
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
|
+
|
|
1124
1664
|
// hide: redact a single message (or an inclusive range) from the active
|
|
1125
1665
|
// branch, addressed by Discord message id. Unlike undo this is a
|
|
1126
1666
|
// removal-in-place (chronicle redact), not a branch rewind.
|
|
@@ -1738,18 +2278,91 @@ export class AgentFramework {
|
|
|
1738
2278
|
this.saveTurnCheckpoints(agentName, checkpoints);
|
|
1739
2279
|
}
|
|
1740
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
|
+
|
|
1741
2311
|
private getTurnCheckpoints(agentName: string): TurnCheckpoint[] {
|
|
1742
|
-
const
|
|
1743
|
-
if (
|
|
1744
|
-
|
|
1745
|
-
|
|
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] : [];
|
|
1746
2329
|
}
|
|
1747
2330
|
|
|
1748
2331
|
private saveTurnCheckpoints(agentName: string, checkpoints: TurnCheckpoint[]): void {
|
|
1749
|
-
const
|
|
1750
|
-
const
|
|
1751
|
-
|
|
1752
|
-
|
|
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
|
+
}
|
|
1753
2366
|
}
|
|
1754
2367
|
|
|
1755
2368
|
/**
|
|
@@ -1759,6 +2372,9 @@ export class AgentFramework {
|
|
|
1759
2372
|
async runUntilIdle(): Promise<void> {
|
|
1760
2373
|
while (
|
|
1761
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 ||
|
|
1762
2378
|
this.activeStreams.size > 0 ||
|
|
1763
2379
|
Array.from(this.agents.values()).some((a) => a.state.status !== 'idle')
|
|
1764
2380
|
) {
|
|
@@ -2115,6 +2731,9 @@ export class AgentFramework {
|
|
|
2115
2731
|
reason: 'mcpl:channel-incoming',
|
|
2116
2732
|
source: event.serverId,
|
|
2117
2733
|
timestamp: Date.now(),
|
|
2734
|
+
// Route this turn's auto-published speech back to THIS channel, not
|
|
2735
|
+
// the global most-recent-inbound locus (item-3 redux, trunk agents).
|
|
2736
|
+
channelId: event.channelId,
|
|
2118
2737
|
});
|
|
2119
2738
|
}
|
|
2120
2739
|
}
|
|
@@ -2209,6 +2828,9 @@ export class AgentFramework {
|
|
|
2209
2828
|
reason: 'mcpl:channel-incoming',
|
|
2210
2829
|
source: event.serverId,
|
|
2211
2830
|
timestamp: Date.now(),
|
|
2831
|
+
// A fork's home channel wins in routeSpeech regardless, but carry the
|
|
2832
|
+
// triggering channel too so the trunk/active path stays consistent.
|
|
2833
|
+
channelId: event.channelId,
|
|
2212
2834
|
});
|
|
2213
2835
|
}
|
|
2214
2836
|
}
|
|
@@ -2285,6 +2907,7 @@ export class AgentFramework {
|
|
|
2285
2907
|
this.agents.delete(agentName);
|
|
2286
2908
|
this.agentConfigs.delete(agentName);
|
|
2287
2909
|
this.conversationAgentHomes.delete(agentName);
|
|
2910
|
+
this.evictTurnCheckpoints(agentName);
|
|
2288
2911
|
this.emitTrace({
|
|
2289
2912
|
type: 'mcpl:conversation-disposed',
|
|
2290
2913
|
agentName,
|
|
@@ -2390,6 +3013,20 @@ export class AgentFramework {
|
|
|
2390
3013
|
const id = this.addMessage('user', event.content, metadata);
|
|
2391
3014
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
|
|
2392
3015
|
|
|
3016
|
+
// Some push events carry a channel of origin (Discord DMs arrive here, not
|
|
3017
|
+
// via channels/incoming, because discord-mcpl forwards them with the channel
|
|
3018
|
+
// closed). Register + open that channel so routeSpeech can resolve it, and
|
|
3019
|
+
// thread it onto the wake request so the reply routes back to it instead of
|
|
3020
|
+
// the global locus (item-3 redux, DM sub-case).
|
|
3021
|
+
const triggerChannel = this.derivePushEventChannel(event.origin);
|
|
3022
|
+
if (triggerChannel && this.channelRegistry) {
|
|
3023
|
+
this.channelRegistry.ensureChannelRegistered(
|
|
3024
|
+
event.serverId,
|
|
3025
|
+
triggerChannel.channelId,
|
|
3026
|
+
triggerChannel.label,
|
|
3027
|
+
);
|
|
3028
|
+
}
|
|
3029
|
+
|
|
2393
3030
|
if (event.triggerInference) {
|
|
2394
3031
|
// Default broadcast excludes conversation forks (channel-driven).
|
|
2395
3032
|
const targetAgents = event.targetAgents
|
|
@@ -2400,11 +3037,50 @@ export class AgentFramework {
|
|
|
2400
3037
|
reason: 'mcpl:push-event',
|
|
2401
3038
|
source: event.serverId,
|
|
2402
3039
|
timestamp: Date.now(),
|
|
3040
|
+
channelId: triggerChannel?.channelId,
|
|
2403
3041
|
});
|
|
2404
3042
|
}
|
|
2405
3043
|
}
|
|
2406
3044
|
}
|
|
2407
3045
|
|
|
3046
|
+
/**
|
|
3047
|
+
* Derive the MCPL composite channel id (the outbound routing locus) for a
|
|
3048
|
+
* push event from its server-defined `origin`, if it carries one.
|
|
3049
|
+
*
|
|
3050
|
+
* Discord DMs are the motivating case: discord-mcpl forwards them via
|
|
3051
|
+
* push/event (the DM channel is closed), so they never pass through
|
|
3052
|
+
* channels/incoming and their channel is never registered — leaving the
|
|
3053
|
+
* agent's reply with nowhere to route (item-3 redux, DM sub-case). Prefers an
|
|
3054
|
+
* explicit `origin.mcplChannelId` (a surface declaring its own composite id —
|
|
3055
|
+
* the surface-agnostic contract); otherwise reconstructs the Discord form
|
|
3056
|
+
* `discord:{guildId|dm}:{channelId}` from origin fields, matching
|
|
3057
|
+
* discord-mcpl's `mcplChannelId()` / `parseMcplChannelId()` convention so the
|
|
3058
|
+
* fix works even against a discord-mcpl build that predates `mcplChannelId`.
|
|
3059
|
+
* Returns undefined for push events with no channel provenance (heartbeats,
|
|
3060
|
+
* timers), which correctly keep the global fallback.
|
|
3061
|
+
*/
|
|
3062
|
+
private derivePushEventChannel(
|
|
3063
|
+
origin: Record<string, unknown> | undefined,
|
|
3064
|
+
): { channelId: string; label?: string } | undefined {
|
|
3065
|
+
if (!origin) return undefined;
|
|
3066
|
+
const label = typeof origin.channelName === 'string' ? origin.channelName : undefined;
|
|
3067
|
+
|
|
3068
|
+
const explicit = origin.mcplChannelId;
|
|
3069
|
+
if (typeof explicit === 'string' && explicit) {
|
|
3070
|
+
return { channelId: explicit, label };
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
// Discord fallback: reconstruct the composite from origin parts. `guildId`
|
|
3074
|
+
// is null for a DM (→ 'dm'); a real guild id for a non-open guild channel.
|
|
3075
|
+
if (origin.source === 'discord' && typeof origin.channelId === 'string' && origin.channelId) {
|
|
3076
|
+
const guild =
|
|
3077
|
+
typeof origin.guildId === 'string' && origin.guildId ? origin.guildId : 'dm';
|
|
3078
|
+
return { channelId: `discord:${guild}:${origin.channelId}`, label };
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
return undefined;
|
|
3082
|
+
}
|
|
3083
|
+
|
|
2408
3084
|
private async processInferenceRequests(): Promise<void> {
|
|
2409
3085
|
if (this.pendingRequests.length === 0) {
|
|
2410
3086
|
return;
|
|
@@ -2438,6 +3114,7 @@ export class AgentFramework {
|
|
|
2438
3114
|
requestCount: requests.length,
|
|
2439
3115
|
oldestRequestAge: now - oldest,
|
|
2440
3116
|
});
|
|
3117
|
+
console.error(`[inference-dropped] agent=${agentName} reason=agent_not_found requests=${requests.length}`);
|
|
2441
3118
|
continue;
|
|
2442
3119
|
}
|
|
2443
3120
|
|
|
@@ -2453,6 +3130,14 @@ export class AgentFramework {
|
|
|
2453
3130
|
requestCount: requests.length,
|
|
2454
3131
|
oldestRequestAge: now - oldest,
|
|
2455
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
|
+
}
|
|
2456
3141
|
}
|
|
2457
3142
|
this.pendingRequests.push(...requests);
|
|
2458
3143
|
continue;
|
|
@@ -2460,12 +3145,31 @@ export class AgentFramework {
|
|
|
2460
3145
|
|
|
2461
3146
|
// Check policy
|
|
2462
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
|
+
);
|
|
2463
3155
|
continue;
|
|
2464
3156
|
}
|
|
2465
3157
|
|
|
2466
3158
|
// Start streaming inference (non-blocking — driveStream runs in background)
|
|
2467
3159
|
const trigger = requests[0];
|
|
2468
|
-
|
|
3160
|
+
// Route this turn's auto-published speech to the channel that triggered
|
|
3161
|
+
// it (item-3 redux). A batched wake may carry several triggering channels
|
|
3162
|
+
// (messages arrived in >1 channel while the agent was busy/idle) — reply
|
|
3163
|
+
// in the MOST-RECENT one: it matches the legacy last-inbound semantics and
|
|
3164
|
+
// is the message the agent is most likely answering. The common case is
|
|
3165
|
+
// 1 message → 1 channel (unambiguous). Non-channel wakes carry no
|
|
3166
|
+
// channelId, leaving the field undefined → global fallback. `reduce`
|
|
3167
|
+
// keeps the last defined channelId across the (FIFO-ordered) batch.
|
|
3168
|
+
const triggerChannel = requests.reduce<string | undefined>(
|
|
3169
|
+
(acc, r) => r.channelId ?? acc,
|
|
3170
|
+
undefined,
|
|
3171
|
+
);
|
|
3172
|
+
await this.startAgentStream(agent, { ...trigger, channelId: triggerChannel });
|
|
2469
3173
|
}
|
|
2470
3174
|
}
|
|
2471
3175
|
|
|
@@ -2476,8 +3180,21 @@ export class AgentFramework {
|
|
|
2476
3180
|
this.redoStacks.delete(agent.name); // new work invalidates redo
|
|
2477
3181
|
}
|
|
2478
3182
|
|
|
3183
|
+
// Establish this turn's outbound routing locus (item-3 redux). Set it to the
|
|
3184
|
+
// triggering channel for a channel/DM-triggered turn; clear it otherwise so
|
|
3185
|
+
// a heartbeat / no-trigger turn doesn't inherit a previous turn's channel and
|
|
3186
|
+
// instead falls back to the global default. A given agent runs one turn at a
|
|
3187
|
+
// time, so a set here is only read during THIS turn's routeSpeech /
|
|
3188
|
+
// buildChannelContext; retries re-run with the same trigger, re-setting it.
|
|
3189
|
+
if (trigger?.channelId) {
|
|
3190
|
+
this.activeTriggerChannels.set(agent.name, trigger.channelId);
|
|
3191
|
+
} else {
|
|
3192
|
+
this.activeTriggerChannels.delete(agent.name);
|
|
3193
|
+
}
|
|
3194
|
+
|
|
2479
3195
|
this.emitTrace({ type: 'inference:started', agentName: agent.name });
|
|
2480
3196
|
this.eventGate?.onInferenceStarted(agent.name);
|
|
3197
|
+
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
|
|
2481
3198
|
|
|
2482
3199
|
try {
|
|
2483
3200
|
const allTools = this.getAllTools();
|
|
@@ -2487,14 +3204,16 @@ export class AgentFramework {
|
|
|
2487
3204
|
// Both produce ContextInjection[] that get merged before inference.
|
|
2488
3205
|
let injections: ContextInjection[] | undefined;
|
|
2489
3206
|
|
|
2490
|
-
// Module gatherContext (fail-open,
|
|
2491
|
-
//
|
|
2492
|
-
//
|
|
2493
|
-
//
|
|
2494
|
-
//
|
|
2495
|
-
// 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.
|
|
2496
3212
|
try {
|
|
2497
|
-
const moduleInjections =
|
|
3213
|
+
const moduleInjections = this.scopeInjectionsForAgent(
|
|
3214
|
+
agent.name,
|
|
3215
|
+
await this.moduleRegistry.gatherContext(agent.name),
|
|
3216
|
+
);
|
|
2498
3217
|
if (moduleInjections.length > 0) {
|
|
2499
3218
|
injections = moduleInjections;
|
|
2500
3219
|
}
|
|
@@ -2541,6 +3260,13 @@ export class AgentFramework {
|
|
|
2541
3260
|
type: 'inference:exhausted',
|
|
2542
3261
|
agentName: agent.name,
|
|
2543
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,
|
|
2544
3270
|
});
|
|
2545
3271
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
2546
3272
|
if (action.emit) {
|
|
@@ -2817,7 +3543,84 @@ export class AgentFramework {
|
|
|
2817
3543
|
`[inference-refusal] agent=${agent.name} category=${category}` +
|
|
2818
3544
|
(stopDetails?.explanation ? ` explanation=${stopDetails.explanation}` : ''),
|
|
2819
3545
|
);
|
|
2820
|
-
|
|
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);
|
|
2821
3624
|
}
|
|
2822
3625
|
|
|
2823
3626
|
// Dispatch speech (and thoughts if any)
|
|
@@ -2888,23 +3691,44 @@ export class AgentFramework {
|
|
|
2888
3691
|
.map((b) => (b as unknown as { name?: string }).name)
|
|
2889
3692
|
.filter((n): n is string => typeof n === 'string');
|
|
2890
3693
|
const silenced = toolNames.some((n) => SILENCING.has(bare(n)));
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
3694
|
+
|
|
3695
|
+
// Split the turn's content into ordered prose segments, broken at
|
|
3696
|
+
// each tool_use / tool_result boundary. `response.content` holds the
|
|
3697
|
+
// WHOLE turn's blocks in provider order — earlier tool rounds
|
|
3698
|
+
// included (see the trailing-slice note above, which slices exactly
|
|
3699
|
+
// because the full turn is present) — so a left-to-right walk
|
|
3700
|
+
// reconstructs the emission order. Interleaved prose
|
|
3701
|
+
// ("msgA → [tool] → msgB → [tool] → msgC") is then delivered as
|
|
3702
|
+
// separate messages IN ORDER instead of being collapsed into one
|
|
3703
|
+
// trailing post (item 4). The silencing rule stays turn-wide: an
|
|
3704
|
+
// explicit send / skip suppresses ALL auto-routed prose this turn,
|
|
3705
|
+
// preserving the double-post guard.
|
|
3706
|
+
const segments = splitProseSegments(response.content);
|
|
3707
|
+
|
|
3708
|
+
if (silenced || segments.length === 0) {
|
|
2896
3709
|
console.error(
|
|
2897
3710
|
`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> NOT routed ` +
|
|
2898
3711
|
`(${silenced ? 'silencing tool / explicit send' : 'no prose'})`,
|
|
2899
3712
|
);
|
|
2900
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);
|
|
2901
3722
|
console.error(
|
|
2902
|
-
`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing
|
|
3723
|
+
`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} prose segment(s) in order -> ${turnLocus ?? '(default)'}`,
|
|
2903
3724
|
);
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
3725
|
+
// Deliver sequentially (await each) so the segments land in order.
|
|
3726
|
+
for (const seg of segments) {
|
|
3727
|
+
try {
|
|
3728
|
+
await this.channelRegistry.routeSpeech(agent.name, seg, turnLocus);
|
|
3729
|
+
} catch (err) {
|
|
3730
|
+
console.error('speech routing failed:', err);
|
|
3731
|
+
}
|
|
2908
3732
|
}
|
|
2909
3733
|
}
|
|
2910
3734
|
}
|
|
@@ -2950,6 +3774,12 @@ export class AgentFramework {
|
|
|
2950
3774
|
type: 'inference:exhausted',
|
|
2951
3775
|
agentName: agent.name,
|
|
2952
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,
|
|
2953
3783
|
});
|
|
2954
3784
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
2955
3785
|
if (action.emit) {
|
|
@@ -2964,19 +3794,58 @@ export class AgentFramework {
|
|
|
2964
3794
|
// Only reset if this is still the active stream (a budget restart
|
|
2965
3795
|
// may have already started a new stream, bumping streamId)
|
|
2966
3796
|
if (agent.streamId === myStreamId) {
|
|
3797
|
+
const durationMs = Date.now() - startTime;
|
|
2967
3798
|
agent.reset();
|
|
2968
3799
|
this.emitTrace({
|
|
2969
3800
|
type: 'inference:exhausted',
|
|
2970
3801
|
agentName: agent.name,
|
|
2971
3802
|
error: `Stream aborted: ${reason}`,
|
|
2972
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
|
+
});
|
|
2973
3819
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
2974
3820
|
}
|
|
2975
3821
|
break;
|
|
2976
3822
|
}
|
|
2977
3823
|
|
|
2978
|
-
case 'usage':
|
|
3824
|
+
case 'usage': {
|
|
2979
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
|
+
|
|
2980
3849
|
this.emitTrace({
|
|
2981
3850
|
type: 'inference:usage',
|
|
2982
3851
|
agentName: agent.name,
|
|
@@ -2988,12 +3857,14 @@ export class AgentFramework {
|
|
|
2988
3857
|
},
|
|
2989
3858
|
});
|
|
2990
3859
|
break;
|
|
3860
|
+
}
|
|
2991
3861
|
}
|
|
2992
3862
|
}
|
|
2993
3863
|
} catch (error) {
|
|
2994
3864
|
// Stream itself threw (unexpected) — no retry path here, so also emit
|
|
2995
3865
|
// inference:exhausted so ephemeral agent promises can settle.
|
|
2996
3866
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
3867
|
+
const durationMs = Date.now() - startTime;
|
|
2997
3868
|
this.emitTrace({
|
|
2998
3869
|
type: 'inference:failed',
|
|
2999
3870
|
agentName: agent.name,
|
|
@@ -3005,9 +3876,31 @@ export class AgentFramework {
|
|
|
3005
3876
|
agentName: agent.name,
|
|
3006
3877
|
error: err.message,
|
|
3007
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
|
+
});
|
|
3008
3891
|
agent.reset();
|
|
3009
3892
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
3010
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
|
+
|
|
3011
3904
|
// Stop the typing indicator on every exit path (complete, error,
|
|
3012
3905
|
// exhausted, abort) so it never sticks after the turn ends.
|
|
3013
3906
|
this.channelRegistry?.stopTyping();
|
|
@@ -3097,7 +3990,10 @@ export class AgentFramework {
|
|
|
3097
3990
|
if (blocks) {
|
|
3098
3991
|
return { toolUseId: callId, content: blocks, isError: false };
|
|
3099
3992
|
}
|
|
3100
|
-
|
|
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) ?? '';
|
|
3101
3997
|
if (maxChars && content.length > maxChars) {
|
|
3102
3998
|
content = safeSlice(content, 0, maxChars)
|
|
3103
3999
|
+ '\n\n[truncated — original was ' + content.length + ' chars]';
|
|
@@ -3169,11 +4065,11 @@ export class AgentFramework {
|
|
|
3169
4065
|
}
|
|
3170
4066
|
}
|
|
3171
4067
|
|
|
3172
|
-
// Append
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
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);
|
|
3177
4073
|
}
|
|
3178
4074
|
|
|
3179
4075
|
private logProcessEvent(event: ProcessEvent, responses: ModuleProcessResponse[]): void {
|
|
@@ -3193,11 +4089,8 @@ export class AgentFramework {
|
|
|
3193
4089
|
entryToStore.responses = { blobId };
|
|
3194
4090
|
}
|
|
3195
4091
|
|
|
3196
|
-
// Append
|
|
3197
|
-
|
|
3198
|
-
const entries = Array.isArray(data) ? data : [];
|
|
3199
|
-
entries.push(entryToStore);
|
|
3200
|
-
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);
|
|
3201
4094
|
}
|
|
3202
4095
|
|
|
3203
4096
|
/**
|
|
@@ -3242,6 +4135,18 @@ export class AgentFramework {
|
|
|
3242
4135
|
return;
|
|
3243
4136
|
}
|
|
3244
4137
|
|
|
4138
|
+
// Route wake-rule tools (runtime gate.json policy add/remove)
|
|
4139
|
+
if ((enrichedCall.name === 'wake_add_rule' || enrichedCall.name === 'wake_remove_rule') && this.eventGate) {
|
|
4140
|
+
this.dispatchWakeRuleToolCall(agentName, enrichedCall);
|
|
4141
|
+
return;
|
|
4142
|
+
}
|
|
4143
|
+
|
|
4144
|
+
// Route event_tags (tag/ontology discovery)
|
|
4145
|
+
if (enrichedCall.name === 'event_tags' && this.eventGate) {
|
|
4146
|
+
this.dispatchEventTagsToolCall(agentName, enrichedCall);
|
|
4147
|
+
return;
|
|
4148
|
+
}
|
|
4149
|
+
|
|
3245
4150
|
// Route synthesized 'think' (private reasoning) and 'skip_reply' (deliberate
|
|
3246
4151
|
// stay-silent) tools — handled by the channel registry like the other
|
|
3247
4152
|
// synthesized channel tools, but they aren't `channel_`-prefixed so they
|
|
@@ -3415,14 +4320,20 @@ export class AgentFramework {
|
|
|
3415
4320
|
this.noteInferenceExhausted(
|
|
3416
4321
|
(event.agentName as string) ?? 'unknown',
|
|
3417
4322
|
(event.error as string) ?? 'unknown error',
|
|
4323
|
+
event.retryable as boolean | undefined,
|
|
4324
|
+
event.errorType as string | undefined,
|
|
3418
4325
|
);
|
|
3419
4326
|
} else if (event.type === 'inference:completed') {
|
|
3420
4327
|
// A successful response — even mid-turn between tool calls — proves the
|
|
3421
|
-
// 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).
|
|
3422
4330
|
const name = event.agentName as string | undefined;
|
|
3423
4331
|
if (name && this.consecutiveInferenceFailures.get(name)) {
|
|
3424
4332
|
this.consecutiveInferenceFailures.set(name, 0);
|
|
3425
4333
|
}
|
|
4334
|
+
if (name && this.exhaustionRewinds.get(name)) {
|
|
4335
|
+
this.exhaustionRewinds.set(name, 0);
|
|
4336
|
+
}
|
|
3426
4337
|
}
|
|
3427
4338
|
|
|
3428
4339
|
const traceEvent = {
|
|
@@ -3453,17 +4364,63 @@ export class AgentFramework {
|
|
|
3453
4364
|
* inference, so this never causes a retry/wake loop.
|
|
3454
4365
|
* 3. escalation — after N consecutive failures the agent is hard-down;
|
|
3455
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.
|
|
3456
4376
|
*/
|
|
3457
|
-
|
|
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.
|
|
4380
|
+
*/
|
|
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 {
|
|
3458
4403
|
const streak = (this.consecutiveInferenceFailures.get(agentName) ?? 0) + 1;
|
|
3459
4404
|
this.consecutiveInferenceFailures.set(agentName, streak);
|
|
4405
|
+
this.lastInferenceAt.set(agentName, { ...this.lastInferenceAt.get(agentName), failedAt: Date.now(), lastError: reason.slice(0, 300) });
|
|
3460
4406
|
|
|
3461
4407
|
// (1) Durable stderr line — works in headless/daemon mode with no client.
|
|
3462
4408
|
console.error(`[inference-failed] agent=${agentName} consecutive=${streak}: ${reason}`);
|
|
3463
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
|
+
|
|
3464
4421
|
// (2) Agent-facing chronicle marker (no inference triggered → no loop).
|
|
3465
4422
|
const agent = this.agents.get(agentName);
|
|
3466
|
-
if (agent) {
|
|
4423
|
+
if (agent && process.env.SUPPRESS_INFERENCE_FAILED_MARKER !== '1') {
|
|
3467
4424
|
try {
|
|
3468
4425
|
agent.getContextManager().addMessage(
|
|
3469
4426
|
'user',
|
|
@@ -3489,7 +4446,82 @@ export class AgentFramework {
|
|
|
3489
4446
|
`[inference-hard-down] agent=${agentName} has FAILED ${streak} consecutive ` +
|
|
3490
4447
|
`inferences — it cannot complete a turn. Last reason: ${reason}`,
|
|
3491
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
|
+
}
|
|
4474
|
+
}
|
|
4475
|
+
}
|
|
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;
|
|
3492
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
|
+
});
|
|
3493
4525
|
}
|
|
3494
4526
|
|
|
3495
4527
|
// ==========================================================================
|
|
@@ -3562,6 +4594,22 @@ export class AgentFramework {
|
|
|
3562
4594
|
}
|
|
3563
4595
|
},
|
|
3564
4596
|
shouldTriggerInference: triggerFilter,
|
|
4597
|
+
// Route a conversation fork's plain-text speech to its HOME channel, not
|
|
4598
|
+
// the process-global most-recent-inbound locus (item 3). The trunk agent
|
|
4599
|
+
// has no home entry, so this returns undefined and routeSpeech falls back
|
|
4600
|
+
// to defaultPublishChannel. `conversationAgentHomes` is the permanent
|
|
4601
|
+
// spawn-time binding; `channelForAgent` is the router's live binding as a
|
|
4602
|
+
// belt-and-suspenders fallback.
|
|
4603
|
+
homeChannelResolver: (agentName) =>
|
|
4604
|
+
this.conversationAgentHomes.get(agentName)
|
|
4605
|
+
?? this.conversationRouter?.channelForAgent(agentName),
|
|
4606
|
+
// Route a single TRUNK agent's plain-text speech to the channel that
|
|
4607
|
+
// triggered its CURRENT turn (item-3 redux). connectome-host runs every
|
|
4608
|
+
// agent as a trunk (it never exposes conversation forks), so without this
|
|
4609
|
+
// a reply falls back to the process-global most-recent-inbound locus and
|
|
4610
|
+
// a concurrent message in another channel hijacks it. Empty for
|
|
4611
|
+
// heartbeat / no-trigger turns → correct global fallback.
|
|
4612
|
+
activeChannelResolver: (agentName) => this.activeTriggerChannels.get(agentName),
|
|
3565
4613
|
// A text-only turn whose speech couldn't be delivered must not vanish
|
|
3566
4614
|
// silently: record a `[discord-send-failed]` marker in chronicle so the
|
|
3567
4615
|
// agent sees, on her next turn, that her reply never reached the human.
|
|
@@ -3585,8 +4633,9 @@ export class AgentFramework {
|
|
|
3585
4633
|
},
|
|
3586
4634
|
);
|
|
3587
4635
|
|
|
3588
|
-
// Host capabilities advertised during the MCP handshake
|
|
3589
|
-
|
|
4636
|
+
// Host capabilities advertised during the MCP handshake — stored so
|
|
4637
|
+
// servers can also be connected later at runtime (connectMcplServer).
|
|
4638
|
+
this.mcplHostCapabilities = {
|
|
3590
4639
|
version: '0.4',
|
|
3591
4640
|
pushEvents: true,
|
|
3592
4641
|
contextHooks: {
|
|
@@ -3598,66 +4647,242 @@ export class AgentFramework {
|
|
|
3598
4647
|
|
|
3599
4648
|
for (const config of serverConfigs) {
|
|
3600
4649
|
try {
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
}
|
|
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
|
+
}
|
|
3606
4668
|
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
// handshake window (between setupMessageRouting and now).
|
|
3611
|
-
this.wireMcplEvents(connection);
|
|
3612
|
-
connection.ready();
|
|
3613
|
-
|
|
3614
|
-
// Initialize feature sets if server advertises MCPL capabilities
|
|
3615
|
-
if (connection.capabilities) {
|
|
3616
|
-
const updateParams = this.featureSetManager.initializeServer(
|
|
3617
|
-
config.id,
|
|
3618
|
-
connection.capabilities,
|
|
3619
|
-
{
|
|
3620
|
-
enabledFeatureSets: config.enabledFeatureSets,
|
|
3621
|
-
disabledFeatureSets: config.disabledFeatureSets,
|
|
3622
|
-
},
|
|
3623
|
-
);
|
|
4669
|
+
// Discover tools from all connected servers
|
|
4670
|
+
await this.refreshMcplTools();
|
|
4671
|
+
}
|
|
3624
4672
|
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
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
|
+
}
|
|
3629
4685
|
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
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);
|
|
3634
4691
|
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
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
|
+
});
|
|
3648
4761
|
}
|
|
3649
4762
|
}
|
|
3650
|
-
|
|
3651
|
-
this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
|
|
3652
|
-
} catch (error) {
|
|
3653
|
-
// Fail-open: log and continue with remaining servers
|
|
3654
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
3655
|
-
console.error(`Failed to connect MCPL server "${config.id}":`, err.message);
|
|
3656
4763
|
}
|
|
3657
4764
|
}
|
|
4765
|
+
}
|
|
3658
4766
|
|
|
3659
|
-
|
|
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);
|
|
3660
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);
|
|
4834
|
+
|
|
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;
|
|
3661
4886
|
}
|
|
3662
4887
|
|
|
3663
4888
|
/**
|
|
@@ -3755,15 +4980,105 @@ export class AgentFramework {
|
|
|
3755
4980
|
this.handleToolsListChanged(connection.id);
|
|
3756
4981
|
});
|
|
3757
4982
|
|
|
3758
|
-
//
|
|
3759
|
-
|
|
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
|
+
}
|
|
3760
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}` });
|
|
3761
5011
|
});
|
|
3762
5012
|
|
|
3763
|
-
//
|
|
3764
|
-
|
|
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) => {
|
|
3765
5074
|
this.featureSetManager?.removeServer(connection.id);
|
|
3766
|
-
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
|
+
});
|
|
3767
5082
|
this.emitTrace({ type: 'module:removed', moduleName: `mcpl:${connection.id}` });
|
|
3768
5083
|
});
|
|
3769
5084
|
}
|
|
@@ -3894,14 +5209,20 @@ export class AgentFramework {
|
|
|
3894
5209
|
const startTime = Date.now();
|
|
3895
5210
|
const args = (call.input && typeof call.input === 'object') ? call.input as Record<string, unknown> : {};
|
|
3896
5211
|
|
|
3897
|
-
// Build state params for stateful tools (Step 8)
|
|
5212
|
+
// Build state params for stateful tools (Step 8).
|
|
5213
|
+
// Host-managed state is single-valued per server, so inject the host-managed
|
|
5214
|
+
// set's `state` for any call; otherwise fall back to a server-managed set's
|
|
5215
|
+
// opaque `checkpoint`. Never blindly pick "first stateful" — when a server
|
|
5216
|
+
// mixes host-managed (e.g. a feed) and server-managed (e.g. post/undo) sets,
|
|
5217
|
+
// that misattributes the feed's state.
|
|
3898
5218
|
let stateParams: { state?: unknown; checkpoint?: string } | undefined;
|
|
3899
5219
|
if (this.checkpointManager) {
|
|
3900
|
-
const
|
|
3901
|
-
if (
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
5220
|
+
const hostFs = this.checkpointManager.getHostManagedFeatureSet(serverId);
|
|
5221
|
+
if (hostFs) {
|
|
5222
|
+
stateParams = { state: this.checkpointManager.getCurrentState(serverId, hostFs) };
|
|
5223
|
+
} else {
|
|
5224
|
+
const fs = this.checkpointManager.getStatefulFeatureSet(serverId);
|
|
5225
|
+
if (fs) {
|
|
3905
5226
|
const cp = this.checkpointManager.getCurrentCheckpoint(serverId, fs);
|
|
3906
5227
|
if (cp) stateParams = { checkpoint: cp };
|
|
3907
5228
|
}
|
|
@@ -3913,10 +5234,17 @@ export class AgentFramework {
|
|
|
3913
5234
|
const durationMs = Date.now() - startTime;
|
|
3914
5235
|
this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, durationMs });
|
|
3915
5236
|
|
|
3916
|
-
// Record checkpoint from stateful tool response (Step 8)
|
|
5237
|
+
// Record checkpoint from stateful tool response (Step 8).
|
|
5238
|
+
// Attribute to the set the server tagged (result.state.featureSet) first;
|
|
5239
|
+
// else the host-managed set (host-managed checkpoints carry data/patch);
|
|
5240
|
+
// else the single stateful set. Avoids recording a feed checkpoint onto a
|
|
5241
|
+
// server-managed set just because it registered first.
|
|
3917
5242
|
if (result.state && this.checkpointManager) {
|
|
3918
|
-
const
|
|
3919
|
-
|
|
5243
|
+
const tagged = result.state.featureSet;
|
|
5244
|
+
const fs = tagged
|
|
5245
|
+
?? this.checkpointManager.getHostManagedFeatureSet(serverId)
|
|
5246
|
+
?? this.checkpointManager.getStatefulFeatureSet(serverId);
|
|
5247
|
+
if (fs && this.checkpointManager.isStateful(serverId, fs)) {
|
|
3920
5248
|
this.checkpointManager.recordCheckpoint(serverId, fs, result.state);
|
|
3921
5249
|
}
|
|
3922
5250
|
}
|
|
@@ -4037,6 +5365,91 @@ export class AgentFramework {
|
|
|
4037
5365
|
});
|
|
4038
5366
|
}
|
|
4039
5367
|
|
|
5368
|
+
/**
|
|
5369
|
+
* Handle the synthesized `wake_add_rule` / `wake_remove_rule` tools: mutate
|
|
5370
|
+
* the hot-reloaded gate.json at runtime. Validation lives in the EventGate
|
|
5371
|
+
* (same path as gate.json load), so an invalid rule is surfaced as a tool
|
|
5372
|
+
* error and nothing is written.
|
|
5373
|
+
*/
|
|
5374
|
+
private dispatchWakeRuleToolCall(agentName: string, call: ToolCall): void {
|
|
5375
|
+
this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
|
|
5376
|
+
const finish = (result: ToolResult) => {
|
|
5377
|
+
this.emitTrace({
|
|
5378
|
+
type: result.isError ? 'tool:failed' : 'tool:completed',
|
|
5379
|
+
module: 'gate', tool: call.name, callId: call.id, durationMs: 0,
|
|
5380
|
+
...(result.isError ? { error: result.error } : {}),
|
|
5381
|
+
});
|
|
5382
|
+
this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
|
|
5383
|
+
};
|
|
5384
|
+
|
|
5385
|
+
try {
|
|
5386
|
+
if (call.name === 'wake_remove_rule') {
|
|
5387
|
+
const input = (call.input ?? {}) as { name?: unknown };
|
|
5388
|
+
if (typeof input.name !== 'string' || !input.name) {
|
|
5389
|
+
finish({ success: false, error: 'wake_remove_rule: `name` (string) is required', isError: true });
|
|
5390
|
+
return;
|
|
5391
|
+
}
|
|
5392
|
+
const removed = this.removeGatePolicy(input.name);
|
|
5393
|
+
finish({
|
|
5394
|
+
success: true,
|
|
5395
|
+
data: { removed, name: input.name, policies: this.getGatePolicyNames() },
|
|
5396
|
+
});
|
|
5397
|
+
return;
|
|
5398
|
+
}
|
|
5399
|
+
|
|
5400
|
+
// wake_add_rule — assemble the canonical behavior from the typed fields
|
|
5401
|
+
// (exactly one), then let the gate's own validator do the authoritative
|
|
5402
|
+
// range/shape checks.
|
|
5403
|
+
const input = (call.input ?? {}) as {
|
|
5404
|
+
name?: unknown; match?: unknown; resets?: unknown; position?: unknown;
|
|
5405
|
+
behavior?: unknown; debounceMs?: unknown; rateLimit?: unknown; passiveSample?: unknown;
|
|
5406
|
+
};
|
|
5407
|
+
const behaviorSources = [
|
|
5408
|
+
input.behavior !== undefined ? 'behavior' : null,
|
|
5409
|
+
input.debounceMs !== undefined ? 'debounceMs' : null,
|
|
5410
|
+
input.rateLimit !== undefined ? 'rateLimit' : null,
|
|
5411
|
+
input.passiveSample !== undefined ? 'passiveSample' : null,
|
|
5412
|
+
].filter((s): s is string => s !== null);
|
|
5413
|
+
if (behaviorSources.length === 0) {
|
|
5414
|
+
finish({
|
|
5415
|
+
success: false,
|
|
5416
|
+
error: 'wake_add_rule: specify exactly one behavior — `behavior` ("always"/"defer"/"skip"), `debounceMs`, `rateLimit`, or `passiveSample`.',
|
|
5417
|
+
isError: true,
|
|
5418
|
+
});
|
|
5419
|
+
return;
|
|
5420
|
+
}
|
|
5421
|
+
if (behaviorSources.length > 1) {
|
|
5422
|
+
finish({
|
|
5423
|
+
success: false,
|
|
5424
|
+
error: `wake_add_rule: give only one behavior, got [${behaviorSources.join(', ')}].`,
|
|
5425
|
+
isError: true,
|
|
5426
|
+
});
|
|
5427
|
+
return;
|
|
5428
|
+
}
|
|
5429
|
+
const behavior: unknown =
|
|
5430
|
+
input.debounceMs !== undefined ? { debounce: input.debounceMs }
|
|
5431
|
+
: input.rateLimit !== undefined ? { rate_limit: input.rateLimit }
|
|
5432
|
+
: input.passiveSample !== undefined ? { passive_sample: input.passiveSample }
|
|
5433
|
+
: input.behavior;
|
|
5434
|
+
|
|
5435
|
+
const position = input.position === 'prepend' ? 'prepend' : 'append';
|
|
5436
|
+
const rawPolicy = {
|
|
5437
|
+
name: input.name,
|
|
5438
|
+
match: input.match ?? {},
|
|
5439
|
+
behavior,
|
|
5440
|
+
...(input.resets !== undefined ? { resets: input.resets } : {}),
|
|
5441
|
+
};
|
|
5442
|
+
const policy = this.addGatePolicy(rawPolicy, { position });
|
|
5443
|
+
finish({
|
|
5444
|
+
success: true,
|
|
5445
|
+
data: { added: policy.name, policies: this.getGatePolicyNames() },
|
|
5446
|
+
});
|
|
5447
|
+
} catch (err) {
|
|
5448
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
5449
|
+
finish({ success: false, error: msg, isError: true });
|
|
5450
|
+
}
|
|
5451
|
+
}
|
|
5452
|
+
|
|
4040
5453
|
/**
|
|
4041
5454
|
* Handle the synthesized `sleep` / `wake` tools. `sleep` arms the gate's
|
|
4042
5455
|
* suppression window, optionally announces in the sticky channel, and ends
|
|
@@ -4064,7 +5477,7 @@ export class AgentFramework {
|
|
|
4064
5477
|
return;
|
|
4065
5478
|
}
|
|
4066
5479
|
|
|
4067
|
-
const { until } = gate.setSleep(seconds, input.message);
|
|
5480
|
+
const { until } = gate.setSleep(seconds, input.message, agentName);
|
|
4068
5481
|
const announce = input.announce !== false; // default true
|
|
4069
5482
|
const human =
|
|
4070
5483
|
seconds >= 3600 ? `${(seconds / 3600).toFixed(1)}h`
|
|
@@ -4084,6 +5497,42 @@ export class AgentFramework {
|
|
|
4084
5497
|
finish({ success: true, data: { sleepingFor: human, until }, endTurn: true });
|
|
4085
5498
|
}
|
|
4086
5499
|
|
|
5500
|
+
/** Aggregate the event-tag vocabulary: reserved chat:* core + each connected
|
|
5501
|
+
* server's declared tag ontology + gate.js status. */
|
|
5502
|
+
private buildEventTagsResult(): Record<string, unknown> {
|
|
5503
|
+
const servers: Record<string, Record<string, unknown>> = {};
|
|
5504
|
+
for (const conn of this.mcplServerRegistry?.getAllServers() ?? []) {
|
|
5505
|
+
const declared = this.featureSetManager?.getDeclaredFeatureSets(conn.id) ?? {};
|
|
5506
|
+
const sets: Record<string, unknown> = {};
|
|
5507
|
+
for (const [name, decl] of Object.entries(declared)) {
|
|
5508
|
+
if (decl.tagOntology) sets[name] = decl.tagOntology;
|
|
5509
|
+
}
|
|
5510
|
+
if (Object.keys(sets).length > 0) servers[conn.id] = sets;
|
|
5511
|
+
}
|
|
5512
|
+
return {
|
|
5513
|
+
core: AgentFramework.CHAT_CORE_TAGS,
|
|
5514
|
+
servers,
|
|
5515
|
+
gateScript: this.eventGate?.getStatus().script ?? null,
|
|
5516
|
+
hint:
|
|
5517
|
+
'Use these in gate.json policies (match.tagsAny / tagsAll / tagsNone) or ' +
|
|
5518
|
+
'in gate.js. Unknown/undeclared tags are tolerated (open ontologies).',
|
|
5519
|
+
};
|
|
5520
|
+
}
|
|
5521
|
+
|
|
5522
|
+
private dispatchEventTagsToolCall(agentName: string, call: ToolCall): void {
|
|
5523
|
+
this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
|
|
5524
|
+
let result: import('./types/events.js').ToolResult;
|
|
5525
|
+
try {
|
|
5526
|
+
result = { success: true, data: this.buildEventTagsResult() };
|
|
5527
|
+
this.emitTrace({ type: 'tool:completed', module: 'gate', tool: call.name, callId: call.id, durationMs: 0 });
|
|
5528
|
+
} catch (error) {
|
|
5529
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
5530
|
+
this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
|
|
5531
|
+
result = { success: false, error: err.message, isError: true };
|
|
5532
|
+
}
|
|
5533
|
+
this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
|
|
5534
|
+
}
|
|
5535
|
+
|
|
4087
5536
|
private dispatchGateToolCall(agentName: string, call: ToolCall): void {
|
|
4088
5537
|
this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
|
|
4089
5538
|
const startTime = Date.now();
|
|
@@ -4117,7 +5566,13 @@ export class AgentFramework {
|
|
|
4117
5566
|
const inferenceId = `${agent.name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
4118
5567
|
return {
|
|
4119
5568
|
inferenceId,
|
|
4120
|
-
|
|
5569
|
+
// Conversation identity = the agent (a trunk agent IS its own conversation;
|
|
5570
|
+
// forks get their own agent). The turn's channel LOCUS — the "proper
|
|
5571
|
+
// conversation tracking" this once flagged as a TODO — is now tracked
|
|
5572
|
+
// per-agent in `activeTriggerChannels` and surfaced to the agent via
|
|
5573
|
+
// buildChannelContext (channels.defaultOutgoing) below, so the agent is
|
|
5574
|
+
// told the same channel its speech will route to (item-3 redux).
|
|
5575
|
+
conversationId: agent.name,
|
|
4121
5576
|
turnIndex: 0, // Simplified; needs per-conversation counter TODO
|
|
4122
5577
|
userMessage: null, // Could extract from trigger context
|
|
4123
5578
|
model: {
|
|
@@ -4126,7 +5581,7 @@ export class AgentFramework {
|
|
|
4126
5581
|
contextWindow: 200000,
|
|
4127
5582
|
capabilities: ['tools'],
|
|
4128
5583
|
},
|
|
4129
|
-
channels: this.channelRegistry?.buildChannelContext(),
|
|
5584
|
+
channels: this.channelRegistry?.buildChannelContext(agent.name),
|
|
4130
5585
|
};
|
|
4131
5586
|
}
|
|
4132
5587
|
}
|