@animalabs/agent-framework 0.6.5 → 0.6.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -0
- package/dist/src/agent.d.ts +56 -2
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +372 -57
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/mcp-server.js +0 -0
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +7 -2
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/canonical-json.d.ts +2 -0
- package/dist/src/canonical-json.d.ts.map +1 -0
- package/dist/src/canonical-json.js +23 -0
- package/dist/src/canonical-json.js.map +1 -0
- package/dist/src/framework.d.ts +160 -2
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +3098 -167
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +34 -4
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +36 -15
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +322 -76
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +1 -0
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +1 -0
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +39 -4
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +146 -28
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +43 -12
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js +1 -0
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +12 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +661 -2
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +4 -0
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.d.ts +99 -0
- package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -0
- package/dist/src/recovery/discord-awareness-outbox.js +334 -0
- package/dist/src/recovery/discord-awareness-outbox.js.map +1 -0
- package/dist/src/recovery/offline-branch.d.ts +45 -0
- package/dist/src/recovery/offline-branch.d.ts.map +1 -0
- package/dist/src/recovery/offline-branch.js +362 -0
- package/dist/src/recovery/offline-branch.js.map +1 -0
- package/dist/src/recovery/recover-cli.d.ts +3 -0
- package/dist/src/recovery/recover-cli.d.ts.map +1 -0
- package/dist/src/recovery/recover-cli.js +129 -0
- package/dist/src/recovery/recover-cli.js.map +1 -0
- package/dist/src/types/agent.d.ts +37 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +1 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +14 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +31 -0
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +4 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/agent-runtime-settings.test.d.ts +2 -0
- package/dist/test/agent-runtime-settings.test.d.ts.map +1 -0
- package/dist/test/agent-runtime-settings.test.js +227 -0
- package/dist/test/agent-runtime-settings.test.js.map +1 -0
- package/dist/test/busy-agent-scheduler.test.d.ts +2 -0
- package/dist/test/busy-agent-scheduler.test.d.ts.map +1 -0
- package/dist/test/busy-agent-scheduler.test.js +62 -0
- package/dist/test/busy-agent-scheduler.test.js.map +1 -0
- package/dist/test/channel-lifecycle-provenance.test.d.ts +2 -0
- package/dist/test/channel-lifecycle-provenance.test.d.ts.map +1 -0
- package/dist/test/channel-lifecycle-provenance.test.js +47 -0
- package/dist/test/channel-lifecycle-provenance.test.js.map +1 -0
- package/dist/test/channel-registry-routing.test.js +5 -4
- package/dist/test/channel-registry-routing.test.js.map +1 -1
- package/dist/test/conversation-fork-injection-scoping.test.js +12 -1
- package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
- package/dist/test/discord-awareness-outbox.test.d.ts +2 -0
- package/dist/test/discord-awareness-outbox.test.d.ts.map +1 -0
- package/dist/test/discord-awareness-outbox.test.js +248 -0
- package/dist/test/discord-awareness-outbox.test.js.map +1 -0
- package/dist/test/event-gate.test.js +21 -1
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/helpers/mock-membrane.d.ts +13 -1
- package/dist/test/helpers/mock-membrane.d.ts.map +1 -1
- package/dist/test/helpers/mock-membrane.js +9 -2
- package/dist/test/helpers/mock-membrane.js.map +1 -1
- package/dist/test/maintenance-timer.test.js +103 -1
- package/dist/test/maintenance-timer.test.js.map +1 -1
- package/dist/test/mcpl-awareness-barrier.test.d.ts +2 -0
- package/dist/test/mcpl-awareness-barrier.test.d.ts.map +1 -0
- package/dist/test/mcpl-awareness-barrier.test.js +845 -0
- package/dist/test/mcpl-awareness-barrier.test.js.map +1 -0
- package/dist/test/mcpl-gate-roundtrip.test.js +138 -6
- package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
- package/dist/test/mcpl-reconnect-reregistration.test.js +22 -3
- package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
- package/dist/test/mcpl-request-timeout.test.js +15 -0
- package/dist/test/mcpl-request-timeout.test.js.map +1 -1
- package/dist/test/offline-recovery-branch.test.d.ts +2 -0
- package/dist/test/offline-recovery-branch.test.d.ts.map +1 -0
- package/dist/test/offline-recovery-branch.test.js +226 -0
- package/dist/test/offline-recovery-branch.test.js.map +1 -0
- package/dist/test/present-while-acting.test.d.ts +19 -0
- package/dist/test/present-while-acting.test.d.ts.map +1 -0
- package/dist/test/present-while-acting.test.js +295 -0
- package/dist/test/present-while-acting.test.js.map +1 -0
- package/dist/test/primary-summary-refusal-fallback-integration.test.d.ts +2 -0
- package/dist/test/primary-summary-refusal-fallback-integration.test.d.ts.map +1 -0
- package/dist/test/primary-summary-refusal-fallback-integration.test.js +1955 -0
- package/dist/test/primary-summary-refusal-fallback-integration.test.js.map +1 -0
- package/dist/test/primary-summary-refusal-fallback.test.d.ts +2 -0
- package/dist/test/primary-summary-refusal-fallback.test.d.ts.map +1 -0
- package/dist/test/primary-summary-refusal-fallback.test.js +753 -0
- package/dist/test/primary-summary-refusal-fallback.test.js.map +1 -0
- package/dist/test/same-round-think-policy.test.d.ts +2 -0
- package/dist/test/same-round-think-policy.test.d.ts.map +1 -0
- package/dist/test/same-round-think-policy.test.js +853 -0
- package/dist/test/same-round-think-policy.test.js.map +1 -0
- package/dist/test/trunk-channel-routing.test.js +38 -0
- package/dist/test/trunk-channel-routing.test.js.map +1 -1
- package/dist/test/workspace-read-image.test.d.ts +2 -0
- package/dist/test/workspace-read-image.test.d.ts.map +1 -0
- package/dist/test/workspace-read-image.test.js +518 -0
- package/dist/test/workspace-read-image.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -4
- package/src/agent.ts +485 -63
- package/src/api/server.ts +8 -2
- package/src/canonical-json.ts +22 -0
- package/src/framework.ts +4096 -304
- package/src/gate/event-gate.ts +50 -4
- package/src/index.ts +20 -0
- package/src/mcpl/channel-registry.ts +396 -85
- package/src/mcpl/push-handler.ts +2 -0
- package/src/mcpl/server-connection.ts +173 -28
- package/src/mcpl/types.ts +49 -12
- package/src/modules/workspace/index.ts +759 -2
- package/src/modules/workspace/types.ts +5 -0
- package/src/recovery/discord-awareness-outbox.ts +459 -0
- package/src/recovery/offline-branch.ts +510 -0
- package/src/recovery/recover-cli.ts +130 -0
- package/src/types/agent.ts +44 -0
- package/src/types/events.ts +1 -1
- package/src/types/framework.ts +17 -0
- package/src/types/module.ts +33 -0
- package/src/types/trace.ts +4 -0
package/dist/src/framework.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
3
4
|
import { JsStore } from '@animalabs/chronicle';
|
|
4
5
|
import { MembraneError } from '@animalabs/membrane';
|
|
5
6
|
import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager';
|
|
@@ -17,19 +18,65 @@ import { ConversationRouter } from './mcpl/conversation-router.js';
|
|
|
17
18
|
import { safeSlice } from './safe-slice.js';
|
|
18
19
|
import { toolResultDataToHistoryString } from './tool-result-history.js';
|
|
19
20
|
import { splitProseSegments } from './prose-segments.js';
|
|
21
|
+
import { canonicalJsonStringify } from './canonical-json.js';
|
|
22
|
+
/**
|
|
23
|
+
* Tools whose presence suppresses auto-routing of the surrounding prose,
|
|
24
|
+
* for two distinct reasons:
|
|
25
|
+
* - `skip_reply` — the agent EXPLICITLY chose not to reply (the deliberate
|
|
26
|
+
* "stay silent" signal).
|
|
27
|
+
* - explicit delivery tools (channel_publish / *send_message /
|
|
28
|
+
* *reply_message / *send_dm) — already sent the message, so routing the
|
|
29
|
+
* prose again would double-post.
|
|
30
|
+
* `think` is deliberately NOT here: it is silent *reasoning*, and same-round
|
|
31
|
+
* prose beside it is governed separately by same_round_think_text_policy.
|
|
32
|
+
*
|
|
33
|
+
* Scope: an explicit delivery suppresses prose from that round onward until
|
|
34
|
+
* another external message is injected. The suppression prevents a
|
|
35
|
+
* `send_message` followed by "sent it" from double-posting, but a new message
|
|
36
|
+
* starts a new conversational round and must be answerable with plain prose.
|
|
37
|
+
* `skip_reply` ends the turn at the tool-result boundary.
|
|
38
|
+
*/
|
|
39
|
+
const SILENCING_TOOLS = new Set([
|
|
40
|
+
'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
|
|
41
|
+
]);
|
|
42
|
+
/** Strip the `server--` MCPL prefix from a tool name. */
|
|
43
|
+
const bareToolName = (n) => n.split('--').pop();
|
|
20
44
|
import { CheckpointManager } from './mcpl/checkpoint-manager.js';
|
|
21
45
|
import { isToolAllowed } from './mcpl/tool-policy.js';
|
|
22
46
|
import { EventGate } from './gate/event-gate.js';
|
|
23
47
|
import { UsageTracker } from './usage/usage-tracker.js';
|
|
24
48
|
import { formatZonedDateTime, resolveTimeZone } from './timezone.js';
|
|
49
|
+
import { DEFAULT_DISCORD_AWARENESS_EMOJI, DiscordAwarenessOutbox, defaultDiscordAwarenessOutboxPath, extractDiscordAwarenessRefs, } from './recovery/discord-awareness-outbox.js';
|
|
25
50
|
const FRAMEWORK_STATE_ID = 'framework/state';
|
|
26
51
|
const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router';
|
|
27
52
|
const INFERENCE_LOG_ID = 'framework/inference-log';
|
|
28
53
|
const PROCESS_LOG_ID = 'framework/process-log';
|
|
54
|
+
const PRIMARY_SUMMARY_FALLBACK_STATE_ID = 'framework/primary-summary-fallback';
|
|
55
|
+
const PRIMARY_SUMMARY_SETTLEMENT_METADATA_KEY = 'primarySummarySettlement';
|
|
56
|
+
const PRIMARY_SUMMARY_SETTLEMENT_VERSION = 1;
|
|
57
|
+
const PRIMARY_SUMMARY_SETTLEMENT_MAX_ID_LENGTH = 1024;
|
|
58
|
+
const PRIMARY_SUMMARY_SETTLEMENT_MAX_NAME_LENGTH = 256;
|
|
59
|
+
const PRIMARY_SUMMARY_SETTLEMENT_MAX_REASON_LENGTH = 256;
|
|
29
60
|
const TURN_CHECKPOINTS_ID = 'framework/turn-checkpoints'; // legacy single-map layout, read-only fallback
|
|
30
61
|
const TURN_CHECKPOINTS_TREE_ID = 'framework/turn-checkpoints/tree';
|
|
31
62
|
/** Maximum number of turn checkpoints to keep per agent. */
|
|
32
63
|
const MAX_TURN_CHECKPOINTS = 20;
|
|
64
|
+
const DEFAULT_DISCORD_AWARENESS_DEADLINE_MS = 10_000;
|
|
65
|
+
const MIN_DISCORD_AWARENESS_DEADLINE_MS = 50;
|
|
66
|
+
const MAX_DISCORD_AWARENESS_DEADLINE_MS = 60_000;
|
|
67
|
+
function normalizeDiscordAwarenessDeadline(value) {
|
|
68
|
+
if (value === undefined || !Number.isFinite(value) || value <= 0) {
|
|
69
|
+
return DEFAULT_DISCORD_AWARENESS_DEADLINE_MS;
|
|
70
|
+
}
|
|
71
|
+
return Math.max(MIN_DISCORD_AWARENESS_DEADLINE_MS, Math.min(MAX_DISCORD_AWARENESS_DEADLINE_MS, Math.floor(value)));
|
|
72
|
+
}
|
|
73
|
+
class DiscordAwarenessAccountingError extends Error {
|
|
74
|
+
constructor(operation, cause) {
|
|
75
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
76
|
+
super(`Discord awareness accounting failed during ${operation}: ${detail}`, { cause });
|
|
77
|
+
this.name = 'DiscordAwarenessAccountingError';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
33
80
|
/**
|
|
34
81
|
* Default inference policy - infer if any request exists for the agent.
|
|
35
82
|
*/
|
|
@@ -38,6 +85,10 @@ class DefaultInferencePolicy {
|
|
|
38
85
|
return requests.some((r) => r.agentName === agentName);
|
|
39
86
|
}
|
|
40
87
|
}
|
|
88
|
+
function isPermanentDiscordReactionFailure(message) {
|
|
89
|
+
return /unknown message|unknown channel|missing access|missing permissions|missing permission|cannot access|channel .* not found|message .* not found/i
|
|
90
|
+
.test(message);
|
|
91
|
+
}
|
|
41
92
|
/**
|
|
42
93
|
* Default error policy - retry with exponential backoff.
|
|
43
94
|
*
|
|
@@ -139,13 +190,29 @@ export class AgentFramework {
|
|
|
139
190
|
maintenanceRunId = 0;
|
|
140
191
|
currentMaintenanceRun = null;
|
|
141
192
|
maintenanceHistory = [];
|
|
142
|
-
/** Last time we
|
|
193
|
+
/** Last time we reported stale (busy-requeued) inference requests, per agent. */
|
|
143
194
|
staleWarnAt = new Map();
|
|
144
195
|
/** Per-agent last inference activity (epoch ms), for /healthz + doctor tooling. */
|
|
145
196
|
lastInferenceAt = new Map();
|
|
146
197
|
processLoggingPersist;
|
|
147
198
|
processLoggingBroadcast;
|
|
148
199
|
activeStreams = new Map();
|
|
200
|
+
activeStreamContexts = new Map();
|
|
201
|
+
primarySummaryStateLock = Promise.resolve();
|
|
202
|
+
/** Per-agent output locus pinned for the CURRENT logical turn (see
|
|
203
|
+
* resolveTurnLocus in driveStream). Lives here — not in driveStream
|
|
204
|
+
* locals — so the pin survives a context-budget stream restart, which
|
|
205
|
+
* continues the same logical turn in a fresh driveStream. A mid-turn
|
|
206
|
+
* injected channel message replaces the pin for the next conversational
|
|
207
|
+
* round; otherwise it remains stable. Cleared at the start of every
|
|
208
|
+
* non-restart turn. */
|
|
209
|
+
turnLocusPins = new Map();
|
|
210
|
+
/** A tool boundary injected fresh external input into the live stream.
|
|
211
|
+
* Presence (including a null value) tells driveStream to clear sticky
|
|
212
|
+
* explicit-send suppression before handling the next model round. A
|
|
213
|
+
* string also moves that round's reply locus to the newest injected
|
|
214
|
+
* channel. */
|
|
215
|
+
midTurnRoutingResets = new Map();
|
|
149
216
|
pendingAssistantBlocks = new Map();
|
|
150
217
|
/** Streams the FRAMEWORK cancelled for non-terminal reasons, keyed
|
|
151
218
|
* `${agentName}:${streamId}`: an endTurn tool result or a context-budget
|
|
@@ -249,13 +316,22 @@ export class AgentFramework {
|
|
|
249
316
|
* subsystem can be lazily initialized by connectMcplServer when the
|
|
250
317
|
* framework started with zero configured servers. */
|
|
251
318
|
mcplInferenceRoutingConfig = null;
|
|
319
|
+
/** Durable, non-Chronicle projection queue for messages removed by a branch. */
|
|
320
|
+
discordAwarenessOutbox = null;
|
|
321
|
+
discordAwarenessEmoji = DEFAULT_DISCORD_AWARENESS_EMOJI;
|
|
322
|
+
discordAwarenessDeadlineMs = DEFAULT_DISCORD_AWARENESS_DEADLINE_MS;
|
|
323
|
+
/** Serialize per-server drains so reconnect and an online undo cannot race. */
|
|
324
|
+
discordAwarenessDrains = new Map();
|
|
325
|
+
/** Framework-global inference gate; older generations cannot release it. */
|
|
326
|
+
discordAwarenessBarrier = null;
|
|
327
|
+
discordAwarenessBarrierGeneration = 0;
|
|
252
328
|
// EventGate (null when FrameworkConfig.gate is omitted)
|
|
253
329
|
eventGate = null;
|
|
254
330
|
// Session-level token usage tracking (always-on)
|
|
255
331
|
usageTracker;
|
|
256
332
|
/** Presentation-only wall-clock zone; persistence remains UTC/epoch. */
|
|
257
333
|
timeZone;
|
|
258
|
-
constructor(store, ownsStore, membrane, inferencePolicy, errorPolicy, syncIntervalMs, maintenanceIntervalMs, processLoggingPersist, processLoggingBroadcast, timeZone) {
|
|
334
|
+
constructor(store, ownsStore, membrane, inferencePolicy, errorPolicy, syncIntervalMs, maintenanceIntervalMs, processLoggingPersist, processLoggingBroadcast, timeZone, discordAwarenessOutbox, discordAwarenessEmoji, discordAwarenessDeadlineMs) {
|
|
259
335
|
this.store = store;
|
|
260
336
|
this.ownsStore = ownsStore;
|
|
261
337
|
this.membrane = membrane;
|
|
@@ -266,6 +342,9 @@ export class AgentFramework {
|
|
|
266
342
|
this.processLoggingPersist = processLoggingPersist;
|
|
267
343
|
this.processLoggingBroadcast = processLoggingBroadcast;
|
|
268
344
|
this.timeZone = timeZone;
|
|
345
|
+
this.discordAwarenessOutbox = discordAwarenessOutbox;
|
|
346
|
+
this.discordAwarenessEmoji = discordAwarenessEmoji;
|
|
347
|
+
this.discordAwarenessDeadlineMs = discordAwarenessDeadlineMs;
|
|
269
348
|
this.queue = new ProcessQueueImpl();
|
|
270
349
|
this.usageTracker = new UsageTracker({
|
|
271
350
|
emitTrace: (e) => this.emitTrace({ ...e }),
|
|
@@ -345,13 +424,45 @@ export class AgentFramework {
|
|
|
345
424
|
// Already registered
|
|
346
425
|
}
|
|
347
426
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
427
|
+
try {
|
|
428
|
+
store.registerState({ id: PRIMARY_SUMMARY_FALLBACK_STATE_ID, strategy: 'snapshot' });
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
// Already registered
|
|
432
|
+
}
|
|
433
|
+
const discordAwarenessOutboxPath = config.discordAwarenessOutboxPath
|
|
434
|
+
?? (config.storePath ? defaultDiscordAwarenessOutboxPath(config.storePath) : undefined);
|
|
435
|
+
const discordAwarenessOutbox = discordAwarenessOutboxPath
|
|
436
|
+
? new DiscordAwarenessOutbox(discordAwarenessOutboxPath)
|
|
437
|
+
: null;
|
|
438
|
+
const framework = new AgentFramework(store, ownsStore, config.membrane, config.inferencePolicy ?? new DefaultInferencePolicy(), config.errorPolicy ?? new DefaultErrorPolicy(), config.syncIntervalMs ?? DEFAULT_SYNC_INTERVAL_MS, config.maintenanceIntervalMs ?? DEFAULT_MAINTENANCE_INTERVAL_MS, processLoggingPersist, processLoggingBroadcast, resolveTimeZone(config.timeZone), discordAwarenessOutbox, config.discordAwarenessEmoji ?? DEFAULT_DISCORD_AWARENESS_EMOJI, normalizeDiscordAwarenessDeadline(config.discordAwarenessDeadlineMs));
|
|
439
|
+
// If an offline recovery process crashed after switching Chronicle but
|
|
440
|
+
// before committing its prepared marker batch, the active branch is the
|
|
441
|
+
// commit record. Promote it now; no quarantined content is read.
|
|
442
|
+
if (discordAwarenessOutbox) {
|
|
443
|
+
try {
|
|
444
|
+
const activated = discordAwarenessOutbox.activatePreparedForBranch(store.currentBranch().name, store.listBranches());
|
|
445
|
+
if (activated > 0) {
|
|
446
|
+
console.error(`[discord-awareness] recovered ${activated} prepared batch(es) for active branch ${store.currentBranch().name}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
// The branch may be safe, but reporting the framework ready while its
|
|
451
|
+
// durable awareness projection is unreadable creates a half-ready
|
|
452
|
+
// host whose data plane can never be released safely.
|
|
453
|
+
throw new DiscordAwarenessAccountingError('startup reconciliation', error);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
351
456
|
// Create agents
|
|
352
457
|
for (const agentConfig of config.agents) {
|
|
353
458
|
await framework.createAgent(agentConfig);
|
|
354
459
|
}
|
|
460
|
+
// Restore persisted usage data (if any) from prior session
|
|
461
|
+
framework.restoreUsageState();
|
|
462
|
+
await framework.reconcilePrimarySummaryFallbackStateOnStartup();
|
|
463
|
+
// Finish any branch-local suppression interrupted after Chronicle switched
|
|
464
|
+
// branches. This runs before modules, MCPL connections, or inbound traffic.
|
|
465
|
+
await framework.resumePreparedDiscordSuppressions();
|
|
355
466
|
// Add modules
|
|
356
467
|
for (const module of config.modules) {
|
|
357
468
|
await framework.addModule(module);
|
|
@@ -566,11 +677,10 @@ export class AgentFramework {
|
|
|
566
677
|
});
|
|
567
678
|
}
|
|
568
679
|
async runQueuedMaintenance() {
|
|
569
|
-
const allTools = this.getAllTools();
|
|
570
680
|
const queued = [...this.agents.values()].flatMap((agent) => {
|
|
571
681
|
const cm = agent.getContextManager();
|
|
572
|
-
const tools =
|
|
573
|
-
cm.setToolDefinitions(tools);
|
|
682
|
+
const tools = this.getToolsForAgent(agent.name).filter((tool) => agent.canUseTool(tool.name));
|
|
683
|
+
cm.setToolDefinitions?.(tools);
|
|
574
684
|
if (cm.isReady())
|
|
575
685
|
return [];
|
|
576
686
|
const pending = cm.getPendingWork()?.description;
|
|
@@ -752,10 +862,150 @@ export class AgentFramework {
|
|
|
752
862
|
AgentFramework.EVENT_TAGS_TOOL,
|
|
753
863
|
]
|
|
754
864
|
: [];
|
|
755
|
-
|
|
756
|
-
|
|
865
|
+
return [
|
|
866
|
+
...moduleTools,
|
|
867
|
+
...this.mcplTools,
|
|
868
|
+
...channelTools,
|
|
869
|
+
...gateTools,
|
|
870
|
+
this.buildAgentSettingsTool(),
|
|
871
|
+
...(this.getWorkspaceModule() ? [AgentFramework.SAVE_IMAGE_TOOL] : []),
|
|
872
|
+
];
|
|
873
|
+
}
|
|
874
|
+
/** The registered workspace module, if any (used by save_image). */
|
|
875
|
+
getWorkspaceModule() {
|
|
876
|
+
const module = this.moduleRegistry.getAllModules().find((m) => m.name === 'workspace');
|
|
877
|
+
return module && typeof module.writeBinary === 'function'
|
|
878
|
+
? module
|
|
879
|
+
: undefined;
|
|
880
|
+
}
|
|
881
|
+
/** Core agent_settings keys — extension keys must not collide with these. */
|
|
882
|
+
static AGENT_SETTINGS_CORE_KEYS = [
|
|
883
|
+
'context_budget_tokens',
|
|
884
|
+
'tail_tokens',
|
|
885
|
+
'transition_pace_tokens',
|
|
886
|
+
'same_round_think_text_policy',
|
|
887
|
+
];
|
|
888
|
+
/**
|
|
889
|
+
* Collect module-declared agent_settings extensions (Module.getAgentSettingsExtension),
|
|
890
|
+
* keyed by owning module name. Extensions whose keys collide with the core
|
|
891
|
+
* settings or an earlier extension are skipped loudly — silent shadowing
|
|
892
|
+
* would make updates route to the wrong owner.
|
|
893
|
+
*/
|
|
894
|
+
collectAgentSettingsExtensions() {
|
|
895
|
+
const result = new Map();
|
|
896
|
+
const taken = new Set(AgentFramework.AGENT_SETTINGS_CORE_KEYS);
|
|
897
|
+
for (const module of this.moduleRegistry.getAllModules()) {
|
|
898
|
+
const ext = module.getAgentSettingsExtension?.();
|
|
899
|
+
if (!ext)
|
|
900
|
+
continue;
|
|
901
|
+
const collision = ext.keys.find((k) => taken.has(k));
|
|
902
|
+
if (collision) {
|
|
903
|
+
console.error(`[agent-settings] extension from module '${module.name}' skipped: key '${collision}' already taken`);
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
ext.keys.forEach((k) => taken.add(k));
|
|
907
|
+
result.set(module.name, ext);
|
|
757
908
|
}
|
|
758
|
-
return
|
|
909
|
+
return result;
|
|
910
|
+
}
|
|
911
|
+
captureInferenceToolSnapshot(agent) {
|
|
912
|
+
return {
|
|
913
|
+
sameRoundThinkTextPolicy: agent.getEffectiveSameRoundThinkTextPolicy(),
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
buildThinkTool(policy) {
|
|
917
|
+
return {
|
|
918
|
+
name: 'think',
|
|
919
|
+
description: 'Reason privately. The think({content}) argument stays in your own context and is NOT sent ' +
|
|
920
|
+
'to channels or other surfaces. ' +
|
|
921
|
+
(policy === 'private'
|
|
922
|
+
? 'Your current same_round_think_text_policy is private, so ordinary text emitted in the SAME native assistant round as think() is withheld from channel routing. Later rounds without think() route normally.'
|
|
923
|
+
: 'Your current same_round_think_text_policy is public, so ordinary text emitted in the SAME native assistant round as think() may still be routed publicly as your speech. Later rounds without think() route normally.') +
|
|
924
|
+
' Use think() to work things out privately. To inspect or change this policy, use agent_settings get/update on same_round_think_text_policy. To deliberately not reply this turn, call skip_reply instead.',
|
|
925
|
+
inputSchema: {
|
|
926
|
+
type: 'object',
|
|
927
|
+
properties: {
|
|
928
|
+
content: {
|
|
929
|
+
type: 'string',
|
|
930
|
+
description: 'Your private thought / reasoning (optional; not sent anywhere).',
|
|
931
|
+
},
|
|
932
|
+
},
|
|
933
|
+
required: [],
|
|
934
|
+
},
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
/** agent_settings tool definition with module extension fields merged in. */
|
|
938
|
+
buildAgentSettingsTool() {
|
|
939
|
+
const base = AgentFramework.AGENT_SETTINGS_TOOL;
|
|
940
|
+
const extensions = this.collectAgentSettingsExtensions();
|
|
941
|
+
if (extensions.size === 0)
|
|
942
|
+
return base;
|
|
943
|
+
const baseSchema = base.inputSchema;
|
|
944
|
+
const extProperties = {};
|
|
945
|
+
const extKeys = [];
|
|
946
|
+
for (const ext of extensions.values()) {
|
|
947
|
+
Object.assign(extProperties, ext.properties);
|
|
948
|
+
extKeys.push(...ext.keys);
|
|
949
|
+
}
|
|
950
|
+
const settingsProp = baseSchema.properties.settings;
|
|
951
|
+
return {
|
|
952
|
+
...base,
|
|
953
|
+
description: base.description +
|
|
954
|
+
` Additional host-managed settings also live here: ${extKeys.join(', ')}.`,
|
|
955
|
+
inputSchema: {
|
|
956
|
+
...baseSchema,
|
|
957
|
+
properties: {
|
|
958
|
+
...baseSchema.properties,
|
|
959
|
+
...extProperties,
|
|
960
|
+
settings: {
|
|
961
|
+
...baseSchema.properties.settings,
|
|
962
|
+
items: {
|
|
963
|
+
...settingsProp.items,
|
|
964
|
+
enum: [...(settingsProp.items?.enum ?? []), ...extKeys],
|
|
965
|
+
},
|
|
966
|
+
},
|
|
967
|
+
},
|
|
968
|
+
},
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
getToolsForAgent(agentName, snapshot) {
|
|
972
|
+
return this.getAllTools().map((tool) => {
|
|
973
|
+
if (tool.name === 'think') {
|
|
974
|
+
return this.buildThinkTool(snapshot?.sameRoundThinkTextPolicy
|
|
975
|
+
?? this.getAgentRuntimeSettings(agentName).sameRoundThinkTextPolicy);
|
|
976
|
+
}
|
|
977
|
+
return tool;
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
getAgentRuntimeSettings(agentName) {
|
|
981
|
+
const agent = this.agents.get(agentName);
|
|
982
|
+
if (!agent)
|
|
983
|
+
throw new Error(`Unknown agent: ${agentName}`);
|
|
984
|
+
return agent.getRuntimeSettings();
|
|
985
|
+
}
|
|
986
|
+
updateAgentRuntimeSettings(agentName, patch) {
|
|
987
|
+
const agent = this.agents.get(agentName);
|
|
988
|
+
if (!agent)
|
|
989
|
+
throw new Error(`Unknown agent: ${agentName}`);
|
|
990
|
+
const result = agent.updateRuntimeSettings(patch);
|
|
991
|
+
this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
|
|
992
|
+
return result;
|
|
993
|
+
}
|
|
994
|
+
resetAgentRuntimeSettings(agentName, keys) {
|
|
995
|
+
const agent = this.agents.get(agentName);
|
|
996
|
+
if (!agent)
|
|
997
|
+
throw new Error(`Unknown agent: ${agentName}`);
|
|
998
|
+
const result = agent.resetRuntimeSettings(keys);
|
|
999
|
+
this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
|
|
1000
|
+
return result;
|
|
1001
|
+
}
|
|
1002
|
+
cancelAgentRuntimeSettingsTransition(agentName) {
|
|
1003
|
+
const agent = this.agents.get(agentName);
|
|
1004
|
+
if (!agent)
|
|
1005
|
+
throw new Error(`Unknown agent: ${agentName}`);
|
|
1006
|
+
const result = agent.cancelRuntimeSettingsTransition();
|
|
1007
|
+
this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
|
|
1008
|
+
return result;
|
|
759
1009
|
}
|
|
760
1010
|
/** Counts-only context-maintenance diagnostics for authenticated debug UIs. */
|
|
761
1011
|
getContextMaintenanceSnapshot() {
|
|
@@ -808,7 +1058,7 @@ export class AgentFramework {
|
|
|
808
1058
|
if (!agent) {
|
|
809
1059
|
throw new Error(`Agent not found: ${agentName}`);
|
|
810
1060
|
}
|
|
811
|
-
const tools = this.
|
|
1061
|
+
const tools = this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name));
|
|
812
1062
|
// Default: no dynamic injection gathering → fully transparent (no
|
|
813
1063
|
// inference, no Chronicle writes, no external RPC). Opt in explicitly.
|
|
814
1064
|
if (!opts?.injections) {
|
|
@@ -932,6 +1182,37 @@ export class AgentFramework {
|
|
|
932
1182
|
// Non-fatal — usage tracking is best-effort
|
|
933
1183
|
}
|
|
934
1184
|
}
|
|
1185
|
+
readAgentRuntimeSettings(agentName) {
|
|
1186
|
+
try {
|
|
1187
|
+
const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
|
|
1188
|
+
const stored = data?.agentRuntimeSettings?.[agentName];
|
|
1189
|
+
return stored ? { ...stored } : null;
|
|
1190
|
+
}
|
|
1191
|
+
catch {
|
|
1192
|
+
return null;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
validatePersistedAgentRuntimeSettings(agentName, overrides) {
|
|
1196
|
+
if (overrides.sameRoundThinkTextPolicy !== undefined &&
|
|
1197
|
+
overrides.sameRoundThinkTextPolicy !== 'public' &&
|
|
1198
|
+
overrides.sameRoundThinkTextPolicy !== 'private') {
|
|
1199
|
+
throw new Error(`Invalid persisted sameRoundThinkTextPolicy for agent "${agentName}": ${JSON.stringify(overrides.sameRoundThinkTextPolicy)}`);
|
|
1200
|
+
}
|
|
1201
|
+
return overrides;
|
|
1202
|
+
}
|
|
1203
|
+
persistAgentRuntimeSettings(agentName, overrides) {
|
|
1204
|
+
const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
|
|
1205
|
+
const state = (data && typeof data === 'object' ? data : {});
|
|
1206
|
+
const all = {
|
|
1207
|
+
...(state.agentRuntimeSettings ?? {}),
|
|
1208
|
+
};
|
|
1209
|
+
if (Object.keys(overrides).length === 0)
|
|
1210
|
+
delete all[agentName];
|
|
1211
|
+
else
|
|
1212
|
+
all[agentName] = { ...overrides };
|
|
1213
|
+
state.agentRuntimeSettings = all;
|
|
1214
|
+
this.store.setStateJson(FRAMEWORK_STATE_ID, state);
|
|
1215
|
+
}
|
|
935
1216
|
/**
|
|
936
1217
|
* Create an ephemeral agent that is NOT registered in the main event loop.
|
|
937
1218
|
*
|
|
@@ -970,6 +1251,1091 @@ export class AgentFramework {
|
|
|
970
1251
|
});
|
|
971
1252
|
return { promise, resolve, reject };
|
|
972
1253
|
}
|
|
1254
|
+
hashJson(value) {
|
|
1255
|
+
return createHash('sha256').update(canonicalJsonStringify(value), 'utf8').digest('hex');
|
|
1256
|
+
}
|
|
1257
|
+
estimateStringTokens(text, overhead = 0) {
|
|
1258
|
+
return Math.ceil(Buffer.byteLength(text, 'utf8') / 4) + overhead;
|
|
1259
|
+
}
|
|
1260
|
+
estimateUnknownTokens(value, overhead = 0) {
|
|
1261
|
+
return this.estimateStringTokens(JSON.stringify(value) ?? 'null', overhead);
|
|
1262
|
+
}
|
|
1263
|
+
estimateRequestInputBoundTokens(request) {
|
|
1264
|
+
let total = 96;
|
|
1265
|
+
total += this.estimateStringTokens(request.system ?? '', 48);
|
|
1266
|
+
for (const message of request.messages) {
|
|
1267
|
+
total += 24;
|
|
1268
|
+
total += this.estimateStringTokens(message.participant, 8);
|
|
1269
|
+
if (message.cacheBreakpoint)
|
|
1270
|
+
total += 8;
|
|
1271
|
+
for (const block of message.content) {
|
|
1272
|
+
switch (block.type) {
|
|
1273
|
+
case 'text':
|
|
1274
|
+
total += this.estimateStringTokens(block.text, 24);
|
|
1275
|
+
break;
|
|
1276
|
+
case 'thinking':
|
|
1277
|
+
total += this.estimateStringTokens(block.thinking, 32);
|
|
1278
|
+
total += block.signature ? this.estimateStringTokens(block.signature, 8) : 0;
|
|
1279
|
+
break;
|
|
1280
|
+
case 'redacted_thinking':
|
|
1281
|
+
total += this.estimateUnknownTokens(block, 32);
|
|
1282
|
+
break;
|
|
1283
|
+
default:
|
|
1284
|
+
total += this.estimateUnknownTokens(block, 32);
|
|
1285
|
+
break;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
if (request.tools && request.tools.length > 0) {
|
|
1290
|
+
total += 32;
|
|
1291
|
+
for (const tool of request.tools) {
|
|
1292
|
+
total += this.estimateUnknownTokens({
|
|
1293
|
+
name: tool.name,
|
|
1294
|
+
description: tool.description,
|
|
1295
|
+
inputSchema: tool.inputSchema,
|
|
1296
|
+
}, 48);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
total += this.estimateUnknownTokens(request.config, 48);
|
|
1300
|
+
if (request.providerParams !== undefined) {
|
|
1301
|
+
total += this.estimateUnknownTokens(request.providerParams, 32);
|
|
1302
|
+
}
|
|
1303
|
+
total += request.promptCaching ? 16 : 0;
|
|
1304
|
+
total += request.cacheTtl ? this.estimateStringTokens(request.cacheTtl, 8) : 0;
|
|
1305
|
+
total += request.assistantParticipant
|
|
1306
|
+
? this.estimateStringTokens(request.assistantParticipant, 8)
|
|
1307
|
+
: 0;
|
|
1308
|
+
return total;
|
|
1309
|
+
}
|
|
1310
|
+
estimateRequestCompleteBoundTokens(request) {
|
|
1311
|
+
const reserve = typeof request.config.maxTokens === 'number' && Number.isFinite(request.config.maxTokens)
|
|
1312
|
+
? request.config.maxTokens
|
|
1313
|
+
: 0;
|
|
1314
|
+
return this.estimateRequestInputBoundTokens(request) + reserve + 96;
|
|
1315
|
+
}
|
|
1316
|
+
primarySummaryAdmissionBoundTokens(originalRequestInputTokens, expandedRequest, originalProviderInputTokens) {
|
|
1317
|
+
const expandedInput = this.estimateRequestInputBoundTokens(expandedRequest);
|
|
1318
|
+
const expandedComplete = this.estimateRequestCompleteBoundTokens(expandedRequest);
|
|
1319
|
+
if (originalProviderInputTokens === undefined)
|
|
1320
|
+
return expandedComplete;
|
|
1321
|
+
const reserve = typeof expandedRequest.config.maxTokens === 'number' && Number.isFinite(expandedRequest.config.maxTokens)
|
|
1322
|
+
? expandedRequest.config.maxTokens
|
|
1323
|
+
: 0;
|
|
1324
|
+
const positiveDelta = Math.max(0, expandedInput - originalRequestInputTokens);
|
|
1325
|
+
return Math.max(expandedComplete, originalProviderInputTokens + positiveDelta + reserve + 96);
|
|
1326
|
+
}
|
|
1327
|
+
readPrimarySummaryFallbackState() {
|
|
1328
|
+
const raw = this.store
|
|
1329
|
+
?.getStateJson?.(PRIMARY_SUMMARY_FALLBACK_STATE_ID);
|
|
1330
|
+
if (!raw || typeof raw !== 'object')
|
|
1331
|
+
return { requests: [] };
|
|
1332
|
+
const requests = Array.isArray(raw.requests)
|
|
1333
|
+
? (raw.requests)
|
|
1334
|
+
: [];
|
|
1335
|
+
return { requests };
|
|
1336
|
+
}
|
|
1337
|
+
writePrimarySummaryFallbackState(state) {
|
|
1338
|
+
this.store.setStateJson(PRIMARY_SUMMARY_FALLBACK_STATE_ID, state);
|
|
1339
|
+
}
|
|
1340
|
+
async withPrimarySummaryStateLock(work) {
|
|
1341
|
+
const previous = this.primarySummaryStateLock;
|
|
1342
|
+
let release;
|
|
1343
|
+
this.primarySummaryStateLock = new Promise((resolve) => { release = resolve; });
|
|
1344
|
+
await previous;
|
|
1345
|
+
try {
|
|
1346
|
+
return await work();
|
|
1347
|
+
}
|
|
1348
|
+
finally {
|
|
1349
|
+
release();
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
async reconcilePrimarySummaryFallbackStateOnStartup() {
|
|
1353
|
+
const alerts = [];
|
|
1354
|
+
const unsettledRequests = (await this.withPrimarySummaryStateLock(() => this.readPrimarySummaryFallbackState().requests
|
|
1355
|
+
.filter((record) => record.finalStatus === 'pending'
|
|
1356
|
+
|| record.finalStatus === 'refusal'
|
|
1357
|
+
|| record.fallbackStatus === 'pending')
|
|
1358
|
+
.map((record) => structuredClone(record))));
|
|
1359
|
+
const resolvedRequestIds = new Set();
|
|
1360
|
+
for (const record of unsettledRequests) {
|
|
1361
|
+
const result = await this.finalizePrimarySummarySettlementOnStartup(record);
|
|
1362
|
+
if (result === 'finalized') {
|
|
1363
|
+
resolvedRequestIds.add(record.requestId);
|
|
1364
|
+
continue;
|
|
1365
|
+
}
|
|
1366
|
+
if (result && typeof result === 'object' && 'invalidReason' in result) {
|
|
1367
|
+
resolvedRequestIds.add(record.requestId);
|
|
1368
|
+
alerts.push({ agentName: record.agentName, requestId: record.requestId, reason: result.invalidReason });
|
|
1369
|
+
await this.markPrimarySummaryRequestRestartFailure(record, result.invalidReason);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
await this.withPrimarySummaryStateLock(() => {
|
|
1373
|
+
const state = this.readPrimarySummaryFallbackState();
|
|
1374
|
+
let changed = false;
|
|
1375
|
+
state.requests = state.requests.map((record) => {
|
|
1376
|
+
if (resolvedRequestIds.has(record.requestId))
|
|
1377
|
+
return record;
|
|
1378
|
+
if (record.dispatchKind === 'primary_summary_fallback_retry' && (record.finalStatus === 'pending' || record.finalStatus === 'refusal')) {
|
|
1379
|
+
changed = true;
|
|
1380
|
+
const reason = 'primary_summary_fallback_unresolved_on_restart';
|
|
1381
|
+
alerts.push({ agentName: record.agentName, requestId: record.requestId, reason });
|
|
1382
|
+
return {
|
|
1383
|
+
...record,
|
|
1384
|
+
finalStatus: 'held',
|
|
1385
|
+
fallbackHeldReason: reason,
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
if (record.fallbackIntent && record.fallbackStatus === 'pending') {
|
|
1389
|
+
changed = true;
|
|
1390
|
+
const reason = 'primary_summary_fallback_unresolved_on_restart';
|
|
1391
|
+
alerts.push({ agentName: record.agentName, requestId: record.requestId, reason });
|
|
1392
|
+
return {
|
|
1393
|
+
...record,
|
|
1394
|
+
fallbackStatus: 'held',
|
|
1395
|
+
fallbackHeldReason: reason,
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
if (record.finalStatus === 'refusal') {
|
|
1399
|
+
changed = true;
|
|
1400
|
+
const reason = 'primary_summary_request_unresolved_on_restart';
|
|
1401
|
+
alerts.push({ agentName: record.agentName, requestId: record.requestId, reason });
|
|
1402
|
+
return {
|
|
1403
|
+
...record,
|
|
1404
|
+
finalStatus: 'held',
|
|
1405
|
+
fallbackStatus: 'held',
|
|
1406
|
+
fallbackHeldReason: reason,
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
if (record.finalStatus === 'pending') {
|
|
1410
|
+
changed = true;
|
|
1411
|
+
const reason = 'primary_summary_request_unresolved_on_restart';
|
|
1412
|
+
alerts.push({ agentName: record.agentName, requestId: record.requestId, reason });
|
|
1413
|
+
return {
|
|
1414
|
+
...record,
|
|
1415
|
+
finalStatus: 'timeout_unknown',
|
|
1416
|
+
fallbackHeldReason: reason,
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
return record;
|
|
1420
|
+
});
|
|
1421
|
+
if (changed)
|
|
1422
|
+
this.writePrimarySummaryFallbackState({ requests: state.requests.slice(-256) });
|
|
1423
|
+
});
|
|
1424
|
+
for (const alert of alerts) {
|
|
1425
|
+
this.opsAlert('refusal-held', alert.agentName, alert.reason, {
|
|
1426
|
+
data: { requestId: alert.requestId },
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
primarySummaryIdentityKey(identity) {
|
|
1431
|
+
return [
|
|
1432
|
+
identity.id,
|
|
1433
|
+
identity.contentHash,
|
|
1434
|
+
identity.carrierHash,
|
|
1435
|
+
identity.sourceLeafHash,
|
|
1436
|
+
].join('\u0000');
|
|
1437
|
+
}
|
|
1438
|
+
primarySummaryProjectionKeys(projection) {
|
|
1439
|
+
return projection?.selectedSummaries.map((selection) => this.primarySummaryIdentityKey(selection.identity)) ?? [];
|
|
1440
|
+
}
|
|
1441
|
+
primarySummaryFallbackConfig(agent) {
|
|
1442
|
+
const raw = agent.refusalHandling?.primarySummaryFallback;
|
|
1443
|
+
const fallbackBudgetBase = agent.contextBudgetTokens ?? 100_000;
|
|
1444
|
+
return {
|
|
1445
|
+
enabled: raw?.enabled === true,
|
|
1446
|
+
maxNewSummaries: typeof raw?.maxNewSummaries === 'number' && Number.isSafeInteger(raw.maxNewSummaries)
|
|
1447
|
+
? Math.max(0, raw.maxNewSummaries)
|
|
1448
|
+
: 4,
|
|
1449
|
+
requestBudgetTokens: typeof raw?.requestBudgetTokens === 'number' && Number.isFinite(raw.requestBudgetTokens) && raw.requestBudgetTokens > 0
|
|
1450
|
+
? raw.requestBudgetTokens
|
|
1451
|
+
: fallbackBudgetBase + agent.maxTokens,
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
latestCompatibleHealthyPrimaryRequest(current, state) {
|
|
1455
|
+
for (let i = state.requests.length - 1; i >= 0; i--) {
|
|
1456
|
+
const candidate = state.requests[i];
|
|
1457
|
+
if (candidate.requestId === current.requestId)
|
|
1458
|
+
continue;
|
|
1459
|
+
if (candidate.agentName !== current.agentName)
|
|
1460
|
+
continue;
|
|
1461
|
+
if (candidate.namespace !== current.namespace)
|
|
1462
|
+
continue;
|
|
1463
|
+
if (candidate.branch.id !== current.branch.id)
|
|
1464
|
+
continue;
|
|
1465
|
+
if (candidate.branch.generation !== current.branch.generation)
|
|
1466
|
+
continue;
|
|
1467
|
+
if (candidate.systemHash !== current.systemHash)
|
|
1468
|
+
continue;
|
|
1469
|
+
if (candidate.modelConfigHash !== current.modelConfigHash)
|
|
1470
|
+
continue;
|
|
1471
|
+
if (candidate.toolContractHash !== current.toolContractHash)
|
|
1472
|
+
continue;
|
|
1473
|
+
if (candidate.dispatchKind !== 'primary')
|
|
1474
|
+
continue;
|
|
1475
|
+
if (candidate.fallbackIntent || candidate.fallbackStatus)
|
|
1476
|
+
continue;
|
|
1477
|
+
if (candidate.finalStatus !== 'success')
|
|
1478
|
+
continue;
|
|
1479
|
+
return candidate;
|
|
1480
|
+
}
|
|
1481
|
+
return null;
|
|
1482
|
+
}
|
|
1483
|
+
async persistPrimarySummaryRequestRecord(record) {
|
|
1484
|
+
await this.withPrimarySummaryStateLock(() => {
|
|
1485
|
+
const state = this.readPrimarySummaryFallbackState();
|
|
1486
|
+
const requests = state.requests.filter((entry) => entry.requestId !== record.requestId);
|
|
1487
|
+
requests.push(record);
|
|
1488
|
+
this.writePrimarySummaryFallbackState({ requests: requests.slice(-256) });
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
async updatePrimarySummaryRequestRecord(requestId, update) {
|
|
1492
|
+
return this.withPrimarySummaryStateLock(() => {
|
|
1493
|
+
const state = this.readPrimarySummaryFallbackState();
|
|
1494
|
+
const index = state.requests.findIndex((entry) => entry.requestId === requestId);
|
|
1495
|
+
if (index < 0)
|
|
1496
|
+
return null;
|
|
1497
|
+
state.requests[index] = update(state.requests[index]);
|
|
1498
|
+
this.writePrimarySummaryFallbackState({ requests: state.requests.slice(-256) });
|
|
1499
|
+
return state.requests[index];
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
async getPrimarySummaryRequestRecord(requestId) {
|
|
1503
|
+
return this.withPrimarySummaryStateLock(() => {
|
|
1504
|
+
const state = this.readPrimarySummaryFallbackState();
|
|
1505
|
+
return state.requests.find((entry) => entry.requestId === requestId) ?? null;
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
primarySummaryContractsMatch(left, right) {
|
|
1509
|
+
return left.systemHash === right.systemHash
|
|
1510
|
+
&& left.modelConfigHash === right.modelConfigHash
|
|
1511
|
+
&& left.toolContractHash === right.toolContractHash;
|
|
1512
|
+
}
|
|
1513
|
+
primarySummaryFallbackOutputPhaseHash(blocks) {
|
|
1514
|
+
return this.hashJson(blocks);
|
|
1515
|
+
}
|
|
1516
|
+
primarySummaryFallbackBlockTypes(blocks) {
|
|
1517
|
+
return blocks.map((block) => block.type);
|
|
1518
|
+
}
|
|
1519
|
+
static NON_EXECUTED_TOOL_RESULT_TEXT = '[tool not executed]';
|
|
1520
|
+
buildNonExecutedToolResults(blocks) {
|
|
1521
|
+
const seenToolUseIds = new Set();
|
|
1522
|
+
return blocks
|
|
1523
|
+
.filter((block) => block.type === 'tool_use')
|
|
1524
|
+
.filter((block) => {
|
|
1525
|
+
if (seenToolUseIds.has(block.id))
|
|
1526
|
+
return false;
|
|
1527
|
+
seenToolUseIds.add(block.id);
|
|
1528
|
+
return true;
|
|
1529
|
+
})
|
|
1530
|
+
.map((block) => ({
|
|
1531
|
+
type: 'tool_result',
|
|
1532
|
+
toolUseId: block.id,
|
|
1533
|
+
content: AgentFramework.NON_EXECUTED_TOOL_RESULT_TEXT,
|
|
1534
|
+
isError: true,
|
|
1535
|
+
}));
|
|
1536
|
+
}
|
|
1537
|
+
buildPrimarySummarySettlementId(record, outcome) {
|
|
1538
|
+
return `${record.dispatchKind}:${outcome}:terminal:v${PRIMARY_SUMMARY_SETTLEMENT_VERSION}`;
|
|
1539
|
+
}
|
|
1540
|
+
buildPrimarySummarySettlementMetadata(record, options) {
|
|
1541
|
+
const settlementId = options.settlementId ?? this.buildPrimarySummarySettlementId(record, options.outcome);
|
|
1542
|
+
return {
|
|
1543
|
+
[PRIMARY_SUMMARY_SETTLEMENT_METADATA_KEY]: {
|
|
1544
|
+
version: PRIMARY_SUMMARY_SETTLEMENT_VERSION,
|
|
1545
|
+
requestId: record.requestId,
|
|
1546
|
+
settlementId,
|
|
1547
|
+
agentName: record.agentName,
|
|
1548
|
+
dispatchKind: record.dispatchKind,
|
|
1549
|
+
outcome: options.outcome,
|
|
1550
|
+
...(options.stopReason ? { stopReason: options.stopReason } : {}),
|
|
1551
|
+
...(options.holdReason ? { holdReason: options.holdReason } : {}),
|
|
1552
|
+
...(options.providerInputTokens !== undefined
|
|
1553
|
+
? { providerInputTokens: options.providerInputTokens }
|
|
1554
|
+
: {}),
|
|
1555
|
+
visibleAssistantOutput: options.visibleAssistantOutput,
|
|
1556
|
+
executedToolCalls: options.executedToolCalls,
|
|
1557
|
+
branch: {
|
|
1558
|
+
id: record.branch.id,
|
|
1559
|
+
name: record.branch.name,
|
|
1560
|
+
generation: record.branch.generation,
|
|
1561
|
+
},
|
|
1562
|
+
role: options.role,
|
|
1563
|
+
kind: options.kind,
|
|
1564
|
+
entryIndex: options.entryIndex,
|
|
1565
|
+
entryCount: options.entryCount,
|
|
1566
|
+
},
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
isBoundedNonEmptyString(value, maxLength) {
|
|
1570
|
+
return typeof value === 'string' && value.length > 0 && value.length <= maxLength;
|
|
1571
|
+
}
|
|
1572
|
+
isSafeNonNegativeInteger(value) {
|
|
1573
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
1574
|
+
}
|
|
1575
|
+
primarySummarySettlementRequestId(message) {
|
|
1576
|
+
const raw = message.metadata?.[PRIMARY_SUMMARY_SETTLEMENT_METADATA_KEY];
|
|
1577
|
+
if (!raw || typeof raw !== 'object')
|
|
1578
|
+
return null;
|
|
1579
|
+
const requestId = raw.requestId;
|
|
1580
|
+
return typeof requestId === 'string' ? requestId : null;
|
|
1581
|
+
}
|
|
1582
|
+
parsePrimarySummarySettlementMetadata(message) {
|
|
1583
|
+
const raw = message.metadata?.[PRIMARY_SUMMARY_SETTLEMENT_METADATA_KEY];
|
|
1584
|
+
if (!raw || typeof raw !== 'object')
|
|
1585
|
+
return null;
|
|
1586
|
+
const candidate = raw;
|
|
1587
|
+
if (candidate.version !== PRIMARY_SUMMARY_SETTLEMENT_VERSION ||
|
|
1588
|
+
!this.isBoundedNonEmptyString(candidate.requestId, PRIMARY_SUMMARY_SETTLEMENT_MAX_ID_LENGTH) ||
|
|
1589
|
+
!this.isBoundedNonEmptyString(candidate.settlementId, PRIMARY_SUMMARY_SETTLEMENT_MAX_ID_LENGTH) ||
|
|
1590
|
+
!this.isBoundedNonEmptyString(candidate.agentName, PRIMARY_SUMMARY_SETTLEMENT_MAX_NAME_LENGTH) ||
|
|
1591
|
+
(candidate.dispatchKind !== 'primary' && candidate.dispatchKind !== 'primary_summary_fallback_retry') ||
|
|
1592
|
+
(candidate.outcome !== 'success' && candidate.outcome !== 'held') ||
|
|
1593
|
+
typeof candidate.visibleAssistantOutput !== 'boolean' ||
|
|
1594
|
+
!this.isSafeNonNegativeInteger(candidate.executedToolCalls) ||
|
|
1595
|
+
!this.isSafeNonNegativeInteger(candidate.entryIndex) ||
|
|
1596
|
+
!this.isSafeNonNegativeInteger(candidate.entryCount) ||
|
|
1597
|
+
(candidate.role !== 'assistant' && candidate.role !== 'user') ||
|
|
1598
|
+
(candidate.kind !== 'assistant_output' && candidate.kind !== 'generated_tool_result') ||
|
|
1599
|
+
!candidate.branch ||
|
|
1600
|
+
!this.isBoundedNonEmptyString(candidate.branch.id, PRIMARY_SUMMARY_SETTLEMENT_MAX_ID_LENGTH) ||
|
|
1601
|
+
!this.isBoundedNonEmptyString(candidate.branch.name, PRIMARY_SUMMARY_SETTLEMENT_MAX_NAME_LENGTH) ||
|
|
1602
|
+
!this.isSafeNonNegativeInteger(candidate.branch.generation) ||
|
|
1603
|
+
candidate.entryCount < 1 ||
|
|
1604
|
+
candidate.entryIndex >= candidate.entryCount ||
|
|
1605
|
+
(candidate.stopReason !== undefined
|
|
1606
|
+
&& !this.isBoundedNonEmptyString(candidate.stopReason, PRIMARY_SUMMARY_SETTLEMENT_MAX_REASON_LENGTH)) ||
|
|
1607
|
+
(candidate.holdReason !== undefined
|
|
1608
|
+
&& !this.isBoundedNonEmptyString(candidate.holdReason, PRIMARY_SUMMARY_SETTLEMENT_MAX_REASON_LENGTH)) ||
|
|
1609
|
+
(candidate.providerInputTokens !== undefined
|
|
1610
|
+
&& !this.isSafeNonNegativeInteger(candidate.providerInputTokens)) ||
|
|
1611
|
+
(candidate.outcome === 'held' && candidate.holdReason === undefined) ||
|
|
1612
|
+
(candidate.outcome === 'success' && candidate.holdReason !== undefined)) {
|
|
1613
|
+
return { invalidReason: 'primary_summary_settlement_metadata_invalid_on_restart' };
|
|
1614
|
+
}
|
|
1615
|
+
return candidate;
|
|
1616
|
+
}
|
|
1617
|
+
primarySummarySettlementMetadataConsistent(left, right) {
|
|
1618
|
+
return left.version === right.version
|
|
1619
|
+
&& left.requestId === right.requestId
|
|
1620
|
+
&& left.settlementId === right.settlementId
|
|
1621
|
+
&& left.agentName === right.agentName
|
|
1622
|
+
&& left.dispatchKind === right.dispatchKind
|
|
1623
|
+
&& left.outcome === right.outcome
|
|
1624
|
+
&& left.stopReason === right.stopReason
|
|
1625
|
+
&& left.holdReason === right.holdReason
|
|
1626
|
+
&& left.providerInputTokens === right.providerInputTokens
|
|
1627
|
+
&& left.visibleAssistantOutput === right.visibleAssistantOutput
|
|
1628
|
+
&& left.executedToolCalls === right.executedToolCalls
|
|
1629
|
+
&& left.branch.id === right.branch.id
|
|
1630
|
+
&& left.branch.name === right.branch.name
|
|
1631
|
+
&& left.branch.generation === right.branch.generation
|
|
1632
|
+
&& left.entryCount === right.entryCount;
|
|
1633
|
+
}
|
|
1634
|
+
primarySummaryShardEnvelopeConsistent(participant, left, candidateParticipant, right) {
|
|
1635
|
+
return participant === candidateParticipant
|
|
1636
|
+
&& this.primarySummarySettlementMetadataConsistent(left, right)
|
|
1637
|
+
&& left.role === right.role
|
|
1638
|
+
&& left.kind === right.kind
|
|
1639
|
+
&& left.entryIndex === right.entryIndex;
|
|
1640
|
+
}
|
|
1641
|
+
primarySummaryLogicalSettlementEntries(agent, requestId) {
|
|
1642
|
+
const allMessages = agent.getContextManager().getAllMessages()
|
|
1643
|
+
.slice()
|
|
1644
|
+
.sort((left, right) => left.sequence - right.sequence);
|
|
1645
|
+
const entries = [];
|
|
1646
|
+
for (let index = 0; index < allMessages.length;) {
|
|
1647
|
+
const message = allMessages[index];
|
|
1648
|
+
if (this.primarySummarySettlementRequestId(message) !== requestId) {
|
|
1649
|
+
index++;
|
|
1650
|
+
continue;
|
|
1651
|
+
}
|
|
1652
|
+
const parsed = this.parsePrimarySummarySettlementMetadata(message);
|
|
1653
|
+
if (!parsed) {
|
|
1654
|
+
index++;
|
|
1655
|
+
continue;
|
|
1656
|
+
}
|
|
1657
|
+
if ('invalidReason' in parsed)
|
|
1658
|
+
return parsed;
|
|
1659
|
+
if (message.bodyGroupId === undefined) {
|
|
1660
|
+
if (message.shardIndex !== undefined) {
|
|
1661
|
+
return { invalidReason: 'primary_summary_settlement_shard_group_invalid_on_restart' };
|
|
1662
|
+
}
|
|
1663
|
+
entries.push({
|
|
1664
|
+
messages: [message],
|
|
1665
|
+
settlement: parsed,
|
|
1666
|
+
participant: message.participant,
|
|
1667
|
+
content: message.content,
|
|
1668
|
+
});
|
|
1669
|
+
index++;
|
|
1670
|
+
continue;
|
|
1671
|
+
}
|
|
1672
|
+
const groupId = message.bodyGroupId;
|
|
1673
|
+
if (!this.isBoundedNonEmptyString(groupId, PRIMARY_SUMMARY_SETTLEMENT_MAX_ID_LENGTH)) {
|
|
1674
|
+
return { invalidReason: 'primary_summary_settlement_shard_group_invalid_on_restart' };
|
|
1675
|
+
}
|
|
1676
|
+
if (index > 0 && allMessages[index - 1]?.bodyGroupId === groupId) {
|
|
1677
|
+
return { invalidReason: 'primary_summary_settlement_shard_group_invalid_on_restart' };
|
|
1678
|
+
}
|
|
1679
|
+
const groupMessages = [];
|
|
1680
|
+
while (index < allMessages.length && allMessages[index].bodyGroupId === groupId) {
|
|
1681
|
+
groupMessages.push(allMessages[index]);
|
|
1682
|
+
index++;
|
|
1683
|
+
}
|
|
1684
|
+
if (allMessages.slice(index).some((candidate) => candidate.bodyGroupId === groupId)) {
|
|
1685
|
+
return { invalidReason: 'primary_summary_settlement_shard_group_invalid_on_restart' };
|
|
1686
|
+
}
|
|
1687
|
+
const orderedGroupMessages = [];
|
|
1688
|
+
let expectedShardIndex = 0;
|
|
1689
|
+
for (const groupMessage of groupMessages) {
|
|
1690
|
+
if (this.primarySummarySettlementRequestId(groupMessage) !== requestId) {
|
|
1691
|
+
return { invalidReason: 'primary_summary_settlement_shard_group_invalid_on_restart' };
|
|
1692
|
+
}
|
|
1693
|
+
const groupParsed = this.parsePrimarySummarySettlementMetadata(groupMessage);
|
|
1694
|
+
if (!groupParsed) {
|
|
1695
|
+
return { invalidReason: 'primary_summary_settlement_shard_group_invalid_on_restart' };
|
|
1696
|
+
}
|
|
1697
|
+
if ('invalidReason' in groupParsed)
|
|
1698
|
+
return groupParsed;
|
|
1699
|
+
if (!this.primarySummaryShardEnvelopeConsistent(message.participant, parsed, groupMessage.participant, groupParsed)) {
|
|
1700
|
+
return { invalidReason: 'primary_summary_settlement_shard_group_invalid_on_restart' };
|
|
1701
|
+
}
|
|
1702
|
+
if (groupMessage.shardIndex !== expectedShardIndex) {
|
|
1703
|
+
return { invalidReason: 'primary_summary_settlement_shard_group_invalid_on_restart' };
|
|
1704
|
+
}
|
|
1705
|
+
orderedGroupMessages.push(groupMessage);
|
|
1706
|
+
expectedShardIndex++;
|
|
1707
|
+
}
|
|
1708
|
+
entries.push({
|
|
1709
|
+
messages: orderedGroupMessages,
|
|
1710
|
+
settlement: parsed,
|
|
1711
|
+
participant: message.participant,
|
|
1712
|
+
content: orderedGroupMessages.flatMap((groupMessage) => groupMessage.content),
|
|
1713
|
+
bodyGroupId: groupId,
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
return entries;
|
|
1717
|
+
}
|
|
1718
|
+
primarySummaryContentMatches(left, right) {
|
|
1719
|
+
return this.primarySummaryFallbackOutputPhaseHash(left) === this.primarySummaryFallbackOutputPhaseHash(right);
|
|
1720
|
+
}
|
|
1721
|
+
async persistPrimarySummaryChronicleEntries(agent, entries) {
|
|
1722
|
+
const messageIds = [];
|
|
1723
|
+
for (const entry of entries) {
|
|
1724
|
+
if (entry.content.length === 0)
|
|
1725
|
+
continue;
|
|
1726
|
+
messageIds.push(agent.getContextManager().addMessage(entry.participant, entry.content, entry.metadata));
|
|
1727
|
+
}
|
|
1728
|
+
return messageIds;
|
|
1729
|
+
}
|
|
1730
|
+
async markPrimarySummaryRequestRestartFailure(record, reason) {
|
|
1731
|
+
if (record.dispatchKind === 'primary_summary_fallback_retry') {
|
|
1732
|
+
const parent = await this.findPrimarySummaryRetryParent(record.requestId);
|
|
1733
|
+
await this.updatePrimarySummaryRequestRecord(record.requestId, (entry) => ({
|
|
1734
|
+
...entry,
|
|
1735
|
+
finalStatus: 'held',
|
|
1736
|
+
fallbackHeldReason: reason,
|
|
1737
|
+
}));
|
|
1738
|
+
if (parent) {
|
|
1739
|
+
await this.updatePrimarySummaryRequestRecord(parent.originalRequestId, (entry) => ({
|
|
1740
|
+
...entry,
|
|
1741
|
+
finalStatus: 'held',
|
|
1742
|
+
fallbackStatus: 'held',
|
|
1743
|
+
fallbackHeldReason: reason,
|
|
1744
|
+
}));
|
|
1745
|
+
}
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
await this.updatePrimarySummaryRequestRecord(record.requestId, (current) => ({
|
|
1749
|
+
...current,
|
|
1750
|
+
finalStatus: 'held',
|
|
1751
|
+
fallbackStatus: 'held',
|
|
1752
|
+
fallbackHeldReason: reason,
|
|
1753
|
+
}));
|
|
1754
|
+
}
|
|
1755
|
+
async persistPrimarySummaryHeldOutput(agent, requestId, holdReason, options) {
|
|
1756
|
+
const record = await this.getPrimarySummaryRequestRecord(requestId);
|
|
1757
|
+
if (!record) {
|
|
1758
|
+
throw new Error(`Primary summary request '${requestId}' disappeared before held-output persistence`);
|
|
1759
|
+
}
|
|
1760
|
+
const placeholderBlocks = this.buildNonExecutedToolResults(options.assistantBlocks);
|
|
1761
|
+
const entryCount = placeholderBlocks.length > 0 ? 2 : 1;
|
|
1762
|
+
const assistantMetadata = this.buildPrimarySummarySettlementMetadata(record, {
|
|
1763
|
+
outcome: 'held',
|
|
1764
|
+
stopReason: options.stopReason,
|
|
1765
|
+
holdReason,
|
|
1766
|
+
providerInputTokens: options.providerInputTokens,
|
|
1767
|
+
visibleAssistantOutput: options.visibleAssistantOutput,
|
|
1768
|
+
executedToolCalls: options.executedToolCalls,
|
|
1769
|
+
entryIndex: 0,
|
|
1770
|
+
entryCount,
|
|
1771
|
+
role: 'assistant',
|
|
1772
|
+
kind: 'assistant_output',
|
|
1773
|
+
});
|
|
1774
|
+
const settlementId = assistantMetadata[PRIMARY_SUMMARY_SETTLEMENT_METADATA_KEY].settlementId;
|
|
1775
|
+
const placeholderMetadata = placeholderBlocks.length > 0
|
|
1776
|
+
? this.buildPrimarySummarySettlementMetadata(record, {
|
|
1777
|
+
outcome: 'held',
|
|
1778
|
+
settlementId,
|
|
1779
|
+
stopReason: options.stopReason,
|
|
1780
|
+
holdReason,
|
|
1781
|
+
providerInputTokens: options.providerInputTokens,
|
|
1782
|
+
visibleAssistantOutput: options.visibleAssistantOutput,
|
|
1783
|
+
executedToolCalls: options.executedToolCalls,
|
|
1784
|
+
entryIndex: 1,
|
|
1785
|
+
entryCount,
|
|
1786
|
+
role: 'user',
|
|
1787
|
+
kind: 'generated_tool_result',
|
|
1788
|
+
})
|
|
1789
|
+
: undefined;
|
|
1790
|
+
const messageIds = await this.persistPrimarySummaryChronicleEntries(agent, [
|
|
1791
|
+
{ participant: agent.name, content: options.assistantBlocks, metadata: assistantMetadata },
|
|
1792
|
+
...(placeholderBlocks.length > 0 && placeholderMetadata
|
|
1793
|
+
? [{ participant: 'user', content: placeholderBlocks, metadata: placeholderMetadata }]
|
|
1794
|
+
: []),
|
|
1795
|
+
]);
|
|
1796
|
+
if (options.retryDispatch && options.retryPhase && options.assistantBlocks.length > 0) {
|
|
1797
|
+
await this.recordPrimarySummaryRetryOutput(options.retryDispatch.originalRequestId, options.retryDispatch.intentId, {
|
|
1798
|
+
phase: options.retryPhase,
|
|
1799
|
+
contentHash: this.primarySummaryFallbackOutputPhaseHash(options.assistantBlocks),
|
|
1800
|
+
messageId: messageIds[0] ?? '',
|
|
1801
|
+
blockTypes: this.primarySummaryFallbackBlockTypes(options.assistantBlocks),
|
|
1802
|
+
persistedAt: Date.now(),
|
|
1803
|
+
});
|
|
1804
|
+
}
|
|
1805
|
+
if (options.retryDispatch) {
|
|
1806
|
+
await this.holdPrimarySummaryRetryFamily(agent, requestId, options.retryDispatch, holdReason, options.holdData);
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
await this.holdPrimarySummaryRequest(agent, requestId, holdReason, options.holdData);
|
|
1810
|
+
}
|
|
1811
|
+
async findPrimarySummaryRetryParent(retryRequestId) {
|
|
1812
|
+
return this.withPrimarySummaryStateLock(() => {
|
|
1813
|
+
const state = this.readPrimarySummaryFallbackState();
|
|
1814
|
+
for (const record of state.requests) {
|
|
1815
|
+
const intent = record.fallbackIntent;
|
|
1816
|
+
if (intent?.retryRequestId === retryRequestId) {
|
|
1817
|
+
return { originalRequestId: record.requestId, intentId: intent.intentId };
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
return null;
|
|
1821
|
+
});
|
|
1822
|
+
}
|
|
1823
|
+
async finalizePrimarySummarySettlementOnStartup(record) {
|
|
1824
|
+
const agent = this.agents.get(record.agentName);
|
|
1825
|
+
if (!agent)
|
|
1826
|
+
return false;
|
|
1827
|
+
const settlementEntries = this.primarySummaryLogicalSettlementEntries(agent, record.requestId);
|
|
1828
|
+
if ('invalidReason' in settlementEntries)
|
|
1829
|
+
return settlementEntries;
|
|
1830
|
+
if (settlementEntries.length === 0) {
|
|
1831
|
+
return false;
|
|
1832
|
+
}
|
|
1833
|
+
const currentBranch = agent.getCurrentBranchGeneration();
|
|
1834
|
+
const assistantEntry = settlementEntries[0];
|
|
1835
|
+
const settlement = assistantEntry.settlement;
|
|
1836
|
+
if (assistantEntry.participant !== record.agentName ||
|
|
1837
|
+
settlement.agentName !== record.agentName ||
|
|
1838
|
+
settlement.dispatchKind !== record.dispatchKind ||
|
|
1839
|
+
settlement.entryIndex !== 0 ||
|
|
1840
|
+
settlement.role !== 'assistant' ||
|
|
1841
|
+
settlement.kind !== 'assistant_output') {
|
|
1842
|
+
return { invalidReason: 'primary_summary_settlement_invalid_assistant_marker_on_restart' };
|
|
1843
|
+
}
|
|
1844
|
+
if (!currentBranch) {
|
|
1845
|
+
return { invalidReason: 'primary_summary_settlement_branch_unknown_on_restart' };
|
|
1846
|
+
}
|
|
1847
|
+
if (settlement.branch.id !== record.branch.id ||
|
|
1848
|
+
settlement.branch.name !== record.branch.name ||
|
|
1849
|
+
settlement.branch.generation !== record.branch.generation ||
|
|
1850
|
+
settlement.branch.id !== currentBranch.id ||
|
|
1851
|
+
settlement.branch.name !== currentBranch.name ||
|
|
1852
|
+
settlement.branch.generation !== currentBranch.generation) {
|
|
1853
|
+
return { invalidReason: 'primary_summary_settlement_branch_mismatch_on_restart' };
|
|
1854
|
+
}
|
|
1855
|
+
if (settlement.entryCount < 1 || settlement.entryCount > 2) {
|
|
1856
|
+
return { invalidReason: 'primary_summary_settlement_entry_count_invalid_on_restart' };
|
|
1857
|
+
}
|
|
1858
|
+
const entriesByIndex = new Map();
|
|
1859
|
+
for (const entry of settlementEntries) {
|
|
1860
|
+
if (!this.primarySummarySettlementMetadataConsistent(settlement, entry.settlement)) {
|
|
1861
|
+
return { invalidReason: 'primary_summary_settlement_marker_conflict_on_restart' };
|
|
1862
|
+
}
|
|
1863
|
+
if (entriesByIndex.has(entry.settlement.entryIndex)) {
|
|
1864
|
+
return { invalidReason: 'primary_summary_settlement_duplicate_entry_on_restart' };
|
|
1865
|
+
}
|
|
1866
|
+
entriesByIndex.set(entry.settlement.entryIndex, entry);
|
|
1867
|
+
}
|
|
1868
|
+
const placeholderEntry = entriesByIndex.get(1);
|
|
1869
|
+
if (placeholderEntry
|
|
1870
|
+
&& (placeholderEntry.participant !== 'user'
|
|
1871
|
+
|| placeholderEntry.settlement.role !== 'user'
|
|
1872
|
+
|| placeholderEntry.settlement.kind !== 'generated_tool_result')) {
|
|
1873
|
+
return { invalidReason: 'primary_summary_settlement_placeholder_marker_invalid_on_restart' };
|
|
1874
|
+
}
|
|
1875
|
+
if ([...entriesByIndex.keys()].some((index) => index !== 0 && index !== 1)) {
|
|
1876
|
+
return { invalidReason: 'primary_summary_settlement_out_of_order_entries_on_restart' };
|
|
1877
|
+
}
|
|
1878
|
+
const expectedPlaceholder = settlement.outcome === 'held'
|
|
1879
|
+
? this.buildNonExecutedToolResults(assistantEntry.content)
|
|
1880
|
+
: [];
|
|
1881
|
+
if (settlement.outcome === 'success') {
|
|
1882
|
+
if (settlement.entryCount !== 1 || placeholderEntry) {
|
|
1883
|
+
return { invalidReason: 'primary_summary_success_settlement_shape_invalid_on_restart' };
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
else {
|
|
1887
|
+
const expectedEntryCount = expectedPlaceholder.length > 0 ? 2 : 1;
|
|
1888
|
+
if (settlement.entryCount !== expectedEntryCount) {
|
|
1889
|
+
return { invalidReason: 'primary_summary_held_settlement_shape_invalid_on_restart' };
|
|
1890
|
+
}
|
|
1891
|
+
if (placeholderEntry) {
|
|
1892
|
+
if (!this.primarySummaryContentMatches(placeholderEntry.content, expectedPlaceholder)) {
|
|
1893
|
+
return { invalidReason: 'primary_summary_settlement_placeholder_content_mismatch_on_restart' };
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
else if (expectedPlaceholder.length > 0) {
|
|
1897
|
+
const placeholderMetadata = this.buildPrimarySummarySettlementMetadata(record, {
|
|
1898
|
+
outcome: 'held',
|
|
1899
|
+
settlementId: settlement.settlementId,
|
|
1900
|
+
stopReason: settlement.stopReason,
|
|
1901
|
+
holdReason: settlement.holdReason,
|
|
1902
|
+
providerInputTokens: settlement.providerInputTokens,
|
|
1903
|
+
visibleAssistantOutput: settlement.visibleAssistantOutput,
|
|
1904
|
+
executedToolCalls: settlement.executedToolCalls,
|
|
1905
|
+
entryIndex: 1,
|
|
1906
|
+
entryCount: settlement.entryCount,
|
|
1907
|
+
role: 'user',
|
|
1908
|
+
kind: 'generated_tool_result',
|
|
1909
|
+
});
|
|
1910
|
+
await this.persistPrimarySummaryChronicleEntries(agent, [
|
|
1911
|
+
{ participant: 'user', content: expectedPlaceholder, metadata: placeholderMetadata },
|
|
1912
|
+
]);
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
if (record.dispatchKind === 'primary_summary_fallback_retry') {
|
|
1916
|
+
const parent = await this.findPrimarySummaryRetryParent(record.requestId);
|
|
1917
|
+
if (!parent)
|
|
1918
|
+
return { invalidReason: 'primary_summary_fallback_parent_missing_on_restart' };
|
|
1919
|
+
if (settlement.outcome === 'success') {
|
|
1920
|
+
await this.updatePrimarySummaryRequestRecord(record.requestId, (entry) => ({
|
|
1921
|
+
...entry,
|
|
1922
|
+
stopReason: settlement.stopReason ?? entry.stopReason,
|
|
1923
|
+
providerInputTokens: settlement.providerInputTokens ?? entry.providerInputTokens,
|
|
1924
|
+
visibleAssistantOutput: settlement.visibleAssistantOutput,
|
|
1925
|
+
executedToolCalls: settlement.executedToolCalls,
|
|
1926
|
+
finalStatus: 'success',
|
|
1927
|
+
fallbackHeldReason: undefined,
|
|
1928
|
+
}));
|
|
1929
|
+
await this.updatePrimarySummaryRequestRecord(parent.originalRequestId, (entry) => {
|
|
1930
|
+
const intent = entry.fallbackIntent;
|
|
1931
|
+
return {
|
|
1932
|
+
...entry,
|
|
1933
|
+
finalStatus: 'success',
|
|
1934
|
+
fallbackStatus: 'success',
|
|
1935
|
+
fallbackHeldReason: undefined,
|
|
1936
|
+
fallbackIntent: intent && intent.intentId === parent.intentId
|
|
1937
|
+
? { ...intent, finalizedAt: intent.finalizedAt ?? Date.now() }
|
|
1938
|
+
: intent,
|
|
1939
|
+
};
|
|
1940
|
+
});
|
|
1941
|
+
return 'finalized';
|
|
1942
|
+
}
|
|
1943
|
+
await this.updatePrimarySummaryRequestRecord(record.requestId, (entry) => ({
|
|
1944
|
+
...entry,
|
|
1945
|
+
stopReason: settlement.stopReason ?? entry.stopReason,
|
|
1946
|
+
providerInputTokens: settlement.providerInputTokens ?? entry.providerInputTokens,
|
|
1947
|
+
visibleAssistantOutput: settlement.visibleAssistantOutput,
|
|
1948
|
+
executedToolCalls: settlement.executedToolCalls,
|
|
1949
|
+
finalStatus: 'held',
|
|
1950
|
+
fallbackHeldReason: settlement.holdReason,
|
|
1951
|
+
}));
|
|
1952
|
+
await this.updatePrimarySummaryRequestRecord(parent.originalRequestId, (entry) => ({
|
|
1953
|
+
...entry,
|
|
1954
|
+
finalStatus: 'held',
|
|
1955
|
+
fallbackStatus: 'held',
|
|
1956
|
+
fallbackHeldReason: settlement.holdReason,
|
|
1957
|
+
}));
|
|
1958
|
+
return 'finalized';
|
|
1959
|
+
}
|
|
1960
|
+
await this.updatePrimarySummaryRequestRecord(record.requestId, (entry) => ({
|
|
1961
|
+
...entry,
|
|
1962
|
+
stopReason: settlement.stopReason ?? entry.stopReason,
|
|
1963
|
+
providerInputTokens: settlement.providerInputTokens ?? entry.providerInputTokens,
|
|
1964
|
+
visibleAssistantOutput: settlement.visibleAssistantOutput,
|
|
1965
|
+
executedToolCalls: settlement.executedToolCalls,
|
|
1966
|
+
finalStatus: settlement.outcome === 'success' ? 'success' : 'held',
|
|
1967
|
+
fallbackStatus: settlement.outcome === 'success' ? entry.fallbackStatus : 'held',
|
|
1968
|
+
fallbackHeldReason: settlement.outcome === 'held' ? settlement.holdReason : undefined,
|
|
1969
|
+
}));
|
|
1970
|
+
return 'finalized';
|
|
1971
|
+
}
|
|
1972
|
+
summarizePrimarySummaryRetryRequest(request) {
|
|
1973
|
+
const blockCounts = new Map();
|
|
1974
|
+
for (const message of request.messages) {
|
|
1975
|
+
for (const block of message.content) {
|
|
1976
|
+
blockCounts.set(block.type, (blockCounts.get(block.type) ?? 0) + 1);
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
return {
|
|
1980
|
+
kind: 'primary_summary_fallback_retry',
|
|
1981
|
+
requestHash: this.hashJson(request),
|
|
1982
|
+
systemHash: this.hashJson(request.system ?? ''),
|
|
1983
|
+
messageCount: request.messages.length,
|
|
1984
|
+
toolCount: request.tools?.length ?? 0,
|
|
1985
|
+
participantCount: [...new Set(request.messages.map((message) => message.participant))].length,
|
|
1986
|
+
blockCounts: Object.fromEntries([...blockCounts.entries()].sort(([left], [right]) => left.localeCompare(right))),
|
|
1987
|
+
promptCaching: request.promptCaching === true,
|
|
1988
|
+
cacheTtl: request.cacheTtl,
|
|
1989
|
+
assistantParticipant: request.assistantParticipant,
|
|
1990
|
+
requestCompleteBoundTokens: this.estimateRequestCompleteBoundTokens(request),
|
|
1991
|
+
};
|
|
1992
|
+
}
|
|
1993
|
+
summarizePrimarySummaryRetryResponse(response) {
|
|
1994
|
+
const blockCounts = new Map();
|
|
1995
|
+
for (const block of response.content) {
|
|
1996
|
+
blockCounts.set(block.type, (blockCounts.get(block.type) ?? 0) + 1);
|
|
1997
|
+
}
|
|
1998
|
+
return {
|
|
1999
|
+
kind: 'primary_summary_fallback_retry',
|
|
2000
|
+
stopReason: response.stopReason,
|
|
2001
|
+
rawAssistantTextHash: response.rawAssistantText
|
|
2002
|
+
? this.hashJson(response.rawAssistantText)
|
|
2003
|
+
: undefined,
|
|
2004
|
+
blockCounts: Object.fromEntries([...blockCounts.entries()].sort(([left], [right]) => left.localeCompare(right))),
|
|
2005
|
+
toolCallIds: response.toolCalls.map((call) => call.id),
|
|
2006
|
+
usage: response.usage
|
|
2007
|
+
? {
|
|
2008
|
+
inputTokens: response.usage.inputTokens,
|
|
2009
|
+
outputTokens: response.usage.outputTokens,
|
|
2010
|
+
}
|
|
2011
|
+
: undefined,
|
|
2012
|
+
};
|
|
2013
|
+
}
|
|
2014
|
+
async claimPrimarySummaryRetryIntent(originalRequestId, intentId, retryRequestId) {
|
|
2015
|
+
let outcome = 'missing';
|
|
2016
|
+
await this.updatePrimarySummaryRequestRecord(originalRequestId, (record) => {
|
|
2017
|
+
const intent = record.fallbackIntent;
|
|
2018
|
+
if (!intent || intent.intentId !== intentId || record.fallbackStatus !== 'pending') {
|
|
2019
|
+
return record;
|
|
2020
|
+
}
|
|
2021
|
+
if (intent.retryRequestId && intent.retryRequestId !== retryRequestId) {
|
|
2022
|
+
outcome = 'duplicate';
|
|
2023
|
+
return record;
|
|
2024
|
+
}
|
|
2025
|
+
outcome = 'claimed';
|
|
2026
|
+
return {
|
|
2027
|
+
...record,
|
|
2028
|
+
fallbackIntent: {
|
|
2029
|
+
...intent,
|
|
2030
|
+
retryRequestId,
|
|
2031
|
+
dispatchedAt: intent.dispatchedAt ?? Date.now(),
|
|
2032
|
+
},
|
|
2033
|
+
};
|
|
2034
|
+
});
|
|
2035
|
+
return outcome;
|
|
2036
|
+
}
|
|
2037
|
+
async validatePrimarySummaryRetryMutation(agent, requestId, retryDispatch, compiledRequest, artifacts, phase) {
|
|
2038
|
+
const branch = agent.getCurrentBranchGeneration();
|
|
2039
|
+
if (!branch || branch.id !== retryDispatch.branch.id || branch.generation !== retryDispatch.branch.generation) {
|
|
2040
|
+
return {
|
|
2041
|
+
ok: false,
|
|
2042
|
+
reason: `primary_summary_fallback_branch_changed_${phase}`,
|
|
2043
|
+
data: {
|
|
2044
|
+
expectedBranchId: retryDispatch.branch.id,
|
|
2045
|
+
expectedGeneration: retryDispatch.branch.generation,
|
|
2046
|
+
actualBranchId: branch?.id,
|
|
2047
|
+
actualGeneration: branch?.generation,
|
|
2048
|
+
},
|
|
2049
|
+
};
|
|
2050
|
+
}
|
|
2051
|
+
const requestHash = this.hashJson(compiledRequest);
|
|
2052
|
+
if (requestHash !== retryDispatch.expectedRetryRequestHash) {
|
|
2053
|
+
return {
|
|
2054
|
+
ok: false,
|
|
2055
|
+
reason: `primary_summary_fallback_request_changed_${phase}`,
|
|
2056
|
+
data: {
|
|
2057
|
+
expectedRetryRequestHash: retryDispatch.expectedRetryRequestHash,
|
|
2058
|
+
actualRetryRequestHash: requestHash,
|
|
2059
|
+
},
|
|
2060
|
+
};
|
|
2061
|
+
}
|
|
2062
|
+
if (this.primarySummaryProjectionKeys(artifacts.compileResult.primarySummaryProjection ?? null).join('\u0000') !==
|
|
2063
|
+
retryDispatch.originalProjectionKeys.join('\u0000')) {
|
|
2064
|
+
return {
|
|
2065
|
+
ok: false,
|
|
2066
|
+
reason: `primary_summary_fallback_projection_changed_${phase}`,
|
|
2067
|
+
data: {
|
|
2068
|
+
expectedProjectionKeys: retryDispatch.originalProjectionKeys,
|
|
2069
|
+
actualProjectionKeys: this.primarySummaryProjectionKeys(artifacts.compileResult.primarySummaryProjection ?? null),
|
|
2070
|
+
},
|
|
2071
|
+
};
|
|
2072
|
+
}
|
|
2073
|
+
if (!this.primarySummaryContractsMatch(artifacts.contract, retryDispatch.originalArtifacts.contract)) {
|
|
2074
|
+
return {
|
|
2075
|
+
ok: false,
|
|
2076
|
+
reason: `primary_summary_fallback_contract_changed_${phase}`,
|
|
2077
|
+
data: {
|
|
2078
|
+
expectedContract: retryDispatch.originalArtifacts.contract,
|
|
2079
|
+
actualContract: artifacts.contract,
|
|
2080
|
+
},
|
|
2081
|
+
};
|
|
2082
|
+
}
|
|
2083
|
+
const original = await this.getPrimarySummaryRequestRecord(retryDispatch.originalRequestId);
|
|
2084
|
+
if (!original?.fallbackIntent ||
|
|
2085
|
+
original.fallbackIntent.intentId !== retryDispatch.intentId ||
|
|
2086
|
+
original.fallbackIntent.retryRequestId !== requestId ||
|
|
2087
|
+
original.fallbackStatus !== 'pending') {
|
|
2088
|
+
return {
|
|
2089
|
+
ok: false,
|
|
2090
|
+
reason: `primary_summary_fallback_intent_changed_${phase}`,
|
|
2091
|
+
data: {
|
|
2092
|
+
expectedIntentId: retryDispatch.intentId,
|
|
2093
|
+
actualIntentId: original?.fallbackIntent?.intentId,
|
|
2094
|
+
retryRequestId: original?.fallbackIntent?.retryRequestId,
|
|
2095
|
+
fallbackStatus: original?.fallbackStatus,
|
|
2096
|
+
},
|
|
2097
|
+
};
|
|
2098
|
+
}
|
|
2099
|
+
return { ok: true };
|
|
2100
|
+
}
|
|
2101
|
+
async recordPrimarySummaryRetryQuarantine(originalRequestId, intentId) {
|
|
2102
|
+
await this.updatePrimarySummaryRequestRecord(originalRequestId, (record) => {
|
|
2103
|
+
const intent = record.fallbackIntent;
|
|
2104
|
+
if (!intent || intent.intentId !== intentId || intent.quarantinePersistedAt)
|
|
2105
|
+
return record;
|
|
2106
|
+
return {
|
|
2107
|
+
...record,
|
|
2108
|
+
fallbackIntent: {
|
|
2109
|
+
...intent,
|
|
2110
|
+
quarantinePersistedAt: Date.now(),
|
|
2111
|
+
},
|
|
2112
|
+
};
|
|
2113
|
+
});
|
|
2114
|
+
}
|
|
2115
|
+
async recordPrimarySummaryRetryOutput(originalRequestId, intentId, output) {
|
|
2116
|
+
await this.updatePrimarySummaryRequestRecord(originalRequestId, (record) => {
|
|
2117
|
+
const intent = record.fallbackIntent;
|
|
2118
|
+
if (!intent || intent.intentId !== intentId)
|
|
2119
|
+
return record;
|
|
2120
|
+
const existing = intent.outputs?.find((entry) => entry.phase === output.phase);
|
|
2121
|
+
if (existing &&
|
|
2122
|
+
existing.contentHash === output.contentHash &&
|
|
2123
|
+
existing.messageId === output.messageId) {
|
|
2124
|
+
return record;
|
|
2125
|
+
}
|
|
2126
|
+
if (existing) {
|
|
2127
|
+
throw new Error(`Primary summary fallback output phase '${output.phase}' changed after persistence`);
|
|
2128
|
+
}
|
|
2129
|
+
return {
|
|
2130
|
+
...record,
|
|
2131
|
+
fallbackIntent: {
|
|
2132
|
+
...intent,
|
|
2133
|
+
outputs: [...(intent.outputs ?? []), output],
|
|
2134
|
+
},
|
|
2135
|
+
};
|
|
2136
|
+
});
|
|
2137
|
+
}
|
|
2138
|
+
async recordPrimarySummaryRetryToolDispatch(originalRequestId, intentId, call) {
|
|
2139
|
+
await this.updatePrimarySummaryRequestRecord(originalRequestId, (record) => {
|
|
2140
|
+
const intent = record.fallbackIntent;
|
|
2141
|
+
if (!intent || intent.intentId !== intentId)
|
|
2142
|
+
return record;
|
|
2143
|
+
const inputHash = this.hashJson(call.input);
|
|
2144
|
+
const existing = intent.toolDispatches?.find((entry) => entry.callId === call.id);
|
|
2145
|
+
if (existing?.inputHash === inputHash)
|
|
2146
|
+
return record;
|
|
2147
|
+
if (existing) {
|
|
2148
|
+
throw new Error(`Primary summary fallback tool dispatch '${call.id}' changed after persistence`);
|
|
2149
|
+
}
|
|
2150
|
+
return {
|
|
2151
|
+
...record,
|
|
2152
|
+
fallbackIntent: {
|
|
2153
|
+
...intent,
|
|
2154
|
+
toolDispatches: [
|
|
2155
|
+
...(intent.toolDispatches ?? []),
|
|
2156
|
+
{
|
|
2157
|
+
callId: call.id,
|
|
2158
|
+
inputHash,
|
|
2159
|
+
recordedAt: Date.now(),
|
|
2160
|
+
},
|
|
2161
|
+
],
|
|
2162
|
+
},
|
|
2163
|
+
};
|
|
2164
|
+
});
|
|
2165
|
+
}
|
|
2166
|
+
async resolveContextBudgetRestartDispatch(requestId, retryDispatch) {
|
|
2167
|
+
const retryIdentity = retryDispatch
|
|
2168
|
+
? {
|
|
2169
|
+
requestId,
|
|
2170
|
+
dispatchKind: 'primary_summary_fallback_retry',
|
|
2171
|
+
originalRequestId: retryDispatch.originalRequestId,
|
|
2172
|
+
intentId: retryDispatch.intentId,
|
|
2173
|
+
}
|
|
2174
|
+
: null;
|
|
2175
|
+
const record = await this.getPrimarySummaryRequestRecord(requestId);
|
|
2176
|
+
if (record?.dispatchKind === 'primary_summary_fallback_retry' && retryIdentity) {
|
|
2177
|
+
return retryIdentity;
|
|
2178
|
+
}
|
|
2179
|
+
if (retryIdentity) {
|
|
2180
|
+
const original = await this.getPrimarySummaryRequestRecord(retryIdentity.originalRequestId);
|
|
2181
|
+
if (original?.fallbackIntent?.intentId === retryIdentity.intentId
|
|
2182
|
+
&& original.fallbackIntent.retryRequestId === requestId) {
|
|
2183
|
+
return retryIdentity;
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
return {
|
|
2187
|
+
requestId,
|
|
2188
|
+
dispatchKind: 'primary',
|
|
2189
|
+
};
|
|
2190
|
+
}
|
|
2191
|
+
contextBudgetRestartMatchesIdentity(request, identity) {
|
|
2192
|
+
if (request.reason !== 'context_budget_restart')
|
|
2193
|
+
return false;
|
|
2194
|
+
const restart = request.contextBudgetRestart;
|
|
2195
|
+
if (!restart)
|
|
2196
|
+
return false;
|
|
2197
|
+
if (restart.dispatchKind !== identity.dispatchKind)
|
|
2198
|
+
return false;
|
|
2199
|
+
if (restart.requestId !== identity.requestId)
|
|
2200
|
+
return false;
|
|
2201
|
+
if (identity.dispatchKind !== 'primary_summary_fallback_retry')
|
|
2202
|
+
return true;
|
|
2203
|
+
return restart.originalRequestId === identity.originalRequestId
|
|
2204
|
+
&& restart.intentId === identity.intentId;
|
|
2205
|
+
}
|
|
2206
|
+
clearQueuedContextBudgetRestart(identity) {
|
|
2207
|
+
const before = this.pendingRequests.length;
|
|
2208
|
+
this.pendingRequests = this.pendingRequests.filter((request) => !this.contextBudgetRestartMatchesIdentity(request, identity));
|
|
2209
|
+
return before - this.pendingRequests.length;
|
|
2210
|
+
}
|
|
2211
|
+
async markPrimarySummaryRetrySuccess(requestId, retryDispatch, stopReason, providerInputTokens, visibleAssistantOutput, executedToolCalls) {
|
|
2212
|
+
await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
|
|
2213
|
+
...record,
|
|
2214
|
+
stopReason,
|
|
2215
|
+
providerInputTokens,
|
|
2216
|
+
visibleAssistantOutput,
|
|
2217
|
+
executedToolCalls,
|
|
2218
|
+
finalStatus: 'success',
|
|
2219
|
+
fallbackHeldReason: undefined,
|
|
2220
|
+
}));
|
|
2221
|
+
await this.updatePrimarySummaryRequestRecord(retryDispatch.originalRequestId, (record) => {
|
|
2222
|
+
const intent = record.fallbackIntent;
|
|
2223
|
+
return {
|
|
2224
|
+
...record,
|
|
2225
|
+
finalStatus: 'success',
|
|
2226
|
+
fallbackStatus: 'success',
|
|
2227
|
+
fallbackHeldReason: undefined,
|
|
2228
|
+
fallbackIntent: intent && intent.intentId === retryDispatch.intentId
|
|
2229
|
+
? { ...intent, finalizedAt: Date.now() }
|
|
2230
|
+
: intent,
|
|
2231
|
+
};
|
|
2232
|
+
});
|
|
2233
|
+
}
|
|
2234
|
+
async holdPrimarySummaryRetryFamily(agent, requestId, retryDispatch, reason, data, options) {
|
|
2235
|
+
const clearedQueuedContextBudgetRestarts = this.clearQueuedContextBudgetRestart({
|
|
2236
|
+
requestId,
|
|
2237
|
+
dispatchKind: 'primary_summary_fallback_retry',
|
|
2238
|
+
originalRequestId: retryDispatch.originalRequestId,
|
|
2239
|
+
intentId: retryDispatch.intentId,
|
|
2240
|
+
});
|
|
2241
|
+
const holdData = clearedQueuedContextBudgetRestarts > 0
|
|
2242
|
+
? { ...(data ?? {}), clearedQueuedContextBudgetRestarts }
|
|
2243
|
+
: data;
|
|
2244
|
+
await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
|
|
2245
|
+
...record,
|
|
2246
|
+
...(options?.stopReason ? { stopReason: options.stopReason } : {}),
|
|
2247
|
+
finalStatus: 'held',
|
|
2248
|
+
fallbackHeldReason: reason,
|
|
2249
|
+
}));
|
|
2250
|
+
await this.updatePrimarySummaryRequestRecord(retryDispatch.originalRequestId, (record) => ({
|
|
2251
|
+
...record,
|
|
2252
|
+
finalStatus: 'held',
|
|
2253
|
+
fallbackStatus: 'held',
|
|
2254
|
+
fallbackHeldReason: reason,
|
|
2255
|
+
}));
|
|
2256
|
+
this.opsAlert('refusal-held', agent.name, reason, { data: holdData });
|
|
2257
|
+
if (options?.emitChronicleMarker === false)
|
|
2258
|
+
return;
|
|
2259
|
+
try {
|
|
2260
|
+
agent.getContextManager().addMessage('user', [{
|
|
2261
|
+
type: 'text',
|
|
2262
|
+
text: `[refusal-held] Your previous turn was refused by the provider and ` +
|
|
2263
|
+
`is being held for operator review rather than silently retried. ` +
|
|
2264
|
+
`Automatic raw-expansion recovery was either unsafe or unsuccessful.`,
|
|
2265
|
+
}], { system: true, kind: 'refusal-held', reason });
|
|
2266
|
+
}
|
|
2267
|
+
catch (error) {
|
|
2268
|
+
console.error('[refusal-held] could not record chronicle marker:', error);
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
async primarySummaryRetryAlreadySucceeded(requestId, retryDispatch) {
|
|
2272
|
+
const [retryRecord, originalRecord] = await Promise.all([
|
|
2273
|
+
this.getPrimarySummaryRequestRecord(requestId),
|
|
2274
|
+
this.getPrimarySummaryRequestRecord(retryDispatch.originalRequestId),
|
|
2275
|
+
]);
|
|
2276
|
+
return retryRecord?.finalStatus === 'success'
|
|
2277
|
+
|| originalRecord?.fallbackStatus === 'success'
|
|
2278
|
+
|| originalRecord?.finalStatus === 'success';
|
|
2279
|
+
}
|
|
2280
|
+
async primarySummaryRetryFamilyAlreadySettled(requestId, retryDispatch) {
|
|
2281
|
+
const [retryRecord, originalRecord] = await Promise.all([
|
|
2282
|
+
this.getPrimarySummaryRequestRecord(requestId),
|
|
2283
|
+
this.getPrimarySummaryRequestRecord(retryDispatch.originalRequestId),
|
|
2284
|
+
]);
|
|
2285
|
+
return retryRecord?.finalStatus === 'held'
|
|
2286
|
+
|| retryRecord?.finalStatus === 'success'
|
|
2287
|
+
|| originalRecord?.fallbackStatus === 'held'
|
|
2288
|
+
|| originalRecord?.fallbackStatus === 'success'
|
|
2289
|
+
|| originalRecord?.finalStatus === 'held'
|
|
2290
|
+
|| originalRecord?.finalStatus === 'success';
|
|
2291
|
+
}
|
|
2292
|
+
async holdPrimarySummaryRequest(agent, requestId, reason, data) {
|
|
2293
|
+
await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
|
|
2294
|
+
...record,
|
|
2295
|
+
finalStatus: 'held',
|
|
2296
|
+
fallbackStatus: 'held',
|
|
2297
|
+
fallbackHeldReason: reason,
|
|
2298
|
+
}));
|
|
2299
|
+
this.opsAlert('refusal-held', agent.name, reason, { data });
|
|
2300
|
+
try {
|
|
2301
|
+
agent.getContextManager().addMessage('user', [{
|
|
2302
|
+
type: 'text',
|
|
2303
|
+
text: `[refusal-held] Your previous turn was refused by the provider and ` +
|
|
2304
|
+
`is being held for operator review rather than silently retried. ` +
|
|
2305
|
+
`Automatic raw-expansion recovery was either unsafe or unsuccessful.`,
|
|
2306
|
+
}], { system: true, kind: 'refusal-held', reason });
|
|
2307
|
+
}
|
|
2308
|
+
catch (error) {
|
|
2309
|
+
console.error('[refusal-held] could not record chronicle marker:', error);
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
hasVisibleAssistantOutput(blocks) {
|
|
2313
|
+
return blocks.some((block) => block.type !== 'thinking' && block.type !== 'redacted_thinking');
|
|
2314
|
+
}
|
|
2315
|
+
buildPrimarySummaryRequestRecord(agent, requestId, dispatchKind, request, artifacts, branch, stopReason, visibleAssistantOutput, executedToolCalls, providerInputTokens, finalStatus) {
|
|
2316
|
+
const projection = artifacts?.compileResult.primarySummaryProjection ?? null;
|
|
2317
|
+
return {
|
|
2318
|
+
requestId,
|
|
2319
|
+
agentName: agent.name,
|
|
2320
|
+
namespace: projection?.namespace ?? `agents/${agent.name}`,
|
|
2321
|
+
timestamp: Date.now(),
|
|
2322
|
+
dispatchKind,
|
|
2323
|
+
branch: branch
|
|
2324
|
+
? { id: branch.id, name: branch.name, generation: branch.generation }
|
|
2325
|
+
: { id: 'unknown', name: 'unknown', generation: -1 },
|
|
2326
|
+
projection,
|
|
2327
|
+
requestInputBoundTokens: this.estimateRequestInputBoundTokens(request),
|
|
2328
|
+
requestCompleteBoundTokens: this.estimateRequestCompleteBoundTokens(request),
|
|
2329
|
+
providerInputTokens,
|
|
2330
|
+
systemHash: artifacts?.contract.systemHash ?? this.hashJson(request.system ?? ''),
|
|
2331
|
+
modelConfigHash: artifacts?.contract.modelConfigHash ?? this.hashJson({ request: request.config }),
|
|
2332
|
+
toolContractHash: artifacts?.contract.toolContractHash ?? this.hashJson(request.tools ?? []),
|
|
2333
|
+
stopReason,
|
|
2334
|
+
visibleAssistantOutput,
|
|
2335
|
+
executedToolCalls,
|
|
2336
|
+
finalStatus,
|
|
2337
|
+
};
|
|
2338
|
+
}
|
|
973
2339
|
/** Liveness ping for an active ephemeral run (no-op otherwise). */
|
|
974
2340
|
touchEphemeralRun(agentName, inferenceStarted = false) {
|
|
975
2341
|
const run = this.ephemeralRuns.get(agentName);
|
|
@@ -1238,6 +2604,66 @@ export class AgentFramework {
|
|
|
1238
2604
|
'tagsAll / tagsNone) or in gate.js.',
|
|
1239
2605
|
inputSchema: { type: 'object' },
|
|
1240
2606
|
};
|
|
2607
|
+
/** Synthesized save_image tool — present when a workspace module is
|
|
2608
|
+
* registered. Lets the agent persist an image it has already seen in its
|
|
2609
|
+
* own context (Discord attachments arrive inlined as base64 and are
|
|
2610
|
+
* otherwise unreachable as files). */
|
|
2611
|
+
static SAVE_IMAGE_TOOL = {
|
|
2612
|
+
name: 'save_recent_image',
|
|
2613
|
+
description: 'Save one or more recent images from your own context to workspace files. ' +
|
|
2614
|
+
'Images are counted back from the most recent (index 0). A single image is ' +
|
|
2615
|
+
'written to `path` as given (e.g. "project/photos/cat.png"); when `count` > 1 ' +
|
|
2616
|
+
'the range index..index+count-1 is saved with numeric suffixes ' +
|
|
2617
|
+
'("cat-0.png", "cat-1.png", …; 0 = the newest of the range). Saved files are ' +
|
|
2618
|
+
'visible via workspace tools and the /files/ endpoint.',
|
|
2619
|
+
inputSchema: {
|
|
2620
|
+
type: 'object',
|
|
2621
|
+
properties: {
|
|
2622
|
+
path: {
|
|
2623
|
+
type: 'string',
|
|
2624
|
+
description: 'Mount-prefixed destination path, e.g. "project/photos/name.png".',
|
|
2625
|
+
},
|
|
2626
|
+
index: {
|
|
2627
|
+
type: 'number',
|
|
2628
|
+
description: 'Which image, counting back from the most recent (0 = most recent). Default 0.',
|
|
2629
|
+
},
|
|
2630
|
+
count: {
|
|
2631
|
+
type: 'number',
|
|
2632
|
+
description: 'How many images to save, starting at `index` and going further back. Default 1.',
|
|
2633
|
+
},
|
|
2634
|
+
},
|
|
2635
|
+
required: ['path'],
|
|
2636
|
+
},
|
|
2637
|
+
};
|
|
2638
|
+
static AGENT_SETTINGS_TOOL = {
|
|
2639
|
+
name: 'agent_settings',
|
|
2640
|
+
description: 'Read or change your hot runtime settings. This intentionally exposes only ' +
|
|
2641
|
+
'context budget, recent raw tail size, transition pace, and same-round think text routing; model, prompts, ' +
|
|
2642
|
+
'folding strategy, and other restart-bound configuration are not mutable here. ' +
|
|
2643
|
+
'Lower context budgets converge gradually under the transition pace before ' +
|
|
2644
|
+
'becoming the hard live limit; increases take effect immediately.',
|
|
2645
|
+
inputSchema: {
|
|
2646
|
+
type: 'object',
|
|
2647
|
+
properties: {
|
|
2648
|
+
action: { type: 'string', enum: ['get', 'update', 'reset', 'cancel'] },
|
|
2649
|
+
context_budget_tokens: { type: 'number', description: 'Total input context budget, including the reserved response allowance.' },
|
|
2650
|
+
tail_tokens: { type: 'number', description: 'Recent raw context retained verbatim.' },
|
|
2651
|
+
transition_pace_tokens: { type: 'number', description: 'Maximum ordinary KV re-read/perturbation per compile while converging.' },
|
|
2652
|
+
same_round_think_text_policy: {
|
|
2653
|
+
type: 'string',
|
|
2654
|
+
enum: ['public', 'private'],
|
|
2655
|
+
description: 'Routing policy for ordinary text emitted in the same native assistant round as think(). ' +
|
|
2656
|
+
"Omitted in the recipe preserves the compatibility carry-forward: public.",
|
|
2657
|
+
},
|
|
2658
|
+
settings: {
|
|
2659
|
+
type: 'array',
|
|
2660
|
+
items: { type: 'string', enum: ['context_budget_tokens', 'tail_tokens', 'transition_pace_tokens', 'same_round_think_text_policy'] },
|
|
2661
|
+
description: 'For reset: settings to restore to recipe values. Omit to reset all.',
|
|
2662
|
+
},
|
|
2663
|
+
},
|
|
2664
|
+
required: ['action'],
|
|
2665
|
+
},
|
|
2666
|
+
};
|
|
1241
2667
|
/** Reserved chat:* core vocabulary (MCPL RFC-001 §4) — short descriptions so
|
|
1242
2668
|
* the agent can author rules without reading the spec. */
|
|
1243
2669
|
static CHAT_CORE_TAGS = {
|
|
@@ -1619,8 +3045,25 @@ export class AgentFramework {
|
|
|
1619
3045
|
};
|
|
1620
3046
|
}
|
|
1621
3047
|
const target = allMessages[allMessages.length - 1 - n];
|
|
1622
|
-
const
|
|
3048
|
+
const discarded = allMessages.slice(allMessages.length - n);
|
|
3049
|
+
const removedRefs = extractDiscordAwarenessRefs(discarded);
|
|
3050
|
+
const sourceBranch = this.store.currentBranch().name;
|
|
3051
|
+
const targetBranch = `undo-msgs/${agentName}/${Date.now()}`;
|
|
3052
|
+
// Prepare the external side effect before switching Chronicle. If the
|
|
3053
|
+
// process dies after the switch but before activate(), startup promotes
|
|
3054
|
+
// this batch by matching targetBranch to the active branch.
|
|
3055
|
+
const markerBatch = this.discordAwarenessOutbox?.prepare({
|
|
3056
|
+
agentName,
|
|
3057
|
+
sourceBranch,
|
|
3058
|
+
targetBranch,
|
|
3059
|
+
refs: removedRefs,
|
|
3060
|
+
emoji: this.discordAwarenessEmoji,
|
|
3061
|
+
}) ?? null;
|
|
3062
|
+
const branchName = cm.branchAt(target.id, targetBranch);
|
|
1623
3063
|
await cm.switchBranch(branchName);
|
|
3064
|
+
if (markerBatch)
|
|
3065
|
+
this.discordAwarenessOutbox.activate(markerBatch.id);
|
|
3066
|
+
await this.syncDiscordAwarenessMarkers();
|
|
1624
3067
|
// Materialize config files from the new branch (fire-and-forget; gate
|
|
1625
3068
|
// picks up via mtime) — mirrors undoLastTurn.
|
|
1626
3069
|
const wsUndo = this.moduleRegistry.getModule('workspace');
|
|
@@ -1634,6 +3077,7 @@ export class AgentFramework {
|
|
|
1634
3077
|
return {
|
|
1635
3078
|
ok: true,
|
|
1636
3079
|
messagesRemoved: n,
|
|
3080
|
+
removedRefs,
|
|
1637
3081
|
lastVisible: await this.lastVisiblePreview(agentName),
|
|
1638
3082
|
};
|
|
1639
3083
|
}
|
|
@@ -1660,6 +3104,7 @@ export class AgentFramework {
|
|
|
1660
3104
|
if (undone === 0) {
|
|
1661
3105
|
return { ok: true, undone: 0, requested, lastVisible: null };
|
|
1662
3106
|
}
|
|
3107
|
+
await this.syncDiscordAwarenessMarkers();
|
|
1663
3108
|
return { ok: true, undone, requested, lastVisible: await this.lastVisiblePreview(agentName) };
|
|
1664
3109
|
}
|
|
1665
3110
|
/**
|
|
@@ -2155,6 +3600,10 @@ export class AgentFramework {
|
|
|
2155
3600
|
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
2156
3601
|
});
|
|
2157
3602
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
3603
|
+
const restoredSettings = this.readAgentRuntimeSettings(config.name);
|
|
3604
|
+
if (restoredSettings) {
|
|
3605
|
+
agent.restoreRuntimeSettings(this.validatePersistedAgentRuntimeSettings(config.name, restoredSettings));
|
|
3606
|
+
}
|
|
2158
3607
|
this.agents.set(config.name, agent);
|
|
2159
3608
|
this.agentConfigs.set(config.name, config);
|
|
2160
3609
|
// First non-ephemeral agent becomes the primary for message routing
|
|
@@ -2183,13 +3632,22 @@ export class AgentFramework {
|
|
|
2183
3632
|
this.sweepExpiredConversations();
|
|
2184
3633
|
// Check for inference requests
|
|
2185
3634
|
await this.processInferenceRequests();
|
|
2186
|
-
// Yield to the event loop between iterations.
|
|
2187
|
-
//
|
|
2188
|
-
// (
|
|
2189
|
-
|
|
3635
|
+
// Yield to the event loop between iterations. A pending inference request
|
|
3636
|
+
// is not necessarily runnable: while its agent is streaming or waiting for
|
|
3637
|
+
// tools, processInferenceRequests() deliberately requeues it. Treating that
|
|
3638
|
+
// requeued request as "work made progress" creates a microtask-only polling
|
|
3639
|
+
// loop. If activeStreams bookkeeping is absent/stale at the same time, the
|
|
3640
|
+
// old code had no await at all and starved the tool-result and HTTP I/O that
|
|
3641
|
+
// could make the agent runnable again (the Sol outage, 2026-07-15).
|
|
3642
|
+
//
|
|
3643
|
+
// No queue event means no foreground progress, so always take the normal
|
|
3644
|
+
// polling backoff. After an event, retain the low-latency macrotask yield
|
|
3645
|
+
// while background work remains. Both paths give Bun/Node a real event-loop
|
|
3646
|
+
// turn; neither can recurse forever through already-resolved promises.
|
|
3647
|
+
if (!event) {
|
|
2190
3648
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
2191
3649
|
}
|
|
2192
|
-
else if (this.activeStreams.size > 0) {
|
|
3650
|
+
else if (this.pendingRequests.length > 0 || this.activeStreams.size > 0) {
|
|
2193
3651
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2194
3652
|
}
|
|
2195
3653
|
}
|
|
@@ -2226,6 +3684,7 @@ export class AgentFramework {
|
|
|
2226
3684
|
// Cast to AgentState to bypass TypeScript's control flow narrowing
|
|
2227
3685
|
const currentState = agent.state;
|
|
2228
3686
|
if (currentState.status === 'ready') {
|
|
3687
|
+
const activeStreamContext = this.activeStreamContexts.get(agent.name);
|
|
2229
3688
|
// Flush pending assistant blocks (tool_use + preamble text) to context
|
|
2230
3689
|
const pendingBlocks = this.pendingAssistantBlocks.get(agent.name);
|
|
2231
3690
|
if (pendingBlocks) {
|
|
@@ -2247,18 +3706,66 @@ export class AgentFramework {
|
|
|
2247
3706
|
isError: tc.result.isError,
|
|
2248
3707
|
}));
|
|
2249
3708
|
agent.getContextManager().addMessage('user', toolResultContent);
|
|
2250
|
-
// Flush any messages that were deferred while
|
|
2251
|
-
// Route to the PRIMARY agent — deferred messages are
|
|
2252
|
-
// (
|
|
3709
|
+
// Flush any messages that were deferred while this turn was in
|
|
3710
|
+
// flight. Route to the PRIMARY agent — deferred messages are
|
|
3711
|
+
// framework-level (module messages, push events, subagent return
|
|
3712
|
+
// notifications) meant for the main conversation.
|
|
3713
|
+
//
|
|
3714
|
+
// Hear-while-acting: messages flushed into THIS agent's own window
|
|
3715
|
+
// are also collected for mid-turn injection — passed to the membrane
|
|
3716
|
+
// with the tool results below, so the NEXT inference round of the
|
|
3717
|
+
// live turn sees them instead of the agent staying deaf until the
|
|
3718
|
+
// turn ends. The window stores them here, right after the
|
|
3719
|
+
// tool_result message, which is exactly where the membrane appends
|
|
3720
|
+
// them in the live conversation — same participant, same content
|
|
3721
|
+
// blocks, same metadata, and compile() emits recent messages
|
|
3722
|
+
// verbatim, so the next turn's compiled prefix byte-matches the
|
|
3723
|
+
// live request (prompt-cache safe).
|
|
3724
|
+
//
|
|
3725
|
+
// The queue is drained ONLY at the target agent's own boundary:
|
|
3726
|
+
// draining at another agent's boundary would store the messages
|
|
3727
|
+
// mid-turn in the target's window without injecting them into the
|
|
3728
|
+
// target's live stream (deaf agent + window/live divergence). Left
|
|
3729
|
+
// queued, they flush at the target's own next boundary — with
|
|
3730
|
+
// injection — or in driveStream's finally when its turn ends.
|
|
3731
|
+
const midTurnInjections = [];
|
|
2253
3732
|
if (this.deferredMessages.length > 0) {
|
|
2254
|
-
const
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
3733
|
+
const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent;
|
|
3734
|
+
if (target === agent) {
|
|
3735
|
+
const deferred = this.deferredMessages.splice(0);
|
|
3736
|
+
for (const msg of deferred) {
|
|
3737
|
+
target.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
|
|
3738
|
+
// Injection guards: tool blocks would corrupt the tool-cycle
|
|
3739
|
+
// structure the membrane enforces, and a message named as the
|
|
3740
|
+
// agent itself would render as an ASSISTANT turn on the wire
|
|
3741
|
+
// (an unintended prefill the model would continue). Such
|
|
3742
|
+
// messages stay window-only — visible next turn.
|
|
3743
|
+
const hasToolBlocks = msg.content.some((b) => b.type === 'tool_use' || b.type === 'tool_result');
|
|
3744
|
+
if (!hasToolBlocks && msg.participant !== agent.name) {
|
|
3745
|
+
midTurnInjections.push({
|
|
3746
|
+
participant: msg.participant,
|
|
3747
|
+
content: msg.content,
|
|
3748
|
+
...(msg.metadata ? { metadata: msg.metadata } : {}),
|
|
3749
|
+
});
|
|
3750
|
+
}
|
|
3751
|
+
}
|
|
2260
3752
|
}
|
|
2261
3753
|
}
|
|
3754
|
+
// A newly injected message begins a new conversational round inside
|
|
3755
|
+
// the same provider inference. Remember that boundary for
|
|
3756
|
+
// driveStream: an explicit send in the preceding round must not
|
|
3757
|
+
// silence the reply, and the newest channel-bearing injection is
|
|
3758
|
+
// now the reply locus. Map presence matters even without a channel
|
|
3759
|
+
// (CLI/module input): it still clears the prior send suppression.
|
|
3760
|
+
if (midTurnInjections.length > 0) {
|
|
3761
|
+
const injectedChannelId = midTurnInjections.reduce((latest, injection) => {
|
|
3762
|
+
const candidate = injection.metadata?.channelId;
|
|
3763
|
+
return typeof candidate === 'string' && candidate.length > 0
|
|
3764
|
+
? candidate
|
|
3765
|
+
: latest;
|
|
3766
|
+
}, null);
|
|
3767
|
+
this.midTurnRoutingResets.set(agent.name, injectedChannelId);
|
|
3768
|
+
}
|
|
2262
3769
|
// Check if any tool result requested endTurn
|
|
2263
3770
|
const shouldEndTurn = currentState.toolResults.some(tc => tc.result.endTurn);
|
|
2264
3771
|
// Check if accumulated input tokens exceed the agent's budget
|
|
@@ -2288,6 +3795,34 @@ export class AgentFramework {
|
|
|
2288
3795
|
// a terminal failure — rejecting an ephemeral's promise mid-run
|
|
2289
3796
|
// and emitting a spurious inference:exhausted (same race shape as
|
|
2290
3797
|
// endTurn above).
|
|
3798
|
+
const requestId = activeStreamContext?.requestId;
|
|
3799
|
+
const primarySummaryRetry = activeStreamContext?.trigger?.primarySummaryFallbackRetry;
|
|
3800
|
+
const compiledRequest = activeStreamContext?.compiledRequest;
|
|
3801
|
+
const artifacts = activeStreamContext?.artifacts;
|
|
3802
|
+
const restartDispatch = await this.resolveContextBudgetRestartDispatch(requestId ?? `${agent.name}:context_budget_restart`, primarySummaryRetry);
|
|
3803
|
+
if (restartDispatch.dispatchKind === 'primary_summary_fallback_retry'
|
|
3804
|
+
&& requestId
|
|
3805
|
+
&& primarySummaryRetry
|
|
3806
|
+
&& compiledRequest
|
|
3807
|
+
&& artifacts) {
|
|
3808
|
+
const validation = await this.validatePrimarySummaryRetryMutation(agent, requestId, primarySummaryRetry, compiledRequest, artifacts, 'before_context_budget_restart_hold');
|
|
3809
|
+
if (!validation.ok) {
|
|
3810
|
+
await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, validation.reason, validation.data, { emitChronicleMarker: false, stopReason: 'abort' });
|
|
3811
|
+
}
|
|
3812
|
+
else {
|
|
3813
|
+
await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, 'primary_summary_fallback_context_budget_restart_required', {
|
|
3814
|
+
inputTokens: agent.lastStreamInputTokens,
|
|
3815
|
+
budget: agent.maxStreamTokens,
|
|
3816
|
+
originalRequestId: primarySummaryRetry.originalRequestId,
|
|
3817
|
+
intentId: primarySummaryRetry.intentId,
|
|
3818
|
+
}, { emitChronicleMarker: false, stopReason: 'abort' });
|
|
3819
|
+
}
|
|
3820
|
+
this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart');
|
|
3821
|
+
currentState.stream?.cancel();
|
|
3822
|
+
agent.reset();
|
|
3823
|
+
this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
|
|
3824
|
+
return;
|
|
3825
|
+
}
|
|
2291
3826
|
if (currentState.stream) {
|
|
2292
3827
|
this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart');
|
|
2293
3828
|
}
|
|
@@ -2304,14 +3839,23 @@ export class AgentFramework {
|
|
|
2304
3839
|
reason: 'context_budget_restart',
|
|
2305
3840
|
source: 'framework',
|
|
2306
3841
|
timestamp: Date.now(),
|
|
3842
|
+
channelId: activeStreamContext?.trigger?.channelId,
|
|
3843
|
+
contextBudgetRestart: restartDispatch,
|
|
2307
3844
|
});
|
|
2308
3845
|
}
|
|
2309
3846
|
else if (currentState.stream) {
|
|
2310
|
-
// Streaming path: convert results and resume the stream
|
|
3847
|
+
// Streaming path: convert results and resume the stream.
|
|
3848
|
+
// Mid-turn messages collected above ride along as injected user
|
|
3849
|
+
// messages (membrane ≥0.5.72) — appended after the tool_result
|
|
3850
|
+
// envelope so the next round of THIS turn hears them.
|
|
2311
3851
|
const membraneResults = currentState.toolResults.map(tc => this.toMembraneToolResult(tc.id, tc.result, maxChars));
|
|
2312
|
-
currentState.stream.provideToolResults(membraneResults);
|
|
3852
|
+
currentState.stream.provideToolResults(membraneResults, midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections } : undefined);
|
|
2313
3853
|
agent.setStreaming(currentState.stream);
|
|
2314
|
-
this.emitTrace({
|
|
3854
|
+
this.emitTrace({
|
|
3855
|
+
type: 'inference:stream_resumed',
|
|
3856
|
+
agentName: agent.name,
|
|
3857
|
+
...(midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections.length } : {}),
|
|
3858
|
+
});
|
|
2315
3859
|
}
|
|
2316
3860
|
else {
|
|
2317
3861
|
// Non-streaming fallback: schedule re-inference
|
|
@@ -2689,6 +4233,10 @@ export class AgentFramework {
|
|
|
2689
4233
|
* Convert an MCPL push event to a context message.
|
|
2690
4234
|
*/
|
|
2691
4235
|
handleMcplPushEvent(event) {
|
|
4236
|
+
const triggerChannel = this.derivePushEventChannel(event.origin);
|
|
4237
|
+
if (triggerChannel && this.channelRegistry) {
|
|
4238
|
+
this.channelRegistry.ensureChannelRegistered(event.serverId, triggerChannel.channelId, triggerChannel.label);
|
|
4239
|
+
}
|
|
2692
4240
|
const metadata = {
|
|
2693
4241
|
...event.origin,
|
|
2694
4242
|
serverId: event.serverId,
|
|
@@ -2696,17 +4244,36 @@ export class AgentFramework {
|
|
|
2696
4244
|
eventId: event.eventId,
|
|
2697
4245
|
triggered: event.triggerInference ?? false,
|
|
2698
4246
|
};
|
|
2699
|
-
const
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
4247
|
+
const content = [...event.content];
|
|
4248
|
+
const addressedWhileClosed = triggerChannel &&
|
|
4249
|
+
event.tags?.includes('chat:addressed') &&
|
|
4250
|
+
this.channelRegistry &&
|
|
4251
|
+
!this.channelRegistry.isChannelOpen(triggerChannel.channelId);
|
|
4252
|
+
if (addressedWhileClosed) {
|
|
4253
|
+
const descriptor = this.channelRegistry.getDescriptor(triggerChannel.channelId);
|
|
4254
|
+
const messageId = typeof event.origin?.messageId === 'string'
|
|
4255
|
+
? event.origin.messageId
|
|
4256
|
+
: event.eventId;
|
|
4257
|
+
const maxBackscroll = descriptor?.capabilities?.history?.maxMessages ?? 0;
|
|
4258
|
+
content.push({
|
|
4259
|
+
type: 'text',
|
|
4260
|
+
text: `\n[Channel invitation] You were addressed in closed channel ` +
|
|
4261
|
+
`"${triggerChannel.label ?? triggerChannel.channelId}" (${triggerChannel.channelId}, server ${event.serverId}). ` +
|
|
4262
|
+
`You received this one message without subscribing. ` +
|
|
4263
|
+
(maxBackscroll > 0
|
|
4264
|
+
? `To subscribe, call channel_open with channelId, serverId "${event.serverId}", backscroll (0-${maxBackscroll}), and beforeMessageId "${messageId}". `
|
|
4265
|
+
: `To subscribe, call channel_open with this channelId and serverId "${event.serverId}". `) +
|
|
4266
|
+
`You may reply once without subscribing. To remain closed, call channel_decline ` +
|
|
4267
|
+
`with channelId, serverId "${event.serverId}", and messageId "${messageId}"; ` +
|
|
4268
|
+
`optionally set acknowledge to a surface value such as 👀.`,
|
|
4269
|
+
});
|
|
4270
|
+
metadata.channelInvitation = true;
|
|
4271
|
+
metadata.channelOpen = false;
|
|
4272
|
+
metadata.channelId = triggerChannel.channelId;
|
|
4273
|
+
metadata.invitationMessageId = messageId;
|
|
2709
4274
|
}
|
|
4275
|
+
const id = this.addMessage('user', content, metadata);
|
|
4276
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
|
|
2710
4277
|
if (event.triggerInference) {
|
|
2711
4278
|
// Default broadcast excludes conversation forks (channel-driven).
|
|
2712
4279
|
const targetAgents = event.targetAgents
|
|
@@ -2771,7 +4338,8 @@ export class AgentFramework {
|
|
|
2771
4338
|
// Clear pending (we'll re-add if inference doesn't run)
|
|
2772
4339
|
this.pendingRequests = [];
|
|
2773
4340
|
// Check each agent
|
|
2774
|
-
for (const [agentName,
|
|
4341
|
+
for (const [agentName, queuedRequests] of requestsByAgent) {
|
|
4342
|
+
let requests = queuedRequests;
|
|
2775
4343
|
const agent = this.agents.get(agentName);
|
|
2776
4344
|
if (!agent) {
|
|
2777
4345
|
// Agent not found — request is orphaned. Emit warning and drop.
|
|
@@ -2790,7 +4358,13 @@ export class AgentFramework {
|
|
|
2790
4358
|
if (agent.state.status === 'inferring' || agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') {
|
|
2791
4359
|
// Re-queue requests, but warn if they've been pending too long
|
|
2792
4360
|
const oldest = Math.min(...requests.map(r => r.timestamp));
|
|
2793
|
-
if (now - oldest > STALE_REQUEST_MS
|
|
4361
|
+
if (now - oldest > STALE_REQUEST_MS &&
|
|
4362
|
+
(this.staleWarnAt.get(agentName) ?? 0) < now - 60_000) {
|
|
4363
|
+
// Trace and stderr share the throttle. The old code throttled only
|
|
4364
|
+
// stderr, so a long-running tool call generated one trace per poll
|
|
4365
|
+
// (up to 100/sec after the scheduler backoff), adding avoidable work
|
|
4366
|
+
// precisely while the agent was already under pressure.
|
|
4367
|
+
this.staleWarnAt.set(agentName, now);
|
|
2794
4368
|
this.emitTrace({
|
|
2795
4369
|
type: 'inference:request_stale',
|
|
2796
4370
|
agentName,
|
|
@@ -2798,16 +4372,21 @@ export class AgentFramework {
|
|
|
2798
4372
|
requestCount: requests.length,
|
|
2799
4373
|
oldestRequestAge: now - oldest,
|
|
2800
4374
|
});
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
this.staleWarnAt.set(agentName, now);
|
|
2804
|
-
console.error(`[inference-stale] agent=${agentName} busy (${agent.state.status}) — ` +
|
|
2805
|
-
`${requests.length} request(s) waiting ${Math.round((now - oldest) / 1000)}s`);
|
|
2806
|
-
}
|
|
4375
|
+
console.error(`[inference-stale] agent=${agentName} busy (${agent.state.status}) — ` +
|
|
4376
|
+
`${requests.length} request(s) waiting ${Math.round((now - oldest) / 1000)}s`);
|
|
2807
4377
|
}
|
|
2808
4378
|
this.pendingRequests.push(...requests);
|
|
2809
4379
|
continue;
|
|
2810
4380
|
}
|
|
4381
|
+
if (this.discordAwarenessBarrier) {
|
|
4382
|
+
this.pendingRequests.push(...requests);
|
|
4383
|
+
continue;
|
|
4384
|
+
}
|
|
4385
|
+
requests = requests.filter((request) => request.reason !== 'context_budget_restart'
|
|
4386
|
+
|| request.contextBudgetRestart?.dispatchKind !== 'primary_summary_fallback_retry');
|
|
4387
|
+
if (requests.length === 0) {
|
|
4388
|
+
continue;
|
|
4389
|
+
}
|
|
2811
4390
|
// Check policy
|
|
2812
4391
|
if (!this.inferencePolicy.shouldInfer(agentName, requests, state)) {
|
|
2813
4392
|
// Loud drop: a queued request that dies here is otherwise invisible —
|
|
@@ -2818,7 +4397,13 @@ export class AgentFramework {
|
|
|
2818
4397
|
continue;
|
|
2819
4398
|
}
|
|
2820
4399
|
// Start streaming inference (non-blocking — driveStream runs in background)
|
|
2821
|
-
const trigger = requests[0];
|
|
4400
|
+
const trigger = requests.find((request) => request.primarySummaryFallbackRetry) ?? requests[0];
|
|
4401
|
+
if (trigger.primarySummaryFallbackRetry) {
|
|
4402
|
+
for (const request of requests) {
|
|
4403
|
+
if (request !== trigger)
|
|
4404
|
+
this.pendingRequests.push(request);
|
|
4405
|
+
}
|
|
4406
|
+
}
|
|
2822
4407
|
// Route this turn's auto-published speech to the channel that triggered
|
|
2823
4408
|
// it (item-3 redux). A batched wake may carry several triggering channels
|
|
2824
4409
|
// (messages arrived in >1 channel while the agent was busy/idle) — reply
|
|
@@ -2833,7 +4418,7 @@ export class AgentFramework {
|
|
|
2833
4418
|
}
|
|
2834
4419
|
async startAgentStream(agent, trigger, attempt = 0) {
|
|
2835
4420
|
// Record turn checkpoint before inference (only on first attempt, not retries)
|
|
2836
|
-
if (attempt === 0) {
|
|
4421
|
+
if (attempt === 0 && trigger?.reason !== 'primary-summary-fallback-retry') {
|
|
2837
4422
|
this.recordTurnCheckpoint(agent.name);
|
|
2838
4423
|
this.redoStacks.delete(agent.name); // new work invalidates redo
|
|
2839
4424
|
}
|
|
@@ -2853,45 +4438,180 @@ export class AgentFramework {
|
|
|
2853
4438
|
this.emitTrace({ type: 'inference:started', agentName: agent.name });
|
|
2854
4439
|
this.eventGate?.onInferenceStarted(agent.name);
|
|
2855
4440
|
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
|
|
4441
|
+
const requestId = `${agent.name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2856
4442
|
try {
|
|
2857
|
-
const
|
|
4443
|
+
const requestSnapshot = this.captureInferenceToolSnapshot(agent);
|
|
4444
|
+
const allTools = this.getToolsForAgent(agent.name, requestSnapshot);
|
|
2858
4445
|
const tools = allTools.filter((t) => agent.canUseTool(t.name));
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
let
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
4446
|
+
const fallbackConfig = this.primarySummaryFallbackConfig(agent);
|
|
4447
|
+
const retryDispatch = trigger?.primarySummaryFallbackRetry;
|
|
4448
|
+
let compiledRequest;
|
|
4449
|
+
let artifacts;
|
|
4450
|
+
let branch = agent.getCurrentBranchGeneration();
|
|
4451
|
+
if (retryDispatch) {
|
|
4452
|
+
if (!fallbackConfig.enabled) {
|
|
4453
|
+
await this.holdPrimarySummaryRequest(agent, retryDispatch.originalRequestId, 'primary_summary_fallback_disabled_before_retry');
|
|
4454
|
+
agent.reset();
|
|
4455
|
+
this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
|
|
4456
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
4457
|
+
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
|
|
4458
|
+
return;
|
|
4459
|
+
}
|
|
4460
|
+
const claim = await this.claimPrimarySummaryRetryIntent(retryDispatch.originalRequestId, retryDispatch.intentId, requestId);
|
|
4461
|
+
if (claim !== 'claimed') {
|
|
4462
|
+
await this.holdPrimarySummaryRequest(agent, retryDispatch.originalRequestId, claim === 'duplicate'
|
|
4463
|
+
? 'primary_summary_fallback_duplicate_retry_blocked'
|
|
4464
|
+
: 'primary_summary_fallback_retry_intent_missing', { intentId: retryDispatch.intentId, retryRequestId: requestId });
|
|
4465
|
+
agent.reset();
|
|
4466
|
+
this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
|
|
4467
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
4468
|
+
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
|
|
4469
|
+
return;
|
|
4470
|
+
}
|
|
4471
|
+
if (!branch ||
|
|
4472
|
+
branch.id !== retryDispatch.branch.id ||
|
|
4473
|
+
branch.generation !== retryDispatch.branch.generation) {
|
|
4474
|
+
await this.holdPrimarySummaryRequest(agent, retryDispatch.originalRequestId, 'primary_summary_fallback_branch_changed_before_retry', {
|
|
4475
|
+
expectedBranchId: retryDispatch.branch.id,
|
|
4476
|
+
expectedGeneration: retryDispatch.branch.generation,
|
|
4477
|
+
actualBranchId: branch?.id,
|
|
4478
|
+
actualGeneration: branch?.generation,
|
|
4479
|
+
});
|
|
4480
|
+
agent.reset();
|
|
4481
|
+
this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
|
|
4482
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
4483
|
+
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
|
|
4484
|
+
return;
|
|
4485
|
+
}
|
|
4486
|
+
const rebuilt = agent.buildPrimarySummaryRawExpansionRequest(retryDispatch.originalArtifacts, retryDispatch.candidateSummaries, tools);
|
|
4487
|
+
compiledRequest = rebuilt.request;
|
|
4488
|
+
artifacts = rebuilt.artifacts;
|
|
4489
|
+
const rebuiltHash = this.hashJson(compiledRequest);
|
|
4490
|
+
if (rebuiltHash !== retryDispatch.expectedRetryRequestHash ||
|
|
4491
|
+
this.primarySummaryProjectionKeys(artifacts.compileResult.primarySummaryProjection ?? null).join('\u0000') !==
|
|
4492
|
+
retryDispatch.originalProjectionKeys.join('\u0000')) {
|
|
4493
|
+
await this.holdPrimarySummaryRequest(agent, retryDispatch.originalRequestId, 'primary_summary_fallback_revalidation_failed_before_retry', {
|
|
4494
|
+
expectedRetryRequestHash: retryDispatch.expectedRetryRequestHash,
|
|
4495
|
+
actualRetryRequestHash: rebuiltHash,
|
|
4496
|
+
});
|
|
4497
|
+
agent.reset();
|
|
4498
|
+
this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
|
|
4499
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
4500
|
+
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
|
|
4501
|
+
return;
|
|
4502
|
+
}
|
|
4503
|
+
const retryRecord = this.buildPrimarySummaryRequestRecord(agent, requestId, 'primary_summary_fallback_retry', compiledRequest, artifacts, branch, undefined, false, 0, undefined, 'pending');
|
|
4504
|
+
await this.persistPrimarySummaryRequestRecord(retryRecord);
|
|
4505
|
+
const admittedTokens = this.primarySummaryAdmissionBoundTokens(retryDispatch.originalRequestInputBoundTokens, compiledRequest, retryDispatch.originalProviderInputTokens);
|
|
4506
|
+
if (admittedTokens > retryDispatch.requestBudgetTokens) {
|
|
4507
|
+
await this.holdPrimarySummaryRequest(agent, requestId, 'primary_summary_fallback_retry_over_budget', {
|
|
4508
|
+
admittedTokens,
|
|
4509
|
+
requestBudgetTokens: retryDispatch.requestBudgetTokens,
|
|
4510
|
+
retryRequestHash: rebuiltHash,
|
|
4511
|
+
});
|
|
4512
|
+
await this.updatePrimarySummaryRequestRecord(retryDispatch.originalRequestId, (record) => ({
|
|
4513
|
+
...record,
|
|
4514
|
+
fallbackStatus: 'held',
|
|
4515
|
+
fallbackHeldReason: 'primary_summary_fallback_retry_over_budget',
|
|
4516
|
+
}));
|
|
4517
|
+
agent.reset();
|
|
4518
|
+
this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
|
|
4519
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
4520
|
+
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
|
|
4521
|
+
return;
|
|
2871
4522
|
}
|
|
2872
4523
|
}
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
4524
|
+
else {
|
|
4525
|
+
// Gather context from modules (pull-based) and MCPL hooks (push-based)
|
|
4526
|
+
// Both produce ContextInjection[] that get merged before inference.
|
|
4527
|
+
let injections;
|
|
4528
|
+
// Module gatherContext (fail-open, per-module timeout — the module's
|
|
4529
|
+
// contextTimeoutMs, else the registry default). Injections are
|
|
4530
|
+
// channel-scoped via scopeInjectionsForAgent, matching the MCPL hook
|
|
4531
|
+
// injections below, so conversation forks don't see cross-channel
|
|
4532
|
+
// content.
|
|
2878
4533
|
try {
|
|
2879
|
-
const
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
injections = injections ? [...injections, ...hookInjections] : hookInjections;
|
|
4534
|
+
const moduleInjections = this.scopeInjectionsForAgent(agent.name, await this.moduleRegistry.gatherContext(agent.name));
|
|
4535
|
+
if (moduleInjections.length > 0) {
|
|
4536
|
+
injections = moduleInjections;
|
|
2883
4537
|
}
|
|
2884
4538
|
}
|
|
2885
4539
|
catch (error) {
|
|
2886
|
-
console.error('
|
|
4540
|
+
console.error('Module gatherContext error:', error);
|
|
4541
|
+
}
|
|
4542
|
+
// MCPL beforeInference hooks (fail-open)
|
|
4543
|
+
if (this.hookOrchestrator) {
|
|
4544
|
+
try {
|
|
4545
|
+
const hookParams = this.buildBeforeInferenceParams(agent, trigger);
|
|
4546
|
+
const hookInjections = this.scopeInjectionsForAgent(agent.name, await this.hookOrchestrator.beforeInference(hookParams));
|
|
4547
|
+
if (hookInjections.length > 0) {
|
|
4548
|
+
injections = injections ? [...injections, ...hookInjections] : hookInjections;
|
|
4549
|
+
}
|
|
4550
|
+
}
|
|
4551
|
+
catch (error) {
|
|
4552
|
+
console.error('beforeInference hook error:', error);
|
|
4553
|
+
}
|
|
4554
|
+
}
|
|
4555
|
+
const prepared = await agent.prepareActivationRequest(tools, injections);
|
|
4556
|
+
compiledRequest = prepared.request;
|
|
4557
|
+
artifacts = prepared.artifacts;
|
|
4558
|
+
branch = agent.getCurrentBranchGeneration();
|
|
4559
|
+
if (fallbackConfig.enabled) {
|
|
4560
|
+
const record = this.buildPrimarySummaryRequestRecord(agent, requestId, 'primary', compiledRequest, artifacts, branch, undefined, false, 0, undefined, 'pending');
|
|
4561
|
+
await this.persistPrimarySummaryRequestRecord(record);
|
|
4562
|
+
const expandedByQuarantine = artifacts.compileResult.primarySummaryProjection?.selectedSummaries.some((selection) => selection.renderedAs === 'raw_expansion') ?? false;
|
|
4563
|
+
if (expandedByQuarantine) {
|
|
4564
|
+
const admittedTokens = this.estimateRequestCompleteBoundTokens(compiledRequest);
|
|
4565
|
+
if (admittedTokens > fallbackConfig.requestBudgetTokens) {
|
|
4566
|
+
await this.holdPrimarySummaryRequest(agent, requestId, 'primary_summary_quarantine_over_budget', {
|
|
4567
|
+
admittedTokens,
|
|
4568
|
+
requestBudgetTokens: fallbackConfig.requestBudgetTokens,
|
|
4569
|
+
});
|
|
4570
|
+
agent.reset();
|
|
4571
|
+
this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
|
|
4572
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
4573
|
+
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
|
|
4574
|
+
return;
|
|
4575
|
+
}
|
|
4576
|
+
}
|
|
2887
4577
|
}
|
|
2888
4578
|
}
|
|
2889
|
-
const { stream
|
|
2890
|
-
|
|
4579
|
+
const { stream } = agent.startStreamFromRequest(compiledRequest, artifacts);
|
|
4580
|
+
this.activeStreamContexts.set(agent.name, {
|
|
4581
|
+
requestId,
|
|
4582
|
+
trigger,
|
|
4583
|
+
compiledRequest,
|
|
4584
|
+
artifacts,
|
|
4585
|
+
});
|
|
4586
|
+
const handle = this.driveStream(agent, stream, requestSnapshot, requestId, trigger, attempt, compiledRequest, artifacts);
|
|
2891
4587
|
this.activeStreams.set(agent.name, handle);
|
|
2892
4588
|
}
|
|
2893
4589
|
catch (error) {
|
|
2894
4590
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
4591
|
+
if (trigger?.primarySummaryFallbackRetry) {
|
|
4592
|
+
await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
|
|
4593
|
+
...record,
|
|
4594
|
+
finalStatus: 'held',
|
|
4595
|
+
fallbackHeldReason: err.message,
|
|
4596
|
+
}));
|
|
4597
|
+
await this.updatePrimarySummaryRequestRecord(trigger.primarySummaryFallbackRetry.originalRequestId, (record) => ({
|
|
4598
|
+
...record,
|
|
4599
|
+
finalStatus: 'held',
|
|
4600
|
+
fallbackStatus: 'held',
|
|
4601
|
+
fallbackHeldReason: err.message,
|
|
4602
|
+
}));
|
|
4603
|
+
this.opsAlert('refusal-held', agent.name, err.message, {
|
|
4604
|
+
data: { requestId, originalRequestId: trigger.primarySummaryFallbackRetry.originalRequestId },
|
|
4605
|
+
});
|
|
4606
|
+
agent.reset();
|
|
4607
|
+
this.settleAgent(agent.name, {
|
|
4608
|
+
stopReason: 'completed',
|
|
4609
|
+
speech: '',
|
|
4610
|
+
});
|
|
4611
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
4612
|
+
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
|
|
4613
|
+
return;
|
|
4614
|
+
}
|
|
2895
4615
|
this.emitTrace({
|
|
2896
4616
|
type: 'inference:failed',
|
|
2897
4617
|
agentName: agent.name,
|
|
@@ -2930,18 +4650,147 @@ export class AgentFramework {
|
|
|
2930
4650
|
}
|
|
2931
4651
|
}
|
|
2932
4652
|
}
|
|
2933
|
-
async driveStream(agent, stream, trigger, attempt = 0, compiledRequest) {
|
|
4653
|
+
async driveStream(agent, stream, requestSnapshot, requestId, trigger, attempt = 0, compiledRequest, artifacts) {
|
|
2934
4654
|
const startTime = Date.now();
|
|
2935
|
-
const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2936
4655
|
const myStreamId = agent.streamId;
|
|
2937
4656
|
let hadToolCalls = false;
|
|
4657
|
+
let executedToolCalls = 0;
|
|
4658
|
+
const primarySummaryConfig = this.primarySummaryFallbackConfig(agent);
|
|
4659
|
+
const primarySummaryRetry = trigger?.primarySummaryFallbackRetry;
|
|
4660
|
+
let primarySummaryQuarantinePersisted = false;
|
|
4661
|
+
const fallbackRetryRequestForLog = primarySummaryRetry && compiledRequest
|
|
4662
|
+
? this.summarizePrimarySummaryRetryRequest(compiledRequest)
|
|
4663
|
+
: compiledRequest ?? { note: 'streaming request' };
|
|
4664
|
+
// ---- Present-while-acting turn state ---------------------------------
|
|
4665
|
+
// Output locus for the current CONVERSATIONAL ROUND: resolved lazily and
|
|
4666
|
+
// kept stable across tool-only rounds. A channel message injected at a
|
|
4667
|
+
// tool boundary replaces the pin before the next model round, because a
|
|
4668
|
+
// single provider inference can now contain several conversations. The
|
|
4669
|
+
// pin lives in `turnLocusPins` so it survives a context-budget restart. A
|
|
4670
|
+
// null resolution is not pinned.
|
|
4671
|
+
if (trigger?.reason !== 'context_budget_restart' && trigger?.reason !== 'primary-summary-fallback-retry') {
|
|
4672
|
+
this.turnLocusPins.delete(agent.name);
|
|
4673
|
+
this.midTurnRoutingResets.delete(agent.name);
|
|
4674
|
+
}
|
|
4675
|
+
const resolveTurnLocus = () => {
|
|
4676
|
+
let locus = this.turnLocusPins.get(agent.name) ?? null;
|
|
4677
|
+
if (locus === null && this.channelRegistry) {
|
|
4678
|
+
locus = this.channelRegistry.resolveLocus(agent.name);
|
|
4679
|
+
if (locus !== null)
|
|
4680
|
+
this.turnLocusPins.set(agent.name, locus);
|
|
4681
|
+
}
|
|
4682
|
+
return locus;
|
|
4683
|
+
};
|
|
4684
|
+
// Ordered delivery chain for live-routed prose. Links are enqueued
|
|
4685
|
+
// WITHOUT awaiting in the stream-event loop — an awaited network post
|
|
4686
|
+
// here would stall consumption of the next round's events by
|
|
4687
|
+
// segments × RTT on every prose-bearing round. The 'complete' case
|
|
4688
|
+
// awaits the chain before routing trailing prose, so in-channel ordering
|
|
4689
|
+
// is preserved end-to-end. Each link catches its own error: one failed
|
|
4690
|
+
// post must not silence the rest of the turn.
|
|
4691
|
+
let turnSpeechChain = Promise.resolve();
|
|
4692
|
+
const enqueueSpeech = (text, locus) => {
|
|
4693
|
+
turnSpeechChain = turnSpeechChain
|
|
4694
|
+
.then(async () => {
|
|
4695
|
+
await this.channelRegistry.routeSpeech(agent.name, text, locus);
|
|
4696
|
+
})
|
|
4697
|
+
.catch((err) => console.error('mid-turn speech routing failed:', err));
|
|
4698
|
+
};
|
|
4699
|
+
// Sticky explicit-send suppression: prose after send_message stays quiet
|
|
4700
|
+
// to prevent a redundant "sent it" postscript. Fresh injected input
|
|
4701
|
+
// clears it, because the following prose is a reply to a new message.
|
|
4702
|
+
let turnSilenced = false;
|
|
4703
|
+
// Live routing is only trusted when the membrane provides verbatim
|
|
4704
|
+
// round-scoped blocks (roundContent, native tool mode, membrane ≥0.5.64).
|
|
4705
|
+
// The fallback `preamble` is CUMULATIVE in XML mode (assistant prefill +
|
|
4706
|
+
// all earlier rounds' prose and raw tool/thinking XML) — live-routing it
|
|
4707
|
+
// would repost the transcript and leak thinking every round. When no
|
|
4708
|
+
// round was live-routed, the 'complete' case falls back to the
|
|
4709
|
+
// historical whole-turn routing so fallback-mode prose is still
|
|
4710
|
+
// delivered exactly once, at turn end.
|
|
4711
|
+
let liveProseRouting = false;
|
|
2938
4712
|
// Typing indicator: show "<agent> is typing…" in the channel she's
|
|
2939
4713
|
// responding to, for the whole duration of this turn. Started here (paired
|
|
2940
4714
|
// with the finally below, so it can never leak) and refreshed on a 7s
|
|
2941
4715
|
// interval by the ChannelRegistry until stopped on any exit path.
|
|
2942
|
-
|
|
4716
|
+
let typingChannel = this.channelRegistry
|
|
4717
|
+
? trigger?.channelId ?? this.channelRegistry.getDefaultPublishChannel()
|
|
4718
|
+
: null;
|
|
2943
4719
|
if (typingChannel)
|
|
2944
4720
|
this.channelRegistry.startTyping(typingChannel);
|
|
4721
|
+
const adoptInjectedRound = () => {
|
|
4722
|
+
if (!this.midTurnRoutingResets.has(agent.name))
|
|
4723
|
+
return;
|
|
4724
|
+
const nextLocus = this.midTurnRoutingResets.get(agent.name) ?? null;
|
|
4725
|
+
this.midTurnRoutingResets.delete(agent.name);
|
|
4726
|
+
// A new human/agent message, not merely a tool result, means any earlier
|
|
4727
|
+
// explicit delivery has completed its conversational job.
|
|
4728
|
+
turnSilenced = false;
|
|
4729
|
+
if (nextLocus) {
|
|
4730
|
+
this.turnLocusPins.set(agent.name, nextLocus);
|
|
4731
|
+
if (typingChannel !== nextLocus) {
|
|
4732
|
+
if (typingChannel)
|
|
4733
|
+
this.channelRegistry?.stopTyping(typingChannel);
|
|
4734
|
+
typingChannel = nextLocus;
|
|
4735
|
+
this.channelRegistry?.startTyping(nextLocus);
|
|
4736
|
+
}
|
|
4737
|
+
}
|
|
4738
|
+
};
|
|
4739
|
+
const persistPrimarySummaryRetryQuarantine = async () => {
|
|
4740
|
+
if (!primarySummaryRetry || primarySummaryQuarantinePersisted || !artifacts)
|
|
4741
|
+
return true;
|
|
4742
|
+
const validation = await this.validatePrimarySummaryRetryMutation(agent, requestId, primarySummaryRetry, compiledRequest, artifacts, 'before_quarantine');
|
|
4743
|
+
if (!validation.ok) {
|
|
4744
|
+
await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, validation.reason, validation.data, { emitChronicleMarker: false });
|
|
4745
|
+
stream.cancel();
|
|
4746
|
+
agent.reset();
|
|
4747
|
+
this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
|
|
4748
|
+
return false;
|
|
4749
|
+
}
|
|
4750
|
+
try {
|
|
4751
|
+
const quarantinePrimarySummaryForPrimaryLane = agent.getContextManager().quarantinePrimarySummaryForPrimaryLane;
|
|
4752
|
+
if (typeof quarantinePrimarySummaryForPrimaryLane !== 'function') {
|
|
4753
|
+
throw new Error('Primary summary quarantine is unavailable in the active context strategy');
|
|
4754
|
+
}
|
|
4755
|
+
await quarantinePrimarySummaryForPrimaryLane.call(agent.getContextManager(), artifacts.contract, primarySummaryRetry.candidateSummaries);
|
|
4756
|
+
primarySummaryQuarantinePersisted = true;
|
|
4757
|
+
await this.recordPrimarySummaryRetryQuarantine(primarySummaryRetry.originalRequestId, primarySummaryRetry.intentId);
|
|
4758
|
+
return true;
|
|
4759
|
+
}
|
|
4760
|
+
catch (error) {
|
|
4761
|
+
const reason = 'primary_summary_fallback_quarantine_failed';
|
|
4762
|
+
await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, reason, {
|
|
4763
|
+
error: error instanceof Error ? error.message : String(error),
|
|
4764
|
+
});
|
|
4765
|
+
stream.cancel();
|
|
4766
|
+
agent.reset();
|
|
4767
|
+
this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
|
|
4768
|
+
return false;
|
|
4769
|
+
}
|
|
4770
|
+
};
|
|
4771
|
+
const persistPrimarySummaryRetryOutput = async (phase, blocks, participant = agent.name, metadata) => {
|
|
4772
|
+
if (!primarySummaryRetry || !artifacts)
|
|
4773
|
+
return true;
|
|
4774
|
+
if (blocks.length === 0)
|
|
4775
|
+
return true;
|
|
4776
|
+
const validation = await this.validatePrimarySummaryRetryMutation(agent, requestId, primarySummaryRetry, compiledRequest, artifacts, `before_${phase}`);
|
|
4777
|
+
if (!validation.ok) {
|
|
4778
|
+
await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, validation.reason, validation.data, { emitChronicleMarker: false });
|
|
4779
|
+
stream.cancel();
|
|
4780
|
+
agent.reset();
|
|
4781
|
+
this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
|
|
4782
|
+
return false;
|
|
4783
|
+
}
|
|
4784
|
+
const messageId = agent.getContextManager().addMessage(participant, blocks, metadata);
|
|
4785
|
+
await this.recordPrimarySummaryRetryOutput(primarySummaryRetry.originalRequestId, primarySummaryRetry.intentId, {
|
|
4786
|
+
phase,
|
|
4787
|
+
contentHash: this.primarySummaryFallbackOutputPhaseHash(blocks),
|
|
4788
|
+
messageId,
|
|
4789
|
+
blockTypes: this.primarySummaryFallbackBlockTypes(blocks),
|
|
4790
|
+
persistedAt: Date.now(),
|
|
4791
|
+
});
|
|
4792
|
+
return true;
|
|
4793
|
+
};
|
|
2945
4794
|
try {
|
|
2946
4795
|
for await (const event of stream) {
|
|
2947
4796
|
this.touchEphemeralRun(agent.name, true);
|
|
@@ -2967,8 +4816,8 @@ export class AgentFramework {
|
|
|
2967
4816
|
break;
|
|
2968
4817
|
}
|
|
2969
4818
|
case 'tool-calls': {
|
|
4819
|
+
adoptInjectedRound();
|
|
2970
4820
|
hadToolCalls = true;
|
|
2971
|
-
this.recordEphemeralToolCalls(agent.name, event.calls.length);
|
|
2972
4821
|
this.emitTrace({
|
|
2973
4822
|
type: 'inference:tool_calls_yielded',
|
|
2974
4823
|
agentName: agent.name,
|
|
@@ -3002,13 +4851,75 @@ export class AgentFramework {
|
|
|
3002
4851
|
// Truncated responses yield 'complete' with stop_reason 'max_tokens' directly.
|
|
3003
4852
|
// So all calls here have valid input — {} is legitimate for parameterless tools.
|
|
3004
4853
|
agent.enterWaitingForTools(event.calls, stream);
|
|
4854
|
+
if (!(await persistPrimarySummaryRetryQuarantine())) {
|
|
4855
|
+
return;
|
|
4856
|
+
}
|
|
4857
|
+
if (primarySummaryRetry) {
|
|
4858
|
+
const phase = `tool_round_${event.context.depth ?? 0}`;
|
|
4859
|
+
if (!(await persistPrimarySummaryRetryOutput(phase, assistantBlocks))) {
|
|
4860
|
+
return;
|
|
4861
|
+
}
|
|
4862
|
+
this.pendingAssistantBlocks.delete(agent.name);
|
|
4863
|
+
}
|
|
4864
|
+
executedToolCalls += event.calls.length;
|
|
4865
|
+
this.recordEphemeralToolCalls(agent.name, event.calls.length);
|
|
3005
4866
|
for (const call of event.calls) {
|
|
4867
|
+
if (primarySummaryRetry) {
|
|
4868
|
+
const validation = await this.validatePrimarySummaryRetryMutation(agent, requestId, primarySummaryRetry, compiledRequest, artifacts, `before_tool_dispatch_${call.id}`);
|
|
4869
|
+
if (!validation.ok) {
|
|
4870
|
+
await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, validation.reason, validation.data, { emitChronicleMarker: false });
|
|
4871
|
+
stream.cancel();
|
|
4872
|
+
agent.reset();
|
|
4873
|
+
this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
|
|
4874
|
+
return;
|
|
4875
|
+
}
|
|
4876
|
+
await this.recordPrimarySummaryRetryToolDispatch(primarySummaryRetry.originalRequestId, primarySummaryRetry.intentId, call);
|
|
4877
|
+
}
|
|
3006
4878
|
this.dispatchToolCall(agent.name, call);
|
|
3007
4879
|
}
|
|
4880
|
+
// Speak-while-acting: route THIS round's prose to the locus NOW
|
|
4881
|
+
// instead of batching every segment to the end of the turn. Long
|
|
4882
|
+
// tool-using turns (robot control, live sessions) otherwise dump
|
|
4883
|
+
// the whole narration in one blob when the turn finally settles —
|
|
4884
|
+
// and, worse, make "talking" and "acting" feel mutually exclusive
|
|
4885
|
+
// to the agent. Mid-turn prose is speech (delivered live, in
|
|
4886
|
+
// order, via the turn's speech chain — never awaited here); the
|
|
4887
|
+
// `think` tool remains the private channel. Silencing is STICKY
|
|
4888
|
+
// from the round it occurs (see SILENCING_TOOLS). Only rounds
|
|
4889
|
+
// with verbatim roundContent are live-routed (see liveProseRouting
|
|
4890
|
+
// note above — the fallback preamble is cumulative in XML mode).
|
|
4891
|
+
if (this.channelRegistry) {
|
|
4892
|
+
const roundToolNames = event.calls.map((c) => c.name);
|
|
4893
|
+
const hasSameRoundPrivateThink = roundToolNames.includes('think') &&
|
|
4894
|
+
requestSnapshot.sameRoundThinkTextPolicy === 'private';
|
|
4895
|
+
if (roundToolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)))) {
|
|
4896
|
+
turnSilenced = true;
|
|
4897
|
+
}
|
|
4898
|
+
if (roundContent && roundContent.length > 0) {
|
|
4899
|
+
liveProseRouting = true;
|
|
4900
|
+
const roundSegments = splitProseSegments(assistantBlocks);
|
|
4901
|
+
if (roundSegments.length > 0) {
|
|
4902
|
+
if (turnSilenced) {
|
|
4903
|
+
console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`);
|
|
4904
|
+
}
|
|
4905
|
+
else if (hasSameRoundPrivateThink) {
|
|
4906
|
+
console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (same_round_think_text_policy=private)`);
|
|
4907
|
+
}
|
|
4908
|
+
else {
|
|
4909
|
+
const locus = resolveTurnLocus();
|
|
4910
|
+
console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> routing ${roundSegments.length} prose segment(s) live -> ${locus ?? '(default)'}`);
|
|
4911
|
+
for (const seg of roundSegments) {
|
|
4912
|
+
enqueueSpeech(seg, locus);
|
|
4913
|
+
}
|
|
4914
|
+
}
|
|
4915
|
+
}
|
|
4916
|
+
}
|
|
4917
|
+
}
|
|
3008
4918
|
// Stream's async iterator blocks on next() until provideToolResults() is called
|
|
3009
4919
|
break;
|
|
3010
4920
|
}
|
|
3011
4921
|
case 'complete': {
|
|
4922
|
+
adoptInjectedRound();
|
|
3012
4923
|
const durationMs = Date.now() - startTime;
|
|
3013
4924
|
const response = event.response;
|
|
3014
4925
|
// If the agent is still waiting_for_tools when 'complete' fires
|
|
@@ -3045,6 +4956,167 @@ export class AgentFramework {
|
|
|
3045
4956
|
agent.getContextManager().addMessage('user', toolResultContent);
|
|
3046
4957
|
}
|
|
3047
4958
|
}
|
|
4959
|
+
const lastToolIdx = response.content.reduce((last, b, i) => b.type === 'tool_use' || b.type === 'tool_result' ? i : last, -1);
|
|
4960
|
+
const terminalContent = lastToolIdx >= 0
|
|
4961
|
+
? response.content.slice(lastToolIdx + 1)
|
|
4962
|
+
: response.content;
|
|
4963
|
+
const heldAssistantContent = response.content;
|
|
4964
|
+
const visibleAssistantOutput = this.hasVisibleAssistantOutput(response.content);
|
|
4965
|
+
const providerInputTokens = response.usage?.inputTokens ??
|
|
4966
|
+
response.details?.usage?.inputTokens;
|
|
4967
|
+
if (primarySummaryConfig.enabled && compiledRequest && artifacts && response.stopReason === 'refusal') {
|
|
4968
|
+
const stopDetails = response.raw?.response?.stop_details;
|
|
4969
|
+
const category = stopDetails?.category ?? 'unknown';
|
|
4970
|
+
console.error(`[inference-refusal] agent=${agent.name} category=${category}` +
|
|
4971
|
+
(stopDetails?.explanation ? ` explanation=${stopDetails.explanation}` : ''));
|
|
4972
|
+
this.noteRefusal(agent.name, category, response.details?.usage
|
|
4973
|
+
? {
|
|
4974
|
+
input: response.details.usage.inputTokens,
|
|
4975
|
+
output: response.details.usage.outputTokens,
|
|
4976
|
+
}
|
|
4977
|
+
: undefined);
|
|
4978
|
+
const currentRecord = await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
|
|
4979
|
+
...record,
|
|
4980
|
+
stopReason: response.stopReason,
|
|
4981
|
+
providerInputTokens,
|
|
4982
|
+
visibleAssistantOutput,
|
|
4983
|
+
executedToolCalls,
|
|
4984
|
+
finalStatus: 'refusal',
|
|
4985
|
+
}));
|
|
4986
|
+
const holdReasonData = {
|
|
4987
|
+
category,
|
|
4988
|
+
visibleAssistantOutput,
|
|
4989
|
+
executedToolCalls,
|
|
4990
|
+
candidateLimit: primarySummaryConfig.maxNewSummaries,
|
|
4991
|
+
};
|
|
4992
|
+
if (primarySummaryRetry) {
|
|
4993
|
+
const reason = visibleAssistantOutput
|
|
4994
|
+
? 'primary_summary_fallback_retry_partial_output'
|
|
4995
|
+
: executedToolCalls > 0
|
|
4996
|
+
? 'primary_summary_fallback_retry_tool_already_executed'
|
|
4997
|
+
: 'primary_summary_fallback_retry_refusal';
|
|
4998
|
+
if (visibleAssistantOutput || executedToolCalls > 0) {
|
|
4999
|
+
await this.persistPrimarySummaryHeldOutput(agent, requestId, reason, {
|
|
5000
|
+
stopReason: response.stopReason,
|
|
5001
|
+
providerInputTokens,
|
|
5002
|
+
visibleAssistantOutput,
|
|
5003
|
+
executedToolCalls,
|
|
5004
|
+
assistantBlocks: heldAssistantContent,
|
|
5005
|
+
retryDispatch: primarySummaryRetry,
|
|
5006
|
+
retryPhase: 'refusal_visible_output',
|
|
5007
|
+
holdData: holdReasonData,
|
|
5008
|
+
});
|
|
5009
|
+
}
|
|
5010
|
+
else {
|
|
5011
|
+
await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, reason, holdReasonData);
|
|
5012
|
+
}
|
|
5013
|
+
}
|
|
5014
|
+
else if (visibleAssistantOutput || executedToolCalls > 0 || !currentRecord?.projection) {
|
|
5015
|
+
const reason = visibleAssistantOutput
|
|
5016
|
+
? 'primary_summary_refusal_partial_output'
|
|
5017
|
+
: executedToolCalls > 0
|
|
5018
|
+
? 'primary_summary_refusal_tool_already_executed'
|
|
5019
|
+
: 'primary_summary_refusal_no_projection';
|
|
5020
|
+
if (visibleAssistantOutput || executedToolCalls > 0) {
|
|
5021
|
+
await this.persistPrimarySummaryHeldOutput(agent, requestId, reason, {
|
|
5022
|
+
stopReason: response.stopReason,
|
|
5023
|
+
providerInputTokens,
|
|
5024
|
+
visibleAssistantOutput,
|
|
5025
|
+
executedToolCalls,
|
|
5026
|
+
assistantBlocks: heldAssistantContent,
|
|
5027
|
+
holdData: holdReasonData,
|
|
5028
|
+
});
|
|
5029
|
+
}
|
|
5030
|
+
else {
|
|
5031
|
+
await this.holdPrimarySummaryRequest(agent, requestId, reason, holdReasonData);
|
|
5032
|
+
}
|
|
5033
|
+
}
|
|
5034
|
+
else {
|
|
5035
|
+
const state = this.readPrimarySummaryFallbackState();
|
|
5036
|
+
const baseline = this.latestCompatibleHealthyPrimaryRequest(currentRecord, state);
|
|
5037
|
+
if (!baseline?.projection) {
|
|
5038
|
+
await this.holdPrimarySummaryRequest(agent, requestId, 'primary_summary_refusal_no_compatible_healthy_baseline', holdReasonData);
|
|
5039
|
+
}
|
|
5040
|
+
else {
|
|
5041
|
+
const baselineKeys = new Set(this.primarySummaryProjectionKeys(baseline.projection));
|
|
5042
|
+
const candidates = currentRecord.projection.selectedSummaries
|
|
5043
|
+
.filter((selection) => !baselineKeys.has(this.primarySummaryIdentityKey(selection.identity)))
|
|
5044
|
+
.map((selection) => selection.identity);
|
|
5045
|
+
if (candidates.length === 0) {
|
|
5046
|
+
await this.holdPrimarySummaryRequest(agent, requestId, 'primary_summary_refusal_no_newly_admitted_candidates', holdReasonData);
|
|
5047
|
+
}
|
|
5048
|
+
else if (candidates.length > primarySummaryConfig.maxNewSummaries) {
|
|
5049
|
+
await this.holdPrimarySummaryRequest(agent, requestId, 'primary_summary_refusal_too_many_candidates', { ...holdReasonData, candidateCount: candidates.length });
|
|
5050
|
+
}
|
|
5051
|
+
else {
|
|
5052
|
+
try {
|
|
5053
|
+
const retry = agent.buildPrimarySummaryRawExpansionRequest(artifacts, candidates, compiledRequest.tools ?? []);
|
|
5054
|
+
const retryRequestHash = this.hashJson(retry.request);
|
|
5055
|
+
const admittedTokens = this.primarySummaryAdmissionBoundTokens(currentRecord.requestInputBoundTokens, retry.request, providerInputTokens);
|
|
5056
|
+
if (admittedTokens > primarySummaryConfig.requestBudgetTokens) {
|
|
5057
|
+
await this.holdPrimarySummaryRequest(agent, requestId, 'primary_summary_refusal_retry_over_budget', {
|
|
5058
|
+
...holdReasonData,
|
|
5059
|
+
admittedTokens,
|
|
5060
|
+
requestBudgetTokens: primarySummaryConfig.requestBudgetTokens,
|
|
5061
|
+
});
|
|
5062
|
+
}
|
|
5063
|
+
else {
|
|
5064
|
+
const intentId = `${requestId}:fallback:${Math.random().toString(36).slice(2, 8)}`;
|
|
5065
|
+
await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
|
|
5066
|
+
...record,
|
|
5067
|
+
fallbackIntent: {
|
|
5068
|
+
intentId,
|
|
5069
|
+
createdAt: Date.now(),
|
|
5070
|
+
baselineRequestId: baseline.requestId,
|
|
5071
|
+
candidateSummaries: candidates,
|
|
5072
|
+
retryRequestHash,
|
|
5073
|
+
retryCompleteBoundTokens: admittedTokens,
|
|
5074
|
+
requestBudgetTokens: primarySummaryConfig.requestBudgetTokens,
|
|
5075
|
+
},
|
|
5076
|
+
fallbackStatus: 'pending',
|
|
5077
|
+
}));
|
|
5078
|
+
this.pendingRequests.push({
|
|
5079
|
+
agentName: agent.name,
|
|
5080
|
+
reason: 'primary-summary-fallback-retry',
|
|
5081
|
+
source: 'framework',
|
|
5082
|
+
timestamp: Date.now(),
|
|
5083
|
+
channelId: trigger?.channelId,
|
|
5084
|
+
primarySummaryFallbackRetry: {
|
|
5085
|
+
originalRequestId: requestId,
|
|
5086
|
+
baselineRequestId: baseline.requestId,
|
|
5087
|
+
intentId,
|
|
5088
|
+
branch: {
|
|
5089
|
+
id: currentRecord.branch.id,
|
|
5090
|
+
generation: currentRecord.branch.generation,
|
|
5091
|
+
},
|
|
5092
|
+
candidateSummaries: candidates,
|
|
5093
|
+
originalArtifacts: artifacts,
|
|
5094
|
+
originalProjectionKeys: this.primarySummaryProjectionKeys(currentRecord.projection),
|
|
5095
|
+
expectedRetryRequestHash: retryRequestHash,
|
|
5096
|
+
requestBudgetTokens: primarySummaryConfig.requestBudgetTokens,
|
|
5097
|
+
originalRequestInputBoundTokens: currentRecord.requestInputBoundTokens,
|
|
5098
|
+
originalProviderInputTokens: providerInputTokens,
|
|
5099
|
+
},
|
|
5100
|
+
});
|
|
5101
|
+
}
|
|
5102
|
+
}
|
|
5103
|
+
catch (error) {
|
|
5104
|
+
await this.holdPrimarySummaryRequest(agent, requestId, 'primary_summary_refusal_retry_build_failed', {
|
|
5105
|
+
...holdReasonData,
|
|
5106
|
+
error: error instanceof Error ? error.message : String(error),
|
|
5107
|
+
});
|
|
5108
|
+
}
|
|
5109
|
+
}
|
|
5110
|
+
}
|
|
5111
|
+
}
|
|
5112
|
+
agent.reset();
|
|
5113
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
5114
|
+
this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
|
|
5115
|
+
continue;
|
|
5116
|
+
}
|
|
5117
|
+
if (!(await persistPrimarySummaryRetryQuarantine())) {
|
|
5118
|
+
return;
|
|
5119
|
+
}
|
|
3048
5120
|
// Add assistant response to context.
|
|
3049
5121
|
// If we had tool calls, each round's blocks (thinking + text +
|
|
3050
5122
|
// tool_use) were already stored as pendingAssistantBlocks and
|
|
@@ -3053,17 +5125,55 @@ export class AgentFramework {
|
|
|
3053
5125
|
// double-store earlier rounds' text/thinking AND detach signed
|
|
3054
5126
|
// thinking blocks from their tool_use turn (the API requires
|
|
3055
5127
|
// thinking to precede tool_use in the same assistant turn).
|
|
3056
|
-
const lastToolIdx = response.content.reduce((last, b, i) => b.type === 'tool_use' || b.type === 'tool_result' ? i : last, -1);
|
|
3057
|
-
const terminalContent = lastToolIdx >= 0
|
|
3058
|
-
? response.content.slice(lastToolIdx + 1)
|
|
3059
|
-
: response.content;
|
|
3060
5128
|
if (lastToolIdx >= 0) {
|
|
3061
5129
|
if (terminalContent.length > 0) {
|
|
3062
|
-
|
|
5130
|
+
if (primarySummaryRetry) {
|
|
5131
|
+
const retryRecord = await this.getPrimarySummaryRequestRecord(requestId);
|
|
5132
|
+
const terminalMetadata = retryRecord
|
|
5133
|
+
? this.buildPrimarySummarySettlementMetadata(retryRecord, {
|
|
5134
|
+
outcome: 'success',
|
|
5135
|
+
stopReason: response.stopReason,
|
|
5136
|
+
providerInputTokens,
|
|
5137
|
+
visibleAssistantOutput,
|
|
5138
|
+
executedToolCalls,
|
|
5139
|
+
entryIndex: 0,
|
|
5140
|
+
entryCount: 1,
|
|
5141
|
+
role: 'assistant',
|
|
5142
|
+
kind: 'assistant_output',
|
|
5143
|
+
})
|
|
5144
|
+
: undefined;
|
|
5145
|
+
if (!(await persistPrimarySummaryRetryOutput('terminal', terminalContent, agent.name, terminalMetadata))) {
|
|
5146
|
+
return;
|
|
5147
|
+
}
|
|
5148
|
+
}
|
|
5149
|
+
else {
|
|
5150
|
+
agent.addAssistantResponse(terminalContent);
|
|
5151
|
+
}
|
|
3063
5152
|
}
|
|
3064
5153
|
}
|
|
3065
5154
|
else {
|
|
3066
|
-
|
|
5155
|
+
if (primarySummaryRetry) {
|
|
5156
|
+
const retryRecord = await this.getPrimarySummaryRequestRecord(requestId);
|
|
5157
|
+
const terminalMetadata = retryRecord && terminalContent.length > 0
|
|
5158
|
+
? this.buildPrimarySummarySettlementMetadata(retryRecord, {
|
|
5159
|
+
outcome: 'success',
|
|
5160
|
+
stopReason: response.stopReason,
|
|
5161
|
+
providerInputTokens,
|
|
5162
|
+
visibleAssistantOutput,
|
|
5163
|
+
executedToolCalls,
|
|
5164
|
+
entryIndex: 0,
|
|
5165
|
+
entryCount: 1,
|
|
5166
|
+
role: 'assistant',
|
|
5167
|
+
kind: 'assistant_output',
|
|
5168
|
+
})
|
|
5169
|
+
: undefined;
|
|
5170
|
+
if (!(await persistPrimarySummaryRetryOutput('terminal', terminalContent, agent.name, terminalMetadata))) {
|
|
5171
|
+
return;
|
|
5172
|
+
}
|
|
5173
|
+
}
|
|
5174
|
+
else {
|
|
5175
|
+
agent.addAssistantResponse(terminalContent);
|
|
5176
|
+
}
|
|
3067
5177
|
}
|
|
3068
5178
|
// Run afterInference hooks (no-op if no MCPL servers)
|
|
3069
5179
|
if (this.hookOrchestrator) {
|
|
@@ -3098,10 +5208,13 @@ export class AgentFramework {
|
|
|
3098
5208
|
console.error('afterInference hook error:', error);
|
|
3099
5209
|
}
|
|
3100
5210
|
}
|
|
3101
|
-
// Separate speech from thoughts
|
|
3102
|
-
//
|
|
3103
|
-
//
|
|
3104
|
-
//
|
|
5211
|
+
// Separate speech from thoughts — for MODULE dispatch only
|
|
5212
|
+
// (dispatchSpeech / TUI rendering below). This split does NOT
|
|
5213
|
+
// decide what reaches the channel: mid-turn prose is routed live
|
|
5214
|
+
// per round (see the 'tool-calls' case) and trailing prose is
|
|
5215
|
+
// routed after this block. On a tool-using turn the module-level
|
|
5216
|
+
// convention remains "text is thoughts"; on a text-only turn all
|
|
5217
|
+
// text is speech.
|
|
3105
5218
|
const isTextBlock = (block) => block.type === 'text';
|
|
3106
5219
|
const allText = response.content.filter(isTextBlock);
|
|
3107
5220
|
const speechContent = hadToolCalls ? [] : allText;
|
|
@@ -3115,6 +5228,28 @@ export class AgentFramework {
|
|
|
3115
5228
|
cacheRead: du.cacheReadTokens,
|
|
3116
5229
|
}
|
|
3117
5230
|
: undefined;
|
|
5231
|
+
if (primarySummaryRetry && artifacts) {
|
|
5232
|
+
const validation = await this.validatePrimarySummaryRetryMutation(agent, requestId, primarySummaryRetry, compiledRequest, artifacts, 'before_success_markers');
|
|
5233
|
+
if (!validation.ok) {
|
|
5234
|
+
await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, validation.reason, validation.data, { emitChronicleMarker: false });
|
|
5235
|
+
return;
|
|
5236
|
+
}
|
|
5237
|
+
}
|
|
5238
|
+
if (primarySummaryConfig.enabled) {
|
|
5239
|
+
if (primarySummaryRetry) {
|
|
5240
|
+
await this.markPrimarySummaryRetrySuccess(requestId, primarySummaryRetry, response.stopReason, providerInputTokens, visibleAssistantOutput, executedToolCalls);
|
|
5241
|
+
}
|
|
5242
|
+
else {
|
|
5243
|
+
await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
|
|
5244
|
+
...record,
|
|
5245
|
+
stopReason: response.stopReason,
|
|
5246
|
+
providerInputTokens,
|
|
5247
|
+
visibleAssistantOutput,
|
|
5248
|
+
executedToolCalls,
|
|
5249
|
+
finalStatus: 'success',
|
|
5250
|
+
}));
|
|
5251
|
+
}
|
|
5252
|
+
}
|
|
3118
5253
|
// Reset agent state before emitting inference:completed. Traces are
|
|
3119
5254
|
// observability-only, but external synchronous listeners should
|
|
3120
5255
|
// still see the terminal state at the terminal trace boundary.
|
|
@@ -3150,8 +5285,10 @@ export class AgentFramework {
|
|
|
3150
5285
|
agentName: agent.name,
|
|
3151
5286
|
requestId,
|
|
3152
5287
|
success: true,
|
|
3153
|
-
request:
|
|
3154
|
-
response:
|
|
5288
|
+
request: fallbackRetryRequestForLog,
|
|
5289
|
+
response: primarySummaryRetry
|
|
5290
|
+
? this.summarizePrimarySummaryRetryResponse(response)
|
|
5291
|
+
: response.raw ?? { note: 'streaming response' },
|
|
3155
5292
|
durationMs,
|
|
3156
5293
|
tokenUsage,
|
|
3157
5294
|
stopReason: response.stopReason,
|
|
@@ -3273,65 +5410,50 @@ export class AgentFramework {
|
|
|
3273
5410
|
}
|
|
3274
5411
|
}
|
|
3275
5412
|
else if (this.channelRegistry && hadToolCalls && allText.length > 0) {
|
|
3276
|
-
// Tool-call turn that also produced prose.
|
|
3277
|
-
//
|
|
3278
|
-
//
|
|
3279
|
-
//
|
|
3280
|
-
//
|
|
3281
|
-
//
|
|
3282
|
-
// turn
|
|
3283
|
-
//
|
|
3284
|
-
// reaches the channel.
|
|
3285
|
-
//
|
|
3286
|
-
//
|
|
3287
|
-
//
|
|
3288
|
-
//
|
|
3289
|
-
//
|
|
3290
|
-
// *reply_message / *send_dm) — already sent the message, so
|
|
3291
|
-
// routing it again would double-post.
|
|
3292
|
-
// `think` is deliberately NOT here: it is silent *reasoning*, but
|
|
3293
|
-
// prose written after a think is the agent's actual reply and must
|
|
3294
|
-
// be delivered. (A think-only turn has no trailing prose and stays
|
|
3295
|
-
// silent via the `!t` check below.)
|
|
3296
|
-
const SILENCING = new Set([
|
|
3297
|
-
'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
|
|
3298
|
-
]);
|
|
3299
|
-
const bare = (n) => (n.includes('--') ? n.split('--').pop() : n);
|
|
5413
|
+
// Tool-call turn that also produced prose. When live routing was
|
|
5414
|
+
// active (native roundContent), each mid-turn round's prose was
|
|
5415
|
+
// already delivered when that round yielded — only the TRAILING
|
|
5416
|
+
// prose (blocks after the last tool round) remains here. On the
|
|
5417
|
+
// fallback path (XML tool mode / older membrane: no roundContent,
|
|
5418
|
+
// so nothing was live-routed) the historical behavior applies:
|
|
5419
|
+
// route the whole turn's parsed segments, once, now. The global
|
|
5420
|
+
// speech/thoughts split is left untouched (module/TUI rendering
|
|
5421
|
+
// unaffected) — this only governs what reaches the channel.
|
|
5422
|
+
//
|
|
5423
|
+
// Native round routing uses the live sticky flag, which a fresh
|
|
5424
|
+
// injected message can reset. The legacy/fallback path has no
|
|
5425
|
+
// reliable round boundaries, so retain its historical turn-wide
|
|
5426
|
+
// scan to avoid double-posting.
|
|
3300
5427
|
const toolNames = response.content
|
|
3301
5428
|
.filter((b) => b.type === 'tool_use')
|
|
3302
5429
|
.map((b) => b.name)
|
|
3303
5430
|
.filter((n) => typeof n === 'string');
|
|
3304
|
-
const silenced =
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
//
|
|
3309
|
-
//
|
|
3310
|
-
//
|
|
3311
|
-
|
|
3312
|
-
// separate messages IN ORDER instead of being collapsed into one
|
|
3313
|
-
// trailing post (item 4). The silencing rule stays turn-wide: an
|
|
3314
|
-
// explicit send / skip suppresses ALL auto-routed prose this turn,
|
|
3315
|
-
// preserving the double-post guard.
|
|
3316
|
-
const segments = splitProseSegments(response.content);
|
|
5431
|
+
const silenced = liveProseRouting
|
|
5432
|
+
? turnSilenced
|
|
5433
|
+
: turnSilenced || toolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)));
|
|
5434
|
+
const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content);
|
|
5435
|
+
// Preserve in-channel ordering: everything enqueued live must
|
|
5436
|
+
// land before the trailing prose. Awaited even when silenced —
|
|
5437
|
+
// the chain may still be flushing earlier rounds' posts.
|
|
5438
|
+
await turnSpeechChain;
|
|
3317
5439
|
if (silenced || segments.length === 0) {
|
|
3318
|
-
console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> NOT routed ` +
|
|
3319
|
-
`(${silenced ? 'silencing tool / explicit send' : 'no prose'})`);
|
|
5440
|
+
console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> trailing prose NOT routed ` +
|
|
5441
|
+
`(${silenced ? 'silencing tool / explicit send' : 'no trailing prose'})`);
|
|
3320
5442
|
}
|
|
3321
5443
|
else {
|
|
3322
|
-
//
|
|
3323
|
-
//
|
|
3324
|
-
//
|
|
3325
|
-
//
|
|
3326
|
-
//
|
|
3327
|
-
//
|
|
3328
|
-
//
|
|
3329
|
-
const
|
|
3330
|
-
console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} prose segment(s)
|
|
5444
|
+
// Reuse the locus pinned at the turn's first live-routed
|
|
5445
|
+
// segment (or resolve it now for a turn whose only prose is
|
|
5446
|
+
// trailing). This dispatch runs AFTER the agent is idle (see
|
|
5447
|
+
// PR #32 note below), so a queued inbound from another channel
|
|
5448
|
+
// could otherwise overwrite the per-agent triggering channel
|
|
5449
|
+
// between segments — and a turn that narrated live into one
|
|
5450
|
+
// channel must not land its postscript in another.
|
|
5451
|
+
const locus = resolveTurnLocus();
|
|
5452
|
+
console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} ${liveProseRouting ? 'trailing ' : ''}prose segment(s) -> ${locus ?? '(default)'}`);
|
|
3331
5453
|
// Deliver sequentially (await each) so the segments land in order.
|
|
3332
5454
|
for (const seg of segments) {
|
|
3333
5455
|
try {
|
|
3334
|
-
await this.channelRegistry.routeSpeech(agent.name, seg,
|
|
5456
|
+
await this.channelRegistry.routeSpeech(agent.name, seg, locus);
|
|
3335
5457
|
}
|
|
3336
5458
|
catch (err) {
|
|
3337
5459
|
console.error('speech routing failed:', err);
|
|
@@ -3359,12 +5481,22 @@ export class AgentFramework {
|
|
|
3359
5481
|
requestId,
|
|
3360
5482
|
success: false,
|
|
3361
5483
|
error: err.message,
|
|
3362
|
-
request:
|
|
5484
|
+
request: fallbackRetryRequestForLog,
|
|
3363
5485
|
durationMs,
|
|
3364
5486
|
});
|
|
3365
5487
|
// Only reset + retry if this is still the active stream
|
|
3366
5488
|
if (agent.streamId !== myStreamId)
|
|
3367
5489
|
break;
|
|
5490
|
+
if (primarySummaryRetry) {
|
|
5491
|
+
await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, 'primary_summary_fallback_retry_stream_error', { error: err.message });
|
|
5492
|
+
agent.reset();
|
|
5493
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
5494
|
+
this.settleAgent(agent.name, {
|
|
5495
|
+
stopReason: 'completed',
|
|
5496
|
+
speech: '',
|
|
5497
|
+
});
|
|
5498
|
+
return;
|
|
5499
|
+
}
|
|
3368
5500
|
agent.reset();
|
|
3369
5501
|
const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
|
|
3370
5502
|
if (action.retry) {
|
|
@@ -3403,6 +5535,11 @@ export class AgentFramework {
|
|
|
3403
5535
|
// reject an ephemeral's promise mid-run and bump the failure
|
|
3404
5536
|
// streak). Gate release + stream teardown happen in `finally`.
|
|
3405
5537
|
if (this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`)) {
|
|
5538
|
+
if (primarySummaryRetry) {
|
|
5539
|
+
if (!(await this.primarySummaryRetryFamilyAlreadySettled(requestId, primarySummaryRetry))) {
|
|
5540
|
+
await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, 'primary_summary_fallback_retry_cancelled_before_success', { frameworkCancelled: true });
|
|
5541
|
+
}
|
|
5542
|
+
}
|
|
3406
5543
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
3407
5544
|
return;
|
|
3408
5545
|
}
|
|
@@ -3411,6 +5548,33 @@ export class AgentFramework {
|
|
|
3411
5548
|
// may have already started a new stream, bumping streamId)
|
|
3412
5549
|
if (agent.streamId === myStreamId) {
|
|
3413
5550
|
const durationMs = Date.now() - startTime;
|
|
5551
|
+
if (primarySummaryRetry) {
|
|
5552
|
+
await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, `stream_aborted:${reason}`, { reason }, { stopReason: 'abort' });
|
|
5553
|
+
agent.reset();
|
|
5554
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
5555
|
+
this.logInference({
|
|
5556
|
+
timestamp: startTime,
|
|
5557
|
+
agentName: agent.name,
|
|
5558
|
+
requestId,
|
|
5559
|
+
success: false,
|
|
5560
|
+
error: `Stream aborted: ${reason}`,
|
|
5561
|
+
request: fallbackRetryRequestForLog,
|
|
5562
|
+
durationMs,
|
|
5563
|
+
});
|
|
5564
|
+
this.settleAgent(agent.name, {
|
|
5565
|
+
stopReason: 'completed',
|
|
5566
|
+
speech: '',
|
|
5567
|
+
});
|
|
5568
|
+
return;
|
|
5569
|
+
}
|
|
5570
|
+
if (primarySummaryConfig.enabled) {
|
|
5571
|
+
await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
|
|
5572
|
+
...record,
|
|
5573
|
+
stopReason: 'abort',
|
|
5574
|
+
finalStatus: 'error',
|
|
5575
|
+
fallbackHeldReason: `stream_aborted:${reason}`,
|
|
5576
|
+
}));
|
|
5577
|
+
}
|
|
3414
5578
|
agent.reset();
|
|
3415
5579
|
this.settleAgent(agent.name, {
|
|
3416
5580
|
stopReason: 'exhausted',
|
|
@@ -3434,7 +5598,7 @@ export class AgentFramework {
|
|
|
3434
5598
|
requestId,
|
|
3435
5599
|
success: false,
|
|
3436
5600
|
error: `Stream aborted: ${reason}`,
|
|
3437
|
-
request:
|
|
5601
|
+
request: fallbackRetryRequestForLog,
|
|
3438
5602
|
durationMs,
|
|
3439
5603
|
});
|
|
3440
5604
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
@@ -3480,12 +5644,58 @@ export class AgentFramework {
|
|
|
3480
5644
|
// inference:exhausted so ephemeral agent promises can settle.
|
|
3481
5645
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
3482
5646
|
const durationMs = Date.now() - startTime;
|
|
5647
|
+
if (primarySummaryRetry) {
|
|
5648
|
+
if (await this.primarySummaryRetryAlreadySucceeded(requestId, primarySummaryRetry)) {
|
|
5649
|
+
this.logInference({
|
|
5650
|
+
timestamp: startTime,
|
|
5651
|
+
agentName: agent.name,
|
|
5652
|
+
requestId,
|
|
5653
|
+
success: true,
|
|
5654
|
+
request: fallbackRetryRequestForLog,
|
|
5655
|
+
response: { kind: 'primary_summary_fallback_retry', postSuccessError: err.message },
|
|
5656
|
+
durationMs,
|
|
5657
|
+
});
|
|
5658
|
+
agent.reset();
|
|
5659
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
5660
|
+
this.settleAgent(agent.name, {
|
|
5661
|
+
stopReason: 'completed',
|
|
5662
|
+
speech: '',
|
|
5663
|
+
});
|
|
5664
|
+
return;
|
|
5665
|
+
}
|
|
5666
|
+
await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, 'primary_summary_fallback_retry_stream_threw', { error: err.message });
|
|
5667
|
+
}
|
|
5668
|
+
else if (primarySummaryConfig.enabled) {
|
|
5669
|
+
await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
|
|
5670
|
+
...record,
|
|
5671
|
+
finalStatus: 'error',
|
|
5672
|
+
fallbackHeldReason: err.message,
|
|
5673
|
+
}));
|
|
5674
|
+
}
|
|
3483
5675
|
this.emitTrace({
|
|
3484
5676
|
type: 'inference:failed',
|
|
3485
5677
|
agentName: agent.name,
|
|
3486
5678
|
error: err.message,
|
|
3487
5679
|
stack: err.stack,
|
|
3488
5680
|
});
|
|
5681
|
+
if (primarySummaryRetry) {
|
|
5682
|
+
this.logInference({
|
|
5683
|
+
timestamp: startTime,
|
|
5684
|
+
agentName: agent.name,
|
|
5685
|
+
requestId,
|
|
5686
|
+
success: false,
|
|
5687
|
+
error: `Stream threw: ${err.message}`,
|
|
5688
|
+
request: fallbackRetryRequestForLog,
|
|
5689
|
+
durationMs,
|
|
5690
|
+
});
|
|
5691
|
+
agent.reset();
|
|
5692
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
5693
|
+
this.settleAgent(agent.name, {
|
|
5694
|
+
stopReason: 'completed',
|
|
5695
|
+
speech: '',
|
|
5696
|
+
});
|
|
5697
|
+
return;
|
|
5698
|
+
}
|
|
3489
5699
|
this.settleAgent(agent.name, {
|
|
3490
5700
|
stopReason: 'exhausted',
|
|
3491
5701
|
speech: '',
|
|
@@ -3506,7 +5716,7 @@ export class AgentFramework {
|
|
|
3506
5716
|
requestId,
|
|
3507
5717
|
success: false,
|
|
3508
5718
|
error: `Stream threw: ${err.message}`,
|
|
3509
|
-
request:
|
|
5719
|
+
request: fallbackRetryRequestForLog,
|
|
3510
5720
|
durationMs,
|
|
3511
5721
|
});
|
|
3512
5722
|
agent.reset();
|
|
@@ -3527,6 +5737,7 @@ export class AgentFramework {
|
|
|
3527
5737
|
this.channelRegistry?.stopTyping();
|
|
3528
5738
|
this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
|
|
3529
5739
|
this.activeStreams.delete(agent.name);
|
|
5740
|
+
this.activeStreamContexts.delete(agent.name);
|
|
3530
5741
|
this.pendingAssistantBlocks.delete(agent.name);
|
|
3531
5742
|
// A conversation fork whose TTL closure turn just finished is done for
|
|
3532
5743
|
// good — dispose it so the agent map doesn't grow monotonically.
|
|
@@ -3661,6 +5872,49 @@ export class AgentFramework {
|
|
|
3661
5872
|
return maxTokens * 4;
|
|
3662
5873
|
return undefined;
|
|
3663
5874
|
}
|
|
5875
|
+
approximateDecodedBase64Bytes(base64) {
|
|
5876
|
+
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
|
|
5877
|
+
return Math.max(0, Math.floor(base64.length * 3 / 4) - padding);
|
|
5878
|
+
}
|
|
5879
|
+
sanitizeProcessLogValue(value, seen = new WeakMap()) {
|
|
5880
|
+
if (value && typeof value === 'object') {
|
|
5881
|
+
const seenValue = seen.get(value);
|
|
5882
|
+
if (seenValue)
|
|
5883
|
+
return seenValue;
|
|
5884
|
+
}
|
|
5885
|
+
if (Array.isArray(value)) {
|
|
5886
|
+
const clone = [];
|
|
5887
|
+
seen.set(value, clone);
|
|
5888
|
+
for (const item of value) {
|
|
5889
|
+
clone.push(this.sanitizeProcessLogValue(item, seen));
|
|
5890
|
+
}
|
|
5891
|
+
return clone;
|
|
5892
|
+
}
|
|
5893
|
+
if (!value || typeof value !== 'object') {
|
|
5894
|
+
return value;
|
|
5895
|
+
}
|
|
5896
|
+
const record = value;
|
|
5897
|
+
if (record.type === 'image' && typeof record.data === 'string' && typeof record.mimeType === 'string') {
|
|
5898
|
+
const clone = {};
|
|
5899
|
+
seen.set(value, clone);
|
|
5900
|
+
for (const [key, child] of Object.entries(record)) {
|
|
5901
|
+
if (key === 'data' || key === 'mimeType')
|
|
5902
|
+
continue;
|
|
5903
|
+
clone[key] = this.sanitizeProcessLogValue(child, seen);
|
|
5904
|
+
}
|
|
5905
|
+
clone.type = 'image';
|
|
5906
|
+
clone.mimeType = record.mimeType;
|
|
5907
|
+
clone.approxByteLength = this.approximateDecodedBase64Bytes(record.data);
|
|
5908
|
+
clone.redacted = true;
|
|
5909
|
+
return clone;
|
|
5910
|
+
}
|
|
5911
|
+
const clone = {};
|
|
5912
|
+
seen.set(value, clone);
|
|
5913
|
+
for (const [key, child] of Object.entries(record)) {
|
|
5914
|
+
clone[key] = this.sanitizeProcessLogValue(child, seen);
|
|
5915
|
+
}
|
|
5916
|
+
return clone;
|
|
5917
|
+
}
|
|
3664
5918
|
logInference(entry) {
|
|
3665
5919
|
// Store large request/response as blobs
|
|
3666
5920
|
const entryToStore = { ...entry };
|
|
@@ -3689,13 +5943,13 @@ export class AgentFramework {
|
|
|
3689
5943
|
logProcessEvent(event, responses) {
|
|
3690
5944
|
const entry = {
|
|
3691
5945
|
timestamp: Date.now(),
|
|
3692
|
-
processEvent: event,
|
|
3693
|
-
responses,
|
|
5946
|
+
processEvent: this.sanitizeProcessLogValue(event),
|
|
5947
|
+
responses: this.sanitizeProcessLogValue(responses),
|
|
3694
5948
|
};
|
|
3695
5949
|
// Blob threshold: 10KB
|
|
3696
5950
|
const BLOB_THRESHOLD = 10000;
|
|
3697
5951
|
const entryToStore = { ...entry };
|
|
3698
|
-
const responsesJson = JSON.stringify(responses);
|
|
5952
|
+
const responsesJson = JSON.stringify(entry.responses);
|
|
3699
5953
|
if (responsesJson.length > BLOB_THRESHOLD) {
|
|
3700
5954
|
const blobId = this.store.storeBlob(Buffer.from(responsesJson), 'application/json');
|
|
3701
5955
|
entryToStore.responses = { blobId };
|
|
@@ -3729,6 +5983,15 @@ export class AgentFramework {
|
|
|
3729
5983
|
this.dispatchChannelToolCall(agentName, enrichedCall);
|
|
3730
5984
|
return;
|
|
3731
5985
|
}
|
|
5986
|
+
// Route the agent's typed, allowlisted hot-settings surface.
|
|
5987
|
+
if (enrichedCall.name === 'agent_settings') {
|
|
5988
|
+
this.dispatchAgentSettingsToolCall(agentName, enrichedCall);
|
|
5989
|
+
return;
|
|
5990
|
+
}
|
|
5991
|
+
if (enrichedCall.name === 'save_recent_image') {
|
|
5992
|
+
this.dispatchSaveImageToolCall(agentName, enrichedCall);
|
|
5993
|
+
return;
|
|
5994
|
+
}
|
|
3732
5995
|
// Route gate_status tool
|
|
3733
5996
|
if (enrichedCall.name === 'gate_status' && this.eventGate) {
|
|
3734
5997
|
this.dispatchGateToolCall(agentName, enrichedCall);
|
|
@@ -3829,12 +6092,19 @@ export class AgentFramework {
|
|
|
3829
6092
|
if (!agent) {
|
|
3830
6093
|
throw new Error('No agents configured');
|
|
3831
6094
|
}
|
|
3832
|
-
//
|
|
3833
|
-
//
|
|
6095
|
+
// Defer non-tool_result messages while a tool cycle is mid-flight
|
|
6096
|
+
// (pendingAssistantBlocks: preserves tool_use → tool_result adjacency)
|
|
6097
|
+
// OR while the target agent has a live stream at all. A message stored
|
|
6098
|
+
// mid-stream lands BEFORE the turn's assistant blocks in the window even
|
|
6099
|
+
// though the live conversation never saw it — so the next compile
|
|
6100
|
+
// diverges from the live prefix (prompt-cache bust) and the message
|
|
6101
|
+
// misses mid-turn injection. Deferred messages flush at the agent's next
|
|
6102
|
+
// tool boundary (where they are ALSO injected into the live stream —
|
|
6103
|
+
// hear-while-acting) or in driveStream's finally when the turn ends.
|
|
3834
6104
|
const hasToolResult = content.some(b => b.type === 'tool_result');
|
|
3835
|
-
if (!hasToolResult && this.pendingAssistantBlocks.size > 0) {
|
|
6105
|
+
if (!hasToolResult && (this.pendingAssistantBlocks.size > 0 || this.activeStreams.has(agent.name))) {
|
|
3836
6106
|
this.deferredMessages.push({ participant, content, metadata });
|
|
3837
|
-
return ''; // Deferred —
|
|
6107
|
+
return ''; // Deferred — flushed at the next boundary
|
|
3838
6108
|
}
|
|
3839
6109
|
return agent.getContextManager().addMessage(participant, content, metadata);
|
|
3840
6110
|
}
|
|
@@ -3954,12 +6224,22 @@ export class AgentFramework {
|
|
|
3954
6224
|
* work, per-agent status + last inference activity. Cheap and read-only.
|
|
3955
6225
|
*/
|
|
3956
6226
|
healthSnapshot() {
|
|
6227
|
+
const primarySummary = this.readPrimarySummaryFallbackState();
|
|
3957
6228
|
return {
|
|
3958
6229
|
at: new Date().toISOString(),
|
|
3959
6230
|
uptimeSec: Math.round(process.uptime()),
|
|
3960
6231
|
gate: this.eventGate?.inferenceDiagnostics() ?? null,
|
|
3961
6232
|
pendingRequests: this.pendingRequests.length,
|
|
3962
6233
|
activeStreams: [...this.activeStreams.keys()],
|
|
6234
|
+
primarySummaryFallback: {
|
|
6235
|
+
requests: primarySummary.requests.length,
|
|
6236
|
+
pendingDispatches: primarySummary.requests.filter((record) => record.finalStatus === 'pending').length,
|
|
6237
|
+
unresolvedIntents: primarySummary.requests.filter((record) => record.fallbackStatus === 'pending').length,
|
|
6238
|
+
held: primarySummary.requests.filter((record) => record.finalStatus === 'held' ||
|
|
6239
|
+
record.finalStatus === 'timeout_unknown' ||
|
|
6240
|
+
record.fallbackStatus === 'held' ||
|
|
6241
|
+
record.fallbackStatus === 'timeout_unknown').length,
|
|
6242
|
+
},
|
|
3963
6243
|
agents: [...this.agents.entries()].map(([name, agent]) => ({
|
|
3964
6244
|
name,
|
|
3965
6245
|
status: agent.state.status,
|
|
@@ -3990,6 +6270,16 @@ export class AgentFramework {
|
|
|
3990
6270
|
* every ~15 min instead of flooding the channel on every occurrence.
|
|
3991
6271
|
* See connectome docs/observability.md.
|
|
3992
6272
|
*/
|
|
6273
|
+
/**
|
|
6274
|
+
* Public ops-alert entry point for host-wired alarms that originate
|
|
6275
|
+
* OUTSIDE the framework (e.g. context-manager's repeating
|
|
6276
|
+
* compression-quarantine klaxon, wired in the host at strategy
|
|
6277
|
+
* construction). Same sinks and cooldown as internal alerts:
|
|
6278
|
+
* failures.log + ops:alert trace + CONNECTOME_OPS_WEBHOOK.
|
|
6279
|
+
*/
|
|
6280
|
+
notifyOps(kind, agentName, message, data) {
|
|
6281
|
+
this.opsAlert(kind, agentName, message, { data });
|
|
6282
|
+
}
|
|
3993
6283
|
opsAlert(kind, agentName, message, opts) {
|
|
3994
6284
|
if (!opts?.skipLog) {
|
|
3995
6285
|
this.logFailure({ agent: agentName, kind, reason: message, ...opts?.data });
|
|
@@ -4064,6 +6354,16 @@ export class AgentFramework {
|
|
|
4064
6354
|
}
|
|
4065
6355
|
return {};
|
|
4066
6356
|
}
|
|
6357
|
+
/**
|
|
6358
|
+
* Public entry to the ops-alert pipeline (failures.log + ops:alert trace +
|
|
6359
|
+
* throttled webhook) for host-level detectors OUTSIDE the framework's own
|
|
6360
|
+
* inference driver — e.g. the host's logging adapter catching refusals on
|
|
6361
|
+
* off-path calls (compression/summarizer, maintenance) that noteRefusal
|
|
6362
|
+
* never sees. Same per-(agent,kind) cooldown as everything else.
|
|
6363
|
+
*/
|
|
6364
|
+
notifyOpsAlert(kind, agentName, message, data) {
|
|
6365
|
+
this.opsAlert(kind, agentName, message, { data });
|
|
6366
|
+
}
|
|
4067
6367
|
noteInferenceExhausted(agentName, reason, retryable, errorType) {
|
|
4068
6368
|
const streak = (this.consecutiveInferenceFailures.get(agentName) ?? 0) + 1;
|
|
4069
6369
|
this.consecutiveInferenceFailures.set(agentName, streak);
|
|
@@ -4222,6 +6522,7 @@ export class AgentFramework {
|
|
|
4222
6522
|
this.checkpointManager = new CheckpointManager(this.store, (event) => this.emitTrace(event));
|
|
4223
6523
|
// Channel registry (Step 7)
|
|
4224
6524
|
this.channelRegistry = new ChannelRegistry(this.mcplServerRegistry, this.featureSetManager, (event) => this.pushEvent(event), (event) => this.emitTrace(event), {
|
|
6525
|
+
store: this.store,
|
|
4225
6526
|
sendTypingFn: (serverId, channelId, metadata, op) => {
|
|
4226
6527
|
const server = this.mcplServerRegistry.getServer(serverId);
|
|
4227
6528
|
if (server) {
|
|
@@ -4275,9 +6576,17 @@ export class AgentFramework {
|
|
|
4275
6576
|
};
|
|
4276
6577
|
for (const config of serverConfigs) {
|
|
4277
6578
|
try {
|
|
4278
|
-
|
|
6579
|
+
// Stage every startup connection with both planes closed. This removes
|
|
6580
|
+
// server-order dependence: no early heartbeat/shell push can become
|
|
6581
|
+
// model-visible before a later Discord connection has joined the same
|
|
6582
|
+
// awareness generation.
|
|
6583
|
+
await this.connectMcplServerInternal(config, true);
|
|
4279
6584
|
}
|
|
4280
6585
|
catch (error) {
|
|
6586
|
+
if (error instanceof DiscordAwarenessAccountingError) {
|
|
6587
|
+
await this.mcplServerRegistry.closeAll();
|
|
6588
|
+
throw error;
|
|
6589
|
+
}
|
|
4281
6590
|
// Fail-open: log and continue with remaining servers
|
|
4282
6591
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
4283
6592
|
console.error(`Failed to connect MCPL server "${config.id}":`, err.message);
|
|
@@ -4294,16 +6603,289 @@ export class AgentFramework {
|
|
|
4294
6603
|
});
|
|
4295
6604
|
}
|
|
4296
6605
|
}
|
|
6606
|
+
// Start the durable drain only after all startup connections are staged.
|
|
6607
|
+
// The awareness tools/call is written before control-plane registration is
|
|
6608
|
+
// flushed, preserving the server-side queue -> registration -> call order.
|
|
6609
|
+
const startupBarrier = this.installMcplDataPlaneGate();
|
|
6610
|
+
this.releaseMcplDataPlaneGate(startupBarrier);
|
|
6611
|
+
try {
|
|
6612
|
+
await startupBarrier.promise;
|
|
6613
|
+
this.completeMcplDataPlaneGate(startupBarrier);
|
|
6614
|
+
}
|
|
6615
|
+
catch (error) {
|
|
6616
|
+
await this.mcplServerRegistry.closeAll();
|
|
6617
|
+
throw error;
|
|
6618
|
+
}
|
|
4297
6619
|
// Discover tools from all connected servers
|
|
4298
6620
|
await this.refreshMcplTools();
|
|
4299
6621
|
}
|
|
6622
|
+
/** Reconcile the durable ledger with Chronicle, then deliver every server's work. */
|
|
6623
|
+
async syncDiscordAwarenessMarkers(_onlyServerId) {
|
|
6624
|
+
if (!this.discordAwarenessOutbox)
|
|
6625
|
+
return;
|
|
6626
|
+
// A targeted retry may discover pending work for other Discord servers
|
|
6627
|
+
// during reconciliation. Safety is global, so one generation accounts for
|
|
6628
|
+
// all pending operations before releasing any MCPL data plane.
|
|
6629
|
+
const barrier = this.installMcplDataPlaneGate();
|
|
6630
|
+
this.releaseMcplDataPlaneGate(barrier);
|
|
6631
|
+
try {
|
|
6632
|
+
await barrier.promise;
|
|
6633
|
+
this.completeMcplDataPlaneGate(barrier);
|
|
6634
|
+
}
|
|
6635
|
+
catch (error) {
|
|
6636
|
+
await this.failMcplDataPlaneGate(barrier, 'explicit synchronization', error);
|
|
6637
|
+
throw error;
|
|
6638
|
+
}
|
|
6639
|
+
}
|
|
6640
|
+
async resumePreparedDiscordSuppressions() {
|
|
6641
|
+
if (!this.discordAwarenessOutbox)
|
|
6642
|
+
return;
|
|
6643
|
+
const batches = this.discordAwarenessOutbox.preparedSuppressionsForBranch(this.store.currentBranch().name, this.store.listBranches());
|
|
6644
|
+
for (const batch of batches) {
|
|
6645
|
+
const agent = this.agents.get(batch.agentName);
|
|
6646
|
+
if (!agent) {
|
|
6647
|
+
throw new Error(`Cannot resume Discord suppression ${batch.id}: agent ${batch.agentName} is not configured`);
|
|
6648
|
+
}
|
|
6649
|
+
const cm = agent.getContextManager();
|
|
6650
|
+
for (const interval of [...(batch.suppressionIntervals ?? [])].reverse()) {
|
|
6651
|
+
const from = cm.getMessage(interval.fromId);
|
|
6652
|
+
const to = cm.getMessage(interval.toId);
|
|
6653
|
+
if (!from && !to)
|
|
6654
|
+
continue; // This interval committed before the crash.
|
|
6655
|
+
if (!from || !to) {
|
|
6656
|
+
throw new Error(`Cannot safely resume Discord suppression ${batch.id}: interval ` +
|
|
6657
|
+
`${interval.fromId}..${interval.toId} is only partially present`);
|
|
6658
|
+
}
|
|
6659
|
+
if (interval.fromId === interval.toId)
|
|
6660
|
+
cm.removeMessage(from.id);
|
|
6661
|
+
else
|
|
6662
|
+
cm.removeMessages(from.id, to.id);
|
|
6663
|
+
}
|
|
6664
|
+
this.discordAwarenessOutbox.activate(batch.id);
|
|
6665
|
+
console.error(`[discord-awareness] resumed suppression batch ${batch.id}`);
|
|
6666
|
+
}
|
|
6667
|
+
}
|
|
6668
|
+
/**
|
|
6669
|
+
* Deliver every due ref independently. Permanent Discord failures are kept
|
|
6670
|
+
* in the ledger for audit but do not block later operations; retryable
|
|
6671
|
+
* failures remain pending for the next reconnect/list-change attempt.
|
|
6672
|
+
*/
|
|
6673
|
+
drainDiscordAwarenessOutbox(serverId) {
|
|
6674
|
+
if (!this.discordAwarenessOutbox) {
|
|
6675
|
+
return Promise.resolve({ status: 'delivered', delivered: 0, failed: 0 });
|
|
6676
|
+
}
|
|
6677
|
+
const existing = this.discordAwarenessDrains.get(serverId);
|
|
6678
|
+
if (existing)
|
|
6679
|
+
return existing;
|
|
6680
|
+
const drain = (async () => {
|
|
6681
|
+
const connection = this.mcplServerRegistry?.getServer(serverId);
|
|
6682
|
+
if (!connection?.isConnected) {
|
|
6683
|
+
const operations = this.readDiscordAwarenessPending(serverId);
|
|
6684
|
+
for (const operation of operations) {
|
|
6685
|
+
this.writeDiscordAwarenessFailure(operation.batchId, operation.ref, operation.action, 'Awareness delivery not attempted: MCPL connection unavailable', false);
|
|
6686
|
+
}
|
|
6687
|
+
if (operations.length > 0) {
|
|
6688
|
+
console.error(`[discord-awareness] ${serverId}: connection unavailable; ` +
|
|
6689
|
+
`durably deferred=${operations.length}`);
|
|
6690
|
+
}
|
|
6691
|
+
return { status: 'unavailable', accounted: operations.length };
|
|
6692
|
+
}
|
|
6693
|
+
let delivered = 0;
|
|
6694
|
+
let failed = 0;
|
|
6695
|
+
const attempted = new Set();
|
|
6696
|
+
while (true) {
|
|
6697
|
+
const operations = this.readDiscordAwarenessPending(serverId).filter((operation) => {
|
|
6698
|
+
const key = `${operation.batchId}\0${operation.ref.channelId}\0${operation.ref.messageId}\0${operation.action}`;
|
|
6699
|
+
if (attempted.has(key))
|
|
6700
|
+
return false;
|
|
6701
|
+
attempted.add(key);
|
|
6702
|
+
return true;
|
|
6703
|
+
});
|
|
6704
|
+
if (operations.length === 0)
|
|
6705
|
+
break;
|
|
6706
|
+
for (const operation of operations) {
|
|
6707
|
+
const ref = operation.ref;
|
|
6708
|
+
const channelId = ref.channelId.startsWith('discord:')
|
|
6709
|
+
? ref.channelId.split(':').at(-1)
|
|
6710
|
+
: ref.channelId;
|
|
6711
|
+
let deliveryError;
|
|
6712
|
+
try {
|
|
6713
|
+
const tool = operation.action === 'add' ? 'add_reaction' : 'remove_reaction';
|
|
6714
|
+
const result = await connection.sendToolsCallWithDeadline(tool, {
|
|
6715
|
+
channelId,
|
|
6716
|
+
messageId: ref.messageId,
|
|
6717
|
+
emoji: operation.emoji,
|
|
6718
|
+
}, this.discordAwarenessDeadlineMs);
|
|
6719
|
+
if (result.isError) {
|
|
6720
|
+
deliveryError = result.content
|
|
6721
|
+
.map((content) => content.text ?? '')
|
|
6722
|
+
.filter(Boolean)
|
|
6723
|
+
.join('; ') || `Discord ${tool} returned an error`;
|
|
6724
|
+
}
|
|
6725
|
+
}
|
|
6726
|
+
catch (error) {
|
|
6727
|
+
deliveryError = error instanceof Error ? error.message : String(error);
|
|
6728
|
+
}
|
|
6729
|
+
if (deliveryError !== undefined) {
|
|
6730
|
+
const permanent = isPermanentDiscordReactionFailure(deliveryError);
|
|
6731
|
+
this.writeDiscordAwarenessFailure(operation.batchId, ref, operation.action, deliveryError, permanent);
|
|
6732
|
+
failed++;
|
|
6733
|
+
console.error(`[discord-awareness] ${operation.action} failed for ${ref.channelId}/${ref.messageId}` +
|
|
6734
|
+
` (${permanent ? 'permanent' : 'retryable'}): ${deliveryError}`);
|
|
6735
|
+
}
|
|
6736
|
+
else {
|
|
6737
|
+
this.writeDiscordAwarenessSuccess(operation.batchId, ref, operation.action);
|
|
6738
|
+
delivered++;
|
|
6739
|
+
}
|
|
6740
|
+
}
|
|
6741
|
+
}
|
|
6742
|
+
if (delivered > 0 || failed > 0) {
|
|
6743
|
+
console.error(`[discord-awareness] ${serverId}: delivered=${delivered} failed=${failed}`);
|
|
6744
|
+
}
|
|
6745
|
+
return { status: 'delivered', delivered, failed };
|
|
6746
|
+
})().finally(() => {
|
|
6747
|
+
this.discordAwarenessDrains.delete(serverId);
|
|
6748
|
+
});
|
|
6749
|
+
this.discordAwarenessDrains.set(serverId, drain);
|
|
6750
|
+
return drain;
|
|
6751
|
+
}
|
|
6752
|
+
readDiscordAwarenessPending(serverId) {
|
|
6753
|
+
try {
|
|
6754
|
+
return this.discordAwarenessOutbox.pending(serverId);
|
|
6755
|
+
}
|
|
6756
|
+
catch (error) {
|
|
6757
|
+
throw new DiscordAwarenessAccountingError(`ledger read${serverId ? ` for ${serverId}` : ''}`, error);
|
|
6758
|
+
}
|
|
6759
|
+
}
|
|
6760
|
+
writeDiscordAwarenessSuccess(batchId, ref, action) {
|
|
6761
|
+
try {
|
|
6762
|
+
this.discordAwarenessOutbox.recordSuccess(batchId, ref, action);
|
|
6763
|
+
}
|
|
6764
|
+
catch (error) {
|
|
6765
|
+
throw new DiscordAwarenessAccountingError('recordSuccess ledger write', error);
|
|
6766
|
+
}
|
|
6767
|
+
}
|
|
6768
|
+
writeDiscordAwarenessFailure(batchId, ref, action, errorMessage, permanent) {
|
|
6769
|
+
try {
|
|
6770
|
+
this.discordAwarenessOutbox.recordFailure(batchId, ref, action, errorMessage, permanent);
|
|
6771
|
+
}
|
|
6772
|
+
catch (error) {
|
|
6773
|
+
throw new DiscordAwarenessAccountingError('recordFailure ledger write', error);
|
|
6774
|
+
}
|
|
6775
|
+
}
|
|
6776
|
+
beginDiscordAwarenessBarrier() {
|
|
6777
|
+
if (!this.discordAwarenessOutbox) {
|
|
6778
|
+
return {
|
|
6779
|
+
requiresBarrier: false,
|
|
6780
|
+
promise: Promise.resolve({ status: 'delivered', delivered: 0, failed: 0 }),
|
|
6781
|
+
};
|
|
6782
|
+
}
|
|
6783
|
+
try {
|
|
6784
|
+
this.discordAwarenessOutbox.reconcileForBranch(this.store.currentBranch().name, this.store.listBranches());
|
|
6785
|
+
}
|
|
6786
|
+
catch (error) {
|
|
6787
|
+
throw new DiscordAwarenessAccountingError('branch reconciliation', error);
|
|
6788
|
+
}
|
|
6789
|
+
const pending = this.readDiscordAwarenessPending();
|
|
6790
|
+
if (pending.length === 0) {
|
|
6791
|
+
return {
|
|
6792
|
+
requiresBarrier: false,
|
|
6793
|
+
promise: Promise.resolve({ status: 'delivered', delivered: 0, failed: 0 }),
|
|
6794
|
+
};
|
|
6795
|
+
}
|
|
6796
|
+
const serverIds = [...new Set(pending.map((operation) => operation.ref.serverId))];
|
|
6797
|
+
const promise = Promise.all(serverIds.map((serverId) => this.drainDiscordAwarenessOutbox(serverId))).then((outcomes) => {
|
|
6798
|
+
if (outcomes.every((outcome) => outcome.status === 'unavailable')) {
|
|
6799
|
+
return {
|
|
6800
|
+
status: 'unavailable',
|
|
6801
|
+
accounted: outcomes.reduce((count, outcome) => count + (outcome.status === 'unavailable' ? outcome.accounted : 0), 0),
|
|
6802
|
+
};
|
|
6803
|
+
}
|
|
6804
|
+
return {
|
|
6805
|
+
status: 'delivered',
|
|
6806
|
+
delivered: outcomes.reduce((count, outcome) => count + (outcome.status === 'delivered' ? outcome.delivered : 0), 0),
|
|
6807
|
+
failed: outcomes.reduce((count, outcome) => count + (outcome.status === 'delivered' ? outcome.failed : 0), 0),
|
|
6808
|
+
};
|
|
6809
|
+
});
|
|
6810
|
+
return { requiresBarrier: true, promise };
|
|
6811
|
+
}
|
|
6812
|
+
/**
|
|
6813
|
+
* Install one framework-global Discord-awareness generation before releasing
|
|
6814
|
+
* any inference-bearing event. With pending work, every MCPL data plane is
|
|
6815
|
+
* paused while all control planes remain live. With no pending work, ready()
|
|
6816
|
+
* runs synchronously so request responders preserve their historical
|
|
6817
|
+
* same-stack behavior.
|
|
6818
|
+
*/
|
|
6819
|
+
installMcplDataPlaneGate() {
|
|
6820
|
+
for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) {
|
|
6821
|
+
connection.pauseDataPlane();
|
|
6822
|
+
}
|
|
6823
|
+
const generation = ++this.discordAwarenessBarrierGeneration;
|
|
6824
|
+
let begun;
|
|
6825
|
+
try {
|
|
6826
|
+
begun = this.beginDiscordAwarenessBarrier();
|
|
6827
|
+
}
|
|
6828
|
+
catch (error) {
|
|
6829
|
+
begun = {
|
|
6830
|
+
requiresBarrier: true,
|
|
6831
|
+
promise: Promise.reject(error),
|
|
6832
|
+
};
|
|
6833
|
+
}
|
|
6834
|
+
const barrier = { generation, ...begun };
|
|
6835
|
+
this.discordAwarenessBarrier = barrier;
|
|
6836
|
+
return barrier;
|
|
6837
|
+
}
|
|
6838
|
+
releaseMcplDataPlaneGate(barrier) {
|
|
6839
|
+
if (this.discordAwarenessBarrier !== barrier)
|
|
6840
|
+
return;
|
|
6841
|
+
if (barrier.requiresBarrier) {
|
|
6842
|
+
for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) {
|
|
6843
|
+
connection.readyControlPlane();
|
|
6844
|
+
}
|
|
6845
|
+
return;
|
|
6846
|
+
}
|
|
6847
|
+
this.completeMcplDataPlaneGate(barrier);
|
|
6848
|
+
}
|
|
6849
|
+
completeMcplDataPlaneGate(barrier) {
|
|
6850
|
+
if (this.discordAwarenessBarrier !== barrier)
|
|
6851
|
+
return false;
|
|
6852
|
+
this.discordAwarenessBarrier = null;
|
|
6853
|
+
for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) {
|
|
6854
|
+
// ready() can synchronously flush a nested list-change notification that
|
|
6855
|
+
// installs a newer global generation. Never let this older completion
|
|
6856
|
+
// release any remaining server behind that newer gate.
|
|
6857
|
+
if (this.discordAwarenessBarrier !== null)
|
|
6858
|
+
return false;
|
|
6859
|
+
connection.ready();
|
|
6860
|
+
}
|
|
6861
|
+
return this.discordAwarenessBarrier === null;
|
|
6862
|
+
}
|
|
6863
|
+
async failMcplDataPlaneGate(barrier, context, error) {
|
|
6864
|
+
if (this.discordAwarenessBarrier !== barrier)
|
|
6865
|
+
return;
|
|
6866
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
6867
|
+
console.error(`[discord-awareness] ${context} failed globally:`, err.message);
|
|
6868
|
+
const connections = this.mcplServerRegistry?.getAllServers() ?? [];
|
|
6869
|
+
for (const connection of connections) {
|
|
6870
|
+
this.emitTrace({
|
|
6871
|
+
type: 'mcpl:server-error',
|
|
6872
|
+
serverId: connection.id,
|
|
6873
|
+
error: `Discord awareness accounting unhealthy: ${err.message}`,
|
|
6874
|
+
});
|
|
6875
|
+
}
|
|
6876
|
+
await Promise.all(connections.map(async (connection) => {
|
|
6877
|
+
await connection.reconnectAfterFailure().catch((closeError) => {
|
|
6878
|
+
console.error(`[discord-awareness] could not recycle unhealthy connection ${connection.id}:`, closeError instanceof Error ? closeError.message : closeError);
|
|
6879
|
+
});
|
|
6880
|
+
}));
|
|
6881
|
+
}
|
|
4300
6882
|
/**
|
|
4301
6883
|
* Connect a single MCPL server: register routing entries, open the
|
|
4302
6884
|
* connection, wire events, and initialize feature sets / scopes /
|
|
4303
6885
|
* checkpoints. Shared by startup (initializeMcpl) and the runtime
|
|
4304
6886
|
* lifecycle API (connectMcplServer). Throws on connection failure.
|
|
4305
6887
|
*/
|
|
4306
|
-
async connectMcplServerInternal(config) {
|
|
6888
|
+
async connectMcplServerInternal(config, deferAwareness = false) {
|
|
4307
6889
|
if (!this.mcplServerRegistry || !this.mcplHostCapabilities) {
|
|
4308
6890
|
throw new Error('MCPL subsystem is not initialized');
|
|
4309
6891
|
}
|
|
@@ -4314,16 +6896,32 @@ export class AgentFramework {
|
|
|
4314
6896
|
this.mcplServerConfigs.set(config.id, config);
|
|
4315
6897
|
// Record per-server channel subscription policy before the server
|
|
4316
6898
|
// registers channels — handleRegister fires during the handshake.
|
|
4317
|
-
if (
|
|
4318
|
-
this.channelRegistry.setSubscriptionPolicy(config.id, config.channelSubscription);
|
|
6899
|
+
if (this.channelRegistry) {
|
|
6900
|
+
this.channelRegistry.setSubscriptionPolicy(config.id, config.channelSubscription ?? 'manual');
|
|
4319
6901
|
}
|
|
4320
6902
|
const connection = await this.mcplServerRegistry.addServer(config, this.mcplHostCapabilities);
|
|
4321
|
-
// Wire
|
|
4322
|
-
//
|
|
6903
|
+
// Wire listeners before either startup staging or the runtime global gate
|
|
6904
|
+
// releases control traffic needed for registration and marker service.
|
|
4323
6905
|
this.wireMcplEvents(connection);
|
|
4324
|
-
connection.ready();
|
|
4325
6906
|
// Initialize feature sets if server advertises MCPL capabilities
|
|
4326
6907
|
this.registerMcplServerFeatures(config, connection);
|
|
6908
|
+
if (deferAwareness) {
|
|
6909
|
+
this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
|
|
6910
|
+
return;
|
|
6911
|
+
}
|
|
6912
|
+
const awarenessBarrier = this.installMcplDataPlaneGate();
|
|
6913
|
+
this.releaseMcplDataPlaneGate(awarenessBarrier);
|
|
6914
|
+
try {
|
|
6915
|
+
await awarenessBarrier.promise;
|
|
6916
|
+
this.completeMcplDataPlaneGate(awarenessBarrier);
|
|
6917
|
+
}
|
|
6918
|
+
catch (error) {
|
|
6919
|
+
// Startup cannot fail open on a broken awareness ledger. Disable the
|
|
6920
|
+
// reconnecting stub/connection before propagating the distinct error to
|
|
6921
|
+
// initializeMcpl, which tears down any other servers and aborts create().
|
|
6922
|
+
await connection.close().catch(() => { });
|
|
6923
|
+
throw error;
|
|
6924
|
+
}
|
|
4327
6925
|
this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
|
|
4328
6926
|
}
|
|
4329
6927
|
/**
|
|
@@ -4490,12 +7088,18 @@ export class AgentFramework {
|
|
|
4490
7088
|
}
|
|
4491
7089
|
});
|
|
4492
7090
|
// Handle push events (Step 6)
|
|
4493
|
-
connection.on('push-event', (params, responder) => {
|
|
7091
|
+
connection.on('push-event', async (params, responder) => {
|
|
7092
|
+
const barrier = this.discordAwarenessBarrier;
|
|
7093
|
+
if (barrier)
|
|
7094
|
+
await barrier.promise;
|
|
4494
7095
|
this.pushHandler?.handlePushEvent(connection.id, params, responder);
|
|
4495
7096
|
});
|
|
4496
7097
|
// Handle server-initiated inference requests (Step 6)
|
|
4497
7098
|
connection.on('inference-request', async (params, responder) => {
|
|
4498
7099
|
if (this.inferenceRouter && responder) {
|
|
7100
|
+
const barrier = this.discordAwarenessBarrier;
|
|
7101
|
+
if (barrier)
|
|
7102
|
+
await barrier.promise;
|
|
4499
7103
|
await this.inferenceRouter.handleInferenceRequest(connection.id, params, {
|
|
4500
7104
|
respond: responder.respond,
|
|
4501
7105
|
respondError: responder.respondError,
|
|
@@ -4512,7 +7116,10 @@ export class AgentFramework {
|
|
|
4512
7116
|
await this.channelRegistry?.handleChanged(connection.id, params);
|
|
4513
7117
|
});
|
|
4514
7118
|
// Handle incoming channel messages (Step 7)
|
|
4515
|
-
connection.on('channels-incoming', (params, responder) => {
|
|
7119
|
+
connection.on('channels-incoming', async (params, responder) => {
|
|
7120
|
+
const barrier = this.discordAwarenessBarrier;
|
|
7121
|
+
if (barrier)
|
|
7122
|
+
await barrier.promise;
|
|
4516
7123
|
this.channelRegistry?.handleIncoming(connection.id, params, responder);
|
|
4517
7124
|
});
|
|
4518
7125
|
// Handle host-level admin commands from a surface (e.g. Discord /undo)
|
|
@@ -4520,6 +7127,9 @@ export class AgentFramework {
|
|
|
4520
7127
|
if (!responder)
|
|
4521
7128
|
return;
|
|
4522
7129
|
try {
|
|
7130
|
+
const barrier = this.discordAwarenessBarrier;
|
|
7131
|
+
if (barrier)
|
|
7132
|
+
await barrier.promise;
|
|
4523
7133
|
const result = await this.handleHostCommand(connection.id, params ?? {});
|
|
4524
7134
|
responder.respond(result);
|
|
4525
7135
|
}
|
|
@@ -4530,7 +7140,15 @@ export class AgentFramework {
|
|
|
4530
7140
|
});
|
|
4531
7141
|
// Handle dynamic tool list changes (notifications/tools/list_changed)
|
|
4532
7142
|
connection.on('tools-list-changed', () => {
|
|
7143
|
+
// Pause before starting any async refresh. This listener runs
|
|
7144
|
+
// synchronously in the transport line callback, so a following inbound
|
|
7145
|
+
// data event cannot pass before the new barrier exists.
|
|
7146
|
+
const awarenessBarrier = this.installMcplDataPlaneGate();
|
|
7147
|
+
this.releaseMcplDataPlaneGate(awarenessBarrier);
|
|
4533
7148
|
this.handleToolsListChanged(connection.id);
|
|
7149
|
+
void awarenessBarrier.promise.then(() => {
|
|
7150
|
+
this.completeMcplDataPlaneGate(awarenessBarrier);
|
|
7151
|
+
}).catch((error) => this.failMcplDataPlaneGate(awarenessBarrier, 'tools-list reconciliation', error));
|
|
4534
7152
|
});
|
|
4535
7153
|
// Re-establish full server registration on reconnect. The 'close' handler
|
|
4536
7154
|
// removed the feature-set state, so pushes / inference requests from the
|
|
@@ -4540,6 +7158,10 @@ export class AgentFramework {
|
|
|
4540
7158
|
// preserved across the transient close are resumed by idempotent
|
|
4541
7159
|
// registration. Then refresh tools (server may have different tools).
|
|
4542
7160
|
connection.on('reconnect', (info) => {
|
|
7161
|
+
// Install the barrier synchronously so any inbound event emitted after
|
|
7162
|
+
// reconnect observes it before doing work that could wake an agent. The
|
|
7163
|
+
// connection paused its data plane before wiring the fresh transport.
|
|
7164
|
+
const awarenessBarrier = this.installMcplDataPlaneGate();
|
|
4543
7165
|
try {
|
|
4544
7166
|
const config = this.mcplServerConfigs.get(connection.id);
|
|
4545
7167
|
if (config) {
|
|
@@ -4549,15 +7171,21 @@ export class AgentFramework {
|
|
|
4549
7171
|
catch (error) {
|
|
4550
7172
|
console.error(`MCPL server "${connection.id}" re-registration after reconnect failed:`, error instanceof Error ? error.message : error);
|
|
4551
7173
|
}
|
|
7174
|
+
this.releaseMcplDataPlaneGate(awarenessBarrier);
|
|
4552
7175
|
this.handleToolsListChanged(connection.id);
|
|
4553
|
-
|
|
4554
|
-
|
|
4555
|
-
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
|
|
4559
|
-
|
|
4560
|
-
|
|
7176
|
+
void awarenessBarrier.promise.then(() => {
|
|
7177
|
+
if (awarenessBarrier.requiresBarrier
|
|
7178
|
+
&& !this.completeMcplDataPlaneGate(awarenessBarrier))
|
|
7179
|
+
return;
|
|
7180
|
+
this.emitTrace({
|
|
7181
|
+
type: 'mcpl:server-reconnected',
|
|
7182
|
+
serverId: connection.id,
|
|
7183
|
+
attempts: info?.attempts ?? 0,
|
|
7184
|
+
});
|
|
7185
|
+
// Mirror the module:removed emitted on 'close' so module-lifecycle
|
|
7186
|
+
// consumers see the server come back, not just vanish.
|
|
7187
|
+
this.emitTrace({ type: 'module:added', moduleName: `mcpl:${connection.id}` });
|
|
7188
|
+
}).catch((error) => this.failMcplDataPlaneGate(awarenessBarrier, 'reconnect reconciliation', error));
|
|
4561
7189
|
});
|
|
4562
7190
|
// Surface connect/reconnect failures. Before these traces existed the
|
|
4563
7191
|
// only receipt was a console.error on the host's own stderr — invisible
|
|
@@ -4596,6 +7224,7 @@ export class AgentFramework {
|
|
|
4596
7224
|
type: 'mcpl:orphaned-response',
|
|
4597
7225
|
serverId: connection.id,
|
|
4598
7226
|
responseId: info.id,
|
|
7227
|
+
method: info.method,
|
|
4599
7228
|
hadState: info.hadState,
|
|
4600
7229
|
hadCheckpoint: info.hadCheckpoint,
|
|
4601
7230
|
});
|
|
@@ -5021,6 +7650,308 @@ export class AgentFramework {
|
|
|
5021
7650
|
endTurn: true,
|
|
5022
7651
|
});
|
|
5023
7652
|
}
|
|
7653
|
+
/**
|
|
7654
|
+
* Handle the synthesized `save_recent_image` tool: locate the requested
|
|
7655
|
+
* image blocks in the calling agent's context (blobs re-inlined, counted
|
|
7656
|
+
* back from the most recent) and write their bytes to a workspace mount
|
|
7657
|
+
* via WorkspaceModule.writeBinary.
|
|
7658
|
+
*/
|
|
7659
|
+
dispatchSaveImageToolCall(agentName, call) {
|
|
7660
|
+
this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
|
|
7661
|
+
const finish = (result) => {
|
|
7662
|
+
this.emitTrace({
|
|
7663
|
+
type: result.isError ? 'tool:failed' : 'tool:completed',
|
|
7664
|
+
module: 'workspace',
|
|
7665
|
+
tool: call.name,
|
|
7666
|
+
callId: call.id,
|
|
7667
|
+
durationMs: 0,
|
|
7668
|
+
...(result.isError ? { error: result.error } : {}),
|
|
7669
|
+
});
|
|
7670
|
+
this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace', result });
|
|
7671
|
+
};
|
|
7672
|
+
void (async () => {
|
|
7673
|
+
try {
|
|
7674
|
+
const workspace = this.getWorkspaceModule();
|
|
7675
|
+
if (!workspace)
|
|
7676
|
+
throw new Error('save_recent_image requires a workspace module');
|
|
7677
|
+
const agent = this.agents.get(agentName);
|
|
7678
|
+
if (!agent)
|
|
7679
|
+
throw new Error(`Unknown agent: ${agentName}`);
|
|
7680
|
+
const input = (call.input ?? {});
|
|
7681
|
+
if (typeof input.path !== 'string' || input.path.length === 0) {
|
|
7682
|
+
throw new Error('save_recent_image: `path` (mount-prefixed) is required');
|
|
7683
|
+
}
|
|
7684
|
+
const index = input.index === undefined ? 0 : Number(input.index);
|
|
7685
|
+
if (!Number.isInteger(index) || index < 0) {
|
|
7686
|
+
throw new Error('save_recent_image: `index` must be a non-negative integer');
|
|
7687
|
+
}
|
|
7688
|
+
const count = input.count === undefined ? 1 : Number(input.count);
|
|
7689
|
+
const MAX_COUNT = 20;
|
|
7690
|
+
if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) {
|
|
7691
|
+
throw new Error(`save_recent_image: \`count\` must be an integer in 1..${MAX_COUNT}`);
|
|
7692
|
+
}
|
|
7693
|
+
const lastWanted = index + count - 1;
|
|
7694
|
+
// Walk the message store tail-first in bounded windows, re-inlining
|
|
7695
|
+
// blob media, until we've collected the requested range. Scanning is
|
|
7696
|
+
// capped so a pathological index can't drag the whole store through
|
|
7697
|
+
// blob resolution.
|
|
7698
|
+
const MAX_SCAN = 500;
|
|
7699
|
+
const WINDOW = 25;
|
|
7700
|
+
const cm = agent.getContextManager();
|
|
7701
|
+
const total = cm.getMessageCount();
|
|
7702
|
+
let seen = 0;
|
|
7703
|
+
let scanned = 0;
|
|
7704
|
+
const found = [];
|
|
7705
|
+
for (let end = total; end > 0 && scanned < MAX_SCAN && found.length < count; end -= WINDOW) {
|
|
7706
|
+
const start = Math.max(0, end - WINDOW);
|
|
7707
|
+
const { messages } = cm.getMessageWindow(start, end - start, { resolveBlobs: true });
|
|
7708
|
+
scanned += end - start;
|
|
7709
|
+
for (let i = messages.length - 1; i >= 0 && found.length < count; i--) {
|
|
7710
|
+
const content = messages[i]?.content;
|
|
7711
|
+
if (!Array.isArray(content))
|
|
7712
|
+
continue;
|
|
7713
|
+
for (let b = content.length - 1; b >= 0 && found.length < count; b--) {
|
|
7714
|
+
const block = content[b];
|
|
7715
|
+
if (block?.type !== 'image')
|
|
7716
|
+
continue;
|
|
7717
|
+
if (seen >= index && seen <= lastWanted) {
|
|
7718
|
+
if (block.source?.type !== 'base64' || typeof block.source.data !== 'string') {
|
|
7719
|
+
throw new Error(`save_recent_image: image at index ${seen} is not stored inline (base64) — cannot save it`);
|
|
7720
|
+
}
|
|
7721
|
+
found.push({
|
|
7722
|
+
rangeOffset: seen - index,
|
|
7723
|
+
data: block.source.data,
|
|
7724
|
+
mediaType: block.source.mediaType ?? 'image/png',
|
|
7725
|
+
messagesBack: total - (start + i),
|
|
7726
|
+
});
|
|
7727
|
+
}
|
|
7728
|
+
seen++;
|
|
7729
|
+
}
|
|
7730
|
+
}
|
|
7731
|
+
}
|
|
7732
|
+
if (found.length === 0) {
|
|
7733
|
+
throw new Error(seen === 0
|
|
7734
|
+
? `save_recent_image: no images found in the most recent ${Math.min(scanned, MAX_SCAN)} messages`
|
|
7735
|
+
: `save_recent_image: only ${seen} image(s) found in the most recent ${Math.min(scanned, MAX_SCAN)} messages (asked for index ${index})`);
|
|
7736
|
+
}
|
|
7737
|
+
// Single image → path as given. Range → numeric suffix before the
|
|
7738
|
+
// extension ("cat.png" → "cat-0.png"), 0 = newest of the range.
|
|
7739
|
+
const pathFor = (rangeOffset) => {
|
|
7740
|
+
if (count === 1)
|
|
7741
|
+
return input.path;
|
|
7742
|
+
const p = input.path;
|
|
7743
|
+
const dot = p.lastIndexOf('.');
|
|
7744
|
+
const slash = p.lastIndexOf('/');
|
|
7745
|
+
return dot > slash
|
|
7746
|
+
? `${p.slice(0, dot)}-${rangeOffset}${p.slice(dot)}`
|
|
7747
|
+
: `${p}-${rangeOffset}`;
|
|
7748
|
+
};
|
|
7749
|
+
const savedFiles = [];
|
|
7750
|
+
for (const image of found) {
|
|
7751
|
+
const destination = pathFor(image.rangeOffset);
|
|
7752
|
+
const bytes = Buffer.from(image.data, 'base64');
|
|
7753
|
+
const result = await workspace.writeBinary(destination, bytes, image.mediaType);
|
|
7754
|
+
if (!result.success) {
|
|
7755
|
+
finish({
|
|
7756
|
+
success: false,
|
|
7757
|
+
error: `save_recent_image: failed writing "${destination}": ${result.error}` +
|
|
7758
|
+
(savedFiles.length > 0
|
|
7759
|
+
? ` (already saved: ${savedFiles.map((f) => f.path).join(', ')})`
|
|
7760
|
+
: ''),
|
|
7761
|
+
isError: true,
|
|
7762
|
+
});
|
|
7763
|
+
return;
|
|
7764
|
+
}
|
|
7765
|
+
savedFiles.push({
|
|
7766
|
+
...result.data,
|
|
7767
|
+
imageIndex: index + image.rangeOffset,
|
|
7768
|
+
messagesBack: image.messagesBack,
|
|
7769
|
+
});
|
|
7770
|
+
}
|
|
7771
|
+
finish({
|
|
7772
|
+
success: true,
|
|
7773
|
+
data: {
|
|
7774
|
+
saved: savedFiles,
|
|
7775
|
+
...(found.length < count
|
|
7776
|
+
? { note: `only ${found.length} of ${count} requested images exist in the scanned window` }
|
|
7777
|
+
: {}),
|
|
7778
|
+
},
|
|
7779
|
+
});
|
|
7780
|
+
}
|
|
7781
|
+
catch (error) {
|
|
7782
|
+
finish({
|
|
7783
|
+
success: false,
|
|
7784
|
+
error: error instanceof Error ? error.message : String(error),
|
|
7785
|
+
isError: true,
|
|
7786
|
+
});
|
|
7787
|
+
}
|
|
7788
|
+
})();
|
|
7789
|
+
}
|
|
7790
|
+
dispatchAgentSettingsToolCall(agentName, call) {
|
|
7791
|
+
this.emitTrace({
|
|
7792
|
+
type: 'tool:started',
|
|
7793
|
+
module: 'agent',
|
|
7794
|
+
tool: call.name,
|
|
7795
|
+
callId: call.id,
|
|
7796
|
+
input: call.input,
|
|
7797
|
+
});
|
|
7798
|
+
let result;
|
|
7799
|
+
try {
|
|
7800
|
+
const input = (call.input ?? {});
|
|
7801
|
+
const extensions = this.collectAgentSettingsExtensions();
|
|
7802
|
+
/** Extension values merged flat alongside the core snapshot. */
|
|
7803
|
+
const extGet = () => {
|
|
7804
|
+
const out = {};
|
|
7805
|
+
for (const ext of extensions.values())
|
|
7806
|
+
Object.assign(out, ext.get(agentName));
|
|
7807
|
+
return out;
|
|
7808
|
+
};
|
|
7809
|
+
switch (input.action) {
|
|
7810
|
+
case 'get':
|
|
7811
|
+
result = {
|
|
7812
|
+
success: true,
|
|
7813
|
+
data: { ...this.getAgentRuntimeSettings(agentName), ...extGet() },
|
|
7814
|
+
};
|
|
7815
|
+
break;
|
|
7816
|
+
case 'cancel':
|
|
7817
|
+
result = { success: true, data: this.cancelAgentRuntimeSettingsTransition(agentName) };
|
|
7818
|
+
break;
|
|
7819
|
+
case 'update': {
|
|
7820
|
+
const patch = {};
|
|
7821
|
+
if (input.context_budget_tokens !== undefined) {
|
|
7822
|
+
patch.contextBudgetTokens = Number(input.context_budget_tokens);
|
|
7823
|
+
}
|
|
7824
|
+
if (input.tail_tokens !== undefined)
|
|
7825
|
+
patch.tailTokens = Number(input.tail_tokens);
|
|
7826
|
+
if (input.transition_pace_tokens !== undefined) {
|
|
7827
|
+
patch.transitionPaceTokens = Number(input.transition_pace_tokens);
|
|
7828
|
+
}
|
|
7829
|
+
if (input.same_round_think_text_policy !== undefined) {
|
|
7830
|
+
patch.sameRoundThinkTextPolicy = input.same_round_think_text_policy;
|
|
7831
|
+
}
|
|
7832
|
+
// Route extension-owned keys to their modules; apply the core patch
|
|
7833
|
+
// only when it touches core keys (an extension-only update must not
|
|
7834
|
+
// disturb a converging budget transition).
|
|
7835
|
+
const extResults = {};
|
|
7836
|
+
for (const ext of extensions.values()) {
|
|
7837
|
+
const slice = {};
|
|
7838
|
+
for (const key of ext.keys) {
|
|
7839
|
+
if (input[key] !== undefined)
|
|
7840
|
+
slice[key] = input[key];
|
|
7841
|
+
}
|
|
7842
|
+
if (Object.keys(slice).length > 0) {
|
|
7843
|
+
Object.assign(extResults, ext.update(agentName, slice));
|
|
7844
|
+
}
|
|
7845
|
+
}
|
|
7846
|
+
const coreTouched = Object.keys(patch).length > 0;
|
|
7847
|
+
const core = coreTouched
|
|
7848
|
+
? this.updateAgentRuntimeSettings(agentName, patch)
|
|
7849
|
+
: this.getAgentRuntimeSettings(agentName);
|
|
7850
|
+
result = {
|
|
7851
|
+
success: true,
|
|
7852
|
+
data: {
|
|
7853
|
+
...core,
|
|
7854
|
+
...extGet(),
|
|
7855
|
+
...extResults,
|
|
7856
|
+
...(patch.sameRoundThinkTextPolicy !== undefined
|
|
7857
|
+
? {
|
|
7858
|
+
sameRoundThinkTextPolicyUpdateNote: 'Stored now; applies to provider think routing and description beginning with the next inference.',
|
|
7859
|
+
}
|
|
7860
|
+
: {}),
|
|
7861
|
+
},
|
|
7862
|
+
};
|
|
7863
|
+
break;
|
|
7864
|
+
}
|
|
7865
|
+
case 'reset': {
|
|
7866
|
+
let keys;
|
|
7867
|
+
const extResetKeys = new Map();
|
|
7868
|
+
const resetsAllSettings = input.settings === undefined;
|
|
7869
|
+
if (input.settings !== undefined) {
|
|
7870
|
+
if (!Array.isArray(input.settings))
|
|
7871
|
+
throw new Error('reset `settings` must be an array');
|
|
7872
|
+
const names = {
|
|
7873
|
+
context_budget_tokens: 'contextBudgetTokens',
|
|
7874
|
+
tail_tokens: 'tailTokens',
|
|
7875
|
+
transition_pace_tokens: 'transitionPaceTokens',
|
|
7876
|
+
same_round_think_text_policy: 'sameRoundThinkTextPolicy',
|
|
7877
|
+
};
|
|
7878
|
+
keys = [];
|
|
7879
|
+
for (const name of input.settings) {
|
|
7880
|
+
if (typeof name === 'string' && names[name]) {
|
|
7881
|
+
keys.push(names[name]);
|
|
7882
|
+
continue;
|
|
7883
|
+
}
|
|
7884
|
+
const owner = [...extensions.values()].find((ext) => typeof name === 'string' && ext.keys.includes(name));
|
|
7885
|
+
if (!owner)
|
|
7886
|
+
throw new Error(`Unknown reset setting: ${String(name)}`);
|
|
7887
|
+
const list = extResetKeys.get(owner) ?? [];
|
|
7888
|
+
list.push(name);
|
|
7889
|
+
extResetKeys.set(owner, list);
|
|
7890
|
+
}
|
|
7891
|
+
if (keys.length === 0)
|
|
7892
|
+
keys = undefined;
|
|
7893
|
+
}
|
|
7894
|
+
const touchedSameRoundThinkTextPolicy = resetsAllSettings || keys?.includes('sameRoundThinkTextPolicy') === true;
|
|
7895
|
+
const extResults = {};
|
|
7896
|
+
if (input.settings === undefined) {
|
|
7897
|
+
// Reset-all covers extensions too.
|
|
7898
|
+
for (const ext of extensions.values()) {
|
|
7899
|
+
if (ext.reset)
|
|
7900
|
+
Object.assign(extResults, ext.reset(agentName));
|
|
7901
|
+
}
|
|
7902
|
+
}
|
|
7903
|
+
else {
|
|
7904
|
+
for (const [ext, list] of extResetKeys) {
|
|
7905
|
+
if (ext.reset)
|
|
7906
|
+
Object.assign(extResults, ext.reset(agentName, list));
|
|
7907
|
+
}
|
|
7908
|
+
}
|
|
7909
|
+
const coreTouched = input.settings === undefined || keys !== undefined;
|
|
7910
|
+
const core = coreTouched
|
|
7911
|
+
? this.resetAgentRuntimeSettings(agentName, keys)
|
|
7912
|
+
: this.getAgentRuntimeSettings(agentName);
|
|
7913
|
+
result = {
|
|
7914
|
+
success: true,
|
|
7915
|
+
data: {
|
|
7916
|
+
...core,
|
|
7917
|
+
...extGet(),
|
|
7918
|
+
...extResults,
|
|
7919
|
+
...(touchedSameRoundThinkTextPolicy
|
|
7920
|
+
? {
|
|
7921
|
+
sameRoundThinkTextPolicyUpdateNote: 'Reset now; restored provider think routing and description apply beginning with the next inference.',
|
|
7922
|
+
}
|
|
7923
|
+
: {}),
|
|
7924
|
+
},
|
|
7925
|
+
};
|
|
7926
|
+
break;
|
|
7927
|
+
}
|
|
7928
|
+
default:
|
|
7929
|
+
throw new Error('agent_settings: action must be get, update, reset, or cancel');
|
|
7930
|
+
}
|
|
7931
|
+
}
|
|
7932
|
+
catch (error) {
|
|
7933
|
+
result = {
|
|
7934
|
+
success: false,
|
|
7935
|
+
error: error instanceof Error ? error.message : String(error),
|
|
7936
|
+
isError: true,
|
|
7937
|
+
};
|
|
7938
|
+
}
|
|
7939
|
+
this.emitTrace({
|
|
7940
|
+
type: result.isError ? 'tool:failed' : 'tool:completed',
|
|
7941
|
+
module: 'agent',
|
|
7942
|
+
tool: call.name,
|
|
7943
|
+
callId: call.id,
|
|
7944
|
+
durationMs: 0,
|
|
7945
|
+
...(result.isError ? { error: result.error } : {}),
|
|
7946
|
+
});
|
|
7947
|
+
this.pushEvent({
|
|
7948
|
+
type: 'tool-result',
|
|
7949
|
+
callId: call.id,
|
|
7950
|
+
agentName,
|
|
7951
|
+
moduleName: 'agent',
|
|
7952
|
+
result,
|
|
7953
|
+
});
|
|
7954
|
+
}
|
|
5024
7955
|
/** Aggregate the event-tag vocabulary: reserved chat:* core + each connected
|
|
5025
7956
|
* server's declared tag ontology + gate.js status. */
|
|
5026
7957
|
buildEventTagsResult() {
|