@animalabs/agent-framework 0.6.6 → 0.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -0
- package/dist/src/agent.d.ts +5 -1
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +35 -1
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/mcp-server.js +0 -0
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +7 -2
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/framework.d.ts +91 -3
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +988 -82
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +9 -2
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +3 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +29 -15
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +36 -3
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +142 -28
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +12 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +661 -2
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +4 -0
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.d.ts +99 -0
- package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -0
- package/dist/src/recovery/discord-awareness-outbox.js +334 -0
- package/dist/src/recovery/discord-awareness-outbox.js.map +1 -0
- package/dist/src/recovery/offline-branch.d.ts +45 -0
- package/dist/src/recovery/offline-branch.d.ts.map +1 -0
- package/dist/src/recovery/offline-branch.js +362 -0
- package/dist/src/recovery/offline-branch.js.map +1 -0
- package/dist/src/recovery/recover-cli.d.ts +3 -0
- package/dist/src/recovery/recover-cli.d.ts.map +1 -0
- package/dist/src/recovery/recover-cli.js +129 -0
- package/dist/src/recovery/recover-cli.js.map +1 -0
- package/dist/src/types/agent.d.ts +10 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +14 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +31 -0
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +2 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/agent-runtime-settings.test.js +200 -93
- package/dist/test/agent-runtime-settings.test.js.map +1 -1
- package/dist/test/busy-agent-scheduler.test.d.ts +2 -0
- package/dist/test/busy-agent-scheduler.test.d.ts.map +1 -0
- package/dist/test/busy-agent-scheduler.test.js +62 -0
- package/dist/test/busy-agent-scheduler.test.js.map +1 -0
- package/dist/test/channel-lifecycle-provenance.test.d.ts +2 -0
- package/dist/test/channel-lifecycle-provenance.test.d.ts.map +1 -0
- package/dist/test/channel-lifecycle-provenance.test.js +47 -0
- package/dist/test/channel-lifecycle-provenance.test.js.map +1 -0
- package/dist/test/discord-awareness-outbox.test.d.ts +2 -0
- package/dist/test/discord-awareness-outbox.test.d.ts.map +1 -0
- package/dist/test/discord-awareness-outbox.test.js +248 -0
- package/dist/test/discord-awareness-outbox.test.js.map +1 -0
- package/dist/test/maintenance-timer.test.js +100 -0
- package/dist/test/maintenance-timer.test.js.map +1 -1
- package/dist/test/mcpl-awareness-barrier.test.d.ts +2 -0
- package/dist/test/mcpl-awareness-barrier.test.d.ts.map +1 -0
- package/dist/test/mcpl-awareness-barrier.test.js +618 -0
- package/dist/test/mcpl-awareness-barrier.test.js.map +1 -0
- package/dist/test/mcpl-reconnect-reregistration.test.js +22 -3
- package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
- package/dist/test/mcpl-request-timeout.test.js +15 -0
- package/dist/test/mcpl-request-timeout.test.js.map +1 -1
- package/dist/test/offline-recovery-branch.test.d.ts +2 -0
- package/dist/test/offline-recovery-branch.test.d.ts.map +1 -0
- package/dist/test/offline-recovery-branch.test.js +226 -0
- package/dist/test/offline-recovery-branch.test.js.map +1 -0
- package/dist/test/present-while-acting.test.d.ts +3 -2
- package/dist/test/present-while-acting.test.d.ts.map +1 -1
- package/dist/test/present-while-acting.test.js +31 -3
- package/dist/test/present-while-acting.test.js.map +1 -1
- package/dist/test/refusal-single-call.test.d.ts +2 -0
- package/dist/test/refusal-single-call.test.d.ts.map +1 -0
- package/dist/test/refusal-single-call.test.js +177 -0
- package/dist/test/refusal-single-call.test.js.map +1 -0
- package/dist/test/same-round-think-policy.test.d.ts +2 -0
- package/dist/test/same-round-think-policy.test.d.ts.map +1 -0
- package/dist/test/same-round-think-policy.test.js +853 -0
- package/dist/test/same-round-think-policy.test.js.map +1 -0
- package/dist/test/workspace-read-image.test.d.ts +2 -0
- package/dist/test/workspace-read-image.test.d.ts.map +1 -0
- package/dist/test/workspace-read-image.test.js +518 -0
- package/dist/test/workspace-read-image.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -3
- package/src/agent.ts +39 -2
- package/src/api/server.ts +8 -2
- package/src/framework.ts +1180 -79
- package/src/gate/event-gate.ts +15 -3
- package/src/index.ts +20 -0
- package/src/mcpl/channel-registry.ts +31 -16
- package/src/mcpl/server-connection.ts +163 -28
- package/src/modules/workspace/index.ts +759 -2
- package/src/modules/workspace/types.ts +5 -0
- package/src/recovery/discord-awareness-outbox.ts +459 -0
- package/src/recovery/offline-branch.ts +510 -0
- package/src/recovery/recover-cli.ts +130 -0
- package/src/types/agent.ts +14 -0
- package/src/types/framework.ts +17 -0
- package/src/types/module.ts +33 -0
- package/src/types/trace.ts +2 -0
package/dist/src/framework.js
CHANGED
|
@@ -25,15 +25,14 @@ import { splitProseSegments } from './prose-segments.js';
|
|
|
25
25
|
* - explicit delivery tools (channel_publish / *send_message /
|
|
26
26
|
* *reply_message / *send_dm) — already sent the message, so routing the
|
|
27
27
|
* prose again would double-post.
|
|
28
|
-
* `think` is deliberately NOT here: it is silent *reasoning*,
|
|
29
|
-
*
|
|
28
|
+
* `think` is deliberately NOT here: it is silent *reasoning*, and same-round
|
|
29
|
+
* prose beside it is governed separately by same_round_think_text_policy.
|
|
30
30
|
*
|
|
31
|
-
* Scope:
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* double-post guard.
|
|
31
|
+
* Scope: an explicit delivery suppresses prose from that round onward until
|
|
32
|
+
* another external message is injected. The suppression prevents a
|
|
33
|
+
* `send_message` followed by "sent it" from double-posting, but a new message
|
|
34
|
+
* starts a new conversational round and must be answerable with plain prose.
|
|
35
|
+
* `skip_reply` ends the turn at the tool-result boundary.
|
|
37
36
|
*/
|
|
38
37
|
const SILENCING_TOOLS = new Set([
|
|
39
38
|
'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
|
|
@@ -45,6 +44,7 @@ import { isToolAllowed } from './mcpl/tool-policy.js';
|
|
|
45
44
|
import { EventGate } from './gate/event-gate.js';
|
|
46
45
|
import { UsageTracker } from './usage/usage-tracker.js';
|
|
47
46
|
import { formatZonedDateTime, resolveTimeZone } from './timezone.js';
|
|
47
|
+
import { DEFAULT_DISCORD_AWARENESS_EMOJI, DiscordAwarenessOutbox, defaultDiscordAwarenessOutboxPath, extractDiscordAwarenessRefs, } from './recovery/discord-awareness-outbox.js';
|
|
48
48
|
const FRAMEWORK_STATE_ID = 'framework/state';
|
|
49
49
|
const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router';
|
|
50
50
|
const INFERENCE_LOG_ID = 'framework/inference-log';
|
|
@@ -53,6 +53,22 @@ const TURN_CHECKPOINTS_ID = 'framework/turn-checkpoints'; // legacy single-map l
|
|
|
53
53
|
const TURN_CHECKPOINTS_TREE_ID = 'framework/turn-checkpoints/tree';
|
|
54
54
|
/** Maximum number of turn checkpoints to keep per agent. */
|
|
55
55
|
const MAX_TURN_CHECKPOINTS = 20;
|
|
56
|
+
const DEFAULT_DISCORD_AWARENESS_DEADLINE_MS = 10_000;
|
|
57
|
+
const MIN_DISCORD_AWARENESS_DEADLINE_MS = 50;
|
|
58
|
+
const MAX_DISCORD_AWARENESS_DEADLINE_MS = 60_000;
|
|
59
|
+
function normalizeDiscordAwarenessDeadline(value) {
|
|
60
|
+
if (value === undefined || !Number.isFinite(value) || value <= 0) {
|
|
61
|
+
return DEFAULT_DISCORD_AWARENESS_DEADLINE_MS;
|
|
62
|
+
}
|
|
63
|
+
return Math.max(MIN_DISCORD_AWARENESS_DEADLINE_MS, Math.min(MAX_DISCORD_AWARENESS_DEADLINE_MS, Math.floor(value)));
|
|
64
|
+
}
|
|
65
|
+
class DiscordAwarenessAccountingError extends Error {
|
|
66
|
+
constructor(operation, cause) {
|
|
67
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
68
|
+
super(`Discord awareness accounting failed during ${operation}: ${detail}`, { cause });
|
|
69
|
+
this.name = 'DiscordAwarenessAccountingError';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
56
72
|
/**
|
|
57
73
|
* Default inference policy - infer if any request exists for the agent.
|
|
58
74
|
*/
|
|
@@ -61,6 +77,10 @@ class DefaultInferencePolicy {
|
|
|
61
77
|
return requests.some((r) => r.agentName === agentName);
|
|
62
78
|
}
|
|
63
79
|
}
|
|
80
|
+
function isPermanentDiscordReactionFailure(message) {
|
|
81
|
+
return /unknown message|unknown channel|missing access|missing permissions|missing permission|cannot access|channel .* not found|message .* not found/i
|
|
82
|
+
.test(message);
|
|
83
|
+
}
|
|
64
84
|
/**
|
|
65
85
|
* Default error policy - retry with exponential backoff.
|
|
66
86
|
*
|
|
@@ -162,7 +182,7 @@ export class AgentFramework {
|
|
|
162
182
|
maintenanceRunId = 0;
|
|
163
183
|
currentMaintenanceRun = null;
|
|
164
184
|
maintenanceHistory = [];
|
|
165
|
-
/** Last time we
|
|
185
|
+
/** Last time we reported stale (busy-requeued) inference requests, per agent. */
|
|
166
186
|
staleWarnAt = new Map();
|
|
167
187
|
/** Per-agent last inference activity (epoch ms), for /healthz + doctor tooling. */
|
|
168
188
|
lastInferenceAt = new Map();
|
|
@@ -172,9 +192,17 @@ export class AgentFramework {
|
|
|
172
192
|
/** Per-agent output locus pinned for the CURRENT logical turn (see
|
|
173
193
|
* resolveTurnLocus in driveStream). Lives here — not in driveStream
|
|
174
194
|
* locals — so the pin survives a context-budget stream restart, which
|
|
175
|
-
* continues the same logical turn in a fresh driveStream.
|
|
176
|
-
*
|
|
195
|
+
* continues the same logical turn in a fresh driveStream. A mid-turn
|
|
196
|
+
* injected channel message replaces the pin for the next conversational
|
|
197
|
+
* round; otherwise it remains stable. Cleared at the start of every
|
|
198
|
+
* non-restart turn. */
|
|
177
199
|
turnLocusPins = new Map();
|
|
200
|
+
/** A tool boundary injected fresh external input into the live stream.
|
|
201
|
+
* Presence (including a null value) tells driveStream to clear sticky
|
|
202
|
+
* explicit-send suppression before handling the next model round. A
|
|
203
|
+
* string also moves that round's reply locus to the newest injected
|
|
204
|
+
* channel. */
|
|
205
|
+
midTurnRoutingResets = new Map();
|
|
178
206
|
pendingAssistantBlocks = new Map();
|
|
179
207
|
/** Streams the FRAMEWORK cancelled for non-terminal reasons, keyed
|
|
180
208
|
* `${agentName}:${streamId}`: an endTurn tool result or a context-budget
|
|
@@ -278,13 +306,22 @@ export class AgentFramework {
|
|
|
278
306
|
* subsystem can be lazily initialized by connectMcplServer when the
|
|
279
307
|
* framework started with zero configured servers. */
|
|
280
308
|
mcplInferenceRoutingConfig = null;
|
|
309
|
+
/** Durable, non-Chronicle projection queue for messages removed by a branch. */
|
|
310
|
+
discordAwarenessOutbox = null;
|
|
311
|
+
discordAwarenessEmoji = DEFAULT_DISCORD_AWARENESS_EMOJI;
|
|
312
|
+
discordAwarenessDeadlineMs = DEFAULT_DISCORD_AWARENESS_DEADLINE_MS;
|
|
313
|
+
/** Serialize per-server drains so reconnect and an online undo cannot race. */
|
|
314
|
+
discordAwarenessDrains = new Map();
|
|
315
|
+
/** Framework-global inference gate; older generations cannot release it. */
|
|
316
|
+
discordAwarenessBarrier = null;
|
|
317
|
+
discordAwarenessBarrierGeneration = 0;
|
|
281
318
|
// EventGate (null when FrameworkConfig.gate is omitted)
|
|
282
319
|
eventGate = null;
|
|
283
320
|
// Session-level token usage tracking (always-on)
|
|
284
321
|
usageTracker;
|
|
285
322
|
/** Presentation-only wall-clock zone; persistence remains UTC/epoch. */
|
|
286
323
|
timeZone;
|
|
287
|
-
constructor(store, ownsStore, membrane, inferencePolicy, errorPolicy, syncIntervalMs, maintenanceIntervalMs, processLoggingPersist, processLoggingBroadcast, timeZone) {
|
|
324
|
+
constructor(store, ownsStore, membrane, inferencePolicy, errorPolicy, syncIntervalMs, maintenanceIntervalMs, processLoggingPersist, processLoggingBroadcast, timeZone, discordAwarenessOutbox, discordAwarenessEmoji, discordAwarenessDeadlineMs) {
|
|
288
325
|
this.store = store;
|
|
289
326
|
this.ownsStore = ownsStore;
|
|
290
327
|
this.membrane = membrane;
|
|
@@ -295,6 +332,9 @@ export class AgentFramework {
|
|
|
295
332
|
this.processLoggingPersist = processLoggingPersist;
|
|
296
333
|
this.processLoggingBroadcast = processLoggingBroadcast;
|
|
297
334
|
this.timeZone = timeZone;
|
|
335
|
+
this.discordAwarenessOutbox = discordAwarenessOutbox;
|
|
336
|
+
this.discordAwarenessEmoji = discordAwarenessEmoji;
|
|
337
|
+
this.discordAwarenessDeadlineMs = discordAwarenessDeadlineMs;
|
|
298
338
|
this.queue = new ProcessQueueImpl();
|
|
299
339
|
this.usageTracker = new UsageTracker({
|
|
300
340
|
emitTrace: (e) => this.emitTrace({ ...e }),
|
|
@@ -374,13 +414,38 @@ export class AgentFramework {
|
|
|
374
414
|
// Already registered
|
|
375
415
|
}
|
|
376
416
|
}
|
|
377
|
-
const
|
|
417
|
+
const discordAwarenessOutboxPath = config.discordAwarenessOutboxPath
|
|
418
|
+
?? (config.storePath ? defaultDiscordAwarenessOutboxPath(config.storePath) : undefined);
|
|
419
|
+
const discordAwarenessOutbox = discordAwarenessOutboxPath
|
|
420
|
+
? new DiscordAwarenessOutbox(discordAwarenessOutboxPath)
|
|
421
|
+
: null;
|
|
422
|
+
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));
|
|
423
|
+
// If an offline recovery process crashed after switching Chronicle but
|
|
424
|
+
// before committing its prepared marker batch, the active branch is the
|
|
425
|
+
// commit record. Promote it now; no quarantined content is read.
|
|
426
|
+
if (discordAwarenessOutbox) {
|
|
427
|
+
try {
|
|
428
|
+
const activated = discordAwarenessOutbox.activatePreparedForBranch(store.currentBranch().name, store.listBranches());
|
|
429
|
+
if (activated > 0) {
|
|
430
|
+
console.error(`[discord-awareness] recovered ${activated} prepared batch(es) for active branch ${store.currentBranch().name}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
// The branch may be safe, but reporting the framework ready while its
|
|
435
|
+
// durable awareness projection is unreadable creates a half-ready
|
|
436
|
+
// host whose data plane can never be released safely.
|
|
437
|
+
throw new DiscordAwarenessAccountingError('startup reconciliation', error);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
378
440
|
// Restore persisted usage data (if any) from prior session
|
|
379
441
|
framework.restoreUsageState();
|
|
380
442
|
// Create agents
|
|
381
443
|
for (const agentConfig of config.agents) {
|
|
382
444
|
await framework.createAgent(agentConfig);
|
|
383
445
|
}
|
|
446
|
+
// Finish any branch-local suppression interrupted after Chronicle switched
|
|
447
|
+
// branches. This runs before modules, MCPL connections, or inbound traffic.
|
|
448
|
+
await framework.resumePreparedDiscordSuppressions();
|
|
384
449
|
// Add modules
|
|
385
450
|
for (const module of config.modules) {
|
|
386
451
|
await framework.addModule(module);
|
|
@@ -595,10 +660,9 @@ export class AgentFramework {
|
|
|
595
660
|
});
|
|
596
661
|
}
|
|
597
662
|
async runQueuedMaintenance() {
|
|
598
|
-
const allTools = this.getAllTools();
|
|
599
663
|
const queued = [...this.agents.values()].flatMap((agent) => {
|
|
600
664
|
const cm = agent.getContextManager();
|
|
601
|
-
const tools =
|
|
665
|
+
const tools = this.getToolsForAgent(agent.name).filter((tool) => agent.canUseTool(tool.name));
|
|
602
666
|
cm.setToolDefinitions(tools);
|
|
603
667
|
if (cm.isReady())
|
|
604
668
|
return [];
|
|
@@ -786,9 +850,116 @@ export class AgentFramework {
|
|
|
786
850
|
...this.mcplTools,
|
|
787
851
|
...channelTools,
|
|
788
852
|
...gateTools,
|
|
789
|
-
|
|
853
|
+
this.buildAgentSettingsTool(),
|
|
854
|
+
...(this.getWorkspaceModule() ? [AgentFramework.SAVE_IMAGE_TOOL] : []),
|
|
790
855
|
];
|
|
791
856
|
}
|
|
857
|
+
/** The registered workspace module, if any (used by save_image). */
|
|
858
|
+
getWorkspaceModule() {
|
|
859
|
+
const module = this.moduleRegistry.getAllModules().find((m) => m.name === 'workspace');
|
|
860
|
+
return module && typeof module.writeBinary === 'function'
|
|
861
|
+
? module
|
|
862
|
+
: undefined;
|
|
863
|
+
}
|
|
864
|
+
/** Core agent_settings keys — extension keys must not collide with these. */
|
|
865
|
+
static AGENT_SETTINGS_CORE_KEYS = [
|
|
866
|
+
'context_budget_tokens',
|
|
867
|
+
'tail_tokens',
|
|
868
|
+
'transition_pace_tokens',
|
|
869
|
+
'same_round_think_text_policy',
|
|
870
|
+
];
|
|
871
|
+
/**
|
|
872
|
+
* Collect module-declared agent_settings extensions (Module.getAgentSettingsExtension),
|
|
873
|
+
* keyed by owning module name. Extensions whose keys collide with the core
|
|
874
|
+
* settings or an earlier extension are skipped loudly — silent shadowing
|
|
875
|
+
* would make updates route to the wrong owner.
|
|
876
|
+
*/
|
|
877
|
+
collectAgentSettingsExtensions() {
|
|
878
|
+
const result = new Map();
|
|
879
|
+
const taken = new Set(AgentFramework.AGENT_SETTINGS_CORE_KEYS);
|
|
880
|
+
for (const module of this.moduleRegistry.getAllModules()) {
|
|
881
|
+
const ext = module.getAgentSettingsExtension?.();
|
|
882
|
+
if (!ext)
|
|
883
|
+
continue;
|
|
884
|
+
const collision = ext.keys.find((k) => taken.has(k));
|
|
885
|
+
if (collision) {
|
|
886
|
+
console.error(`[agent-settings] extension from module '${module.name}' skipped: key '${collision}' already taken`);
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
ext.keys.forEach((k) => taken.add(k));
|
|
890
|
+
result.set(module.name, ext);
|
|
891
|
+
}
|
|
892
|
+
return result;
|
|
893
|
+
}
|
|
894
|
+
captureInferenceToolSnapshot(agent) {
|
|
895
|
+
return {
|
|
896
|
+
sameRoundThinkTextPolicy: agent.getEffectiveSameRoundThinkTextPolicy(),
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
buildThinkTool(policy) {
|
|
900
|
+
return {
|
|
901
|
+
name: 'think',
|
|
902
|
+
description: 'Reason privately. The think({content}) argument stays in your own context and is NOT sent ' +
|
|
903
|
+
'to channels or other surfaces. ' +
|
|
904
|
+
(policy === 'private'
|
|
905
|
+
? '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.'
|
|
906
|
+
: '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.') +
|
|
907
|
+
' 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.',
|
|
908
|
+
inputSchema: {
|
|
909
|
+
type: 'object',
|
|
910
|
+
properties: {
|
|
911
|
+
content: {
|
|
912
|
+
type: 'string',
|
|
913
|
+
description: 'Your private thought / reasoning (optional; not sent anywhere).',
|
|
914
|
+
},
|
|
915
|
+
},
|
|
916
|
+
required: [],
|
|
917
|
+
},
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
/** agent_settings tool definition with module extension fields merged in. */
|
|
921
|
+
buildAgentSettingsTool() {
|
|
922
|
+
const base = AgentFramework.AGENT_SETTINGS_TOOL;
|
|
923
|
+
const extensions = this.collectAgentSettingsExtensions();
|
|
924
|
+
if (extensions.size === 0)
|
|
925
|
+
return base;
|
|
926
|
+
const baseSchema = base.inputSchema;
|
|
927
|
+
const extProperties = {};
|
|
928
|
+
const extKeys = [];
|
|
929
|
+
for (const ext of extensions.values()) {
|
|
930
|
+
Object.assign(extProperties, ext.properties);
|
|
931
|
+
extKeys.push(...ext.keys);
|
|
932
|
+
}
|
|
933
|
+
const settingsProp = baseSchema.properties.settings;
|
|
934
|
+
return {
|
|
935
|
+
...base,
|
|
936
|
+
description: base.description +
|
|
937
|
+
` Additional host-managed settings also live here: ${extKeys.join(', ')}.`,
|
|
938
|
+
inputSchema: {
|
|
939
|
+
...baseSchema,
|
|
940
|
+
properties: {
|
|
941
|
+
...baseSchema.properties,
|
|
942
|
+
...extProperties,
|
|
943
|
+
settings: {
|
|
944
|
+
...baseSchema.properties.settings,
|
|
945
|
+
items: {
|
|
946
|
+
...settingsProp.items,
|
|
947
|
+
enum: [...(settingsProp.items?.enum ?? []), ...extKeys],
|
|
948
|
+
},
|
|
949
|
+
},
|
|
950
|
+
},
|
|
951
|
+
},
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
getToolsForAgent(agentName, snapshot) {
|
|
955
|
+
return this.getAllTools().map((tool) => {
|
|
956
|
+
if (tool.name === 'think') {
|
|
957
|
+
return this.buildThinkTool(snapshot?.sameRoundThinkTextPolicy
|
|
958
|
+
?? this.getAgentRuntimeSettings(agentName).sameRoundThinkTextPolicy);
|
|
959
|
+
}
|
|
960
|
+
return tool;
|
|
961
|
+
});
|
|
962
|
+
}
|
|
792
963
|
getAgentRuntimeSettings(agentName) {
|
|
793
964
|
const agent = this.agents.get(agentName);
|
|
794
965
|
if (!agent)
|
|
@@ -870,7 +1041,7 @@ export class AgentFramework {
|
|
|
870
1041
|
if (!agent) {
|
|
871
1042
|
throw new Error(`Agent not found: ${agentName}`);
|
|
872
1043
|
}
|
|
873
|
-
const tools = this.
|
|
1044
|
+
const tools = this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name));
|
|
874
1045
|
// Default: no dynamic injection gathering → fully transparent (no
|
|
875
1046
|
// inference, no Chronicle writes, no external RPC). Opt in explicitly.
|
|
876
1047
|
if (!opts?.injections) {
|
|
@@ -1004,6 +1175,14 @@ export class AgentFramework {
|
|
|
1004
1175
|
return null;
|
|
1005
1176
|
}
|
|
1006
1177
|
}
|
|
1178
|
+
validatePersistedAgentRuntimeSettings(agentName, overrides) {
|
|
1179
|
+
if (overrides.sameRoundThinkTextPolicy !== undefined &&
|
|
1180
|
+
overrides.sameRoundThinkTextPolicy !== 'public' &&
|
|
1181
|
+
overrides.sameRoundThinkTextPolicy !== 'private') {
|
|
1182
|
+
throw new Error(`Invalid persisted sameRoundThinkTextPolicy for agent "${agentName}": ${JSON.stringify(overrides.sameRoundThinkTextPolicy)}`);
|
|
1183
|
+
}
|
|
1184
|
+
return overrides;
|
|
1185
|
+
}
|
|
1007
1186
|
persistAgentRuntimeSettings(agentName, overrides) {
|
|
1008
1187
|
const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
|
|
1009
1188
|
const state = (data && typeof data === 'object' ? data : {});
|
|
@@ -1323,10 +1502,41 @@ export class AgentFramework {
|
|
|
1323
1502
|
'tagsAll / tagsNone) or in gate.js.',
|
|
1324
1503
|
inputSchema: { type: 'object' },
|
|
1325
1504
|
};
|
|
1505
|
+
/** Synthesized save_image tool — present when a workspace module is
|
|
1506
|
+
* registered. Lets the agent persist an image it has already seen in its
|
|
1507
|
+
* own context (Discord attachments arrive inlined as base64 and are
|
|
1508
|
+
* otherwise unreachable as files). */
|
|
1509
|
+
static SAVE_IMAGE_TOOL = {
|
|
1510
|
+
name: 'save_recent_image',
|
|
1511
|
+
description: 'Save one or more recent images from your own context to workspace files. ' +
|
|
1512
|
+
'Images are counted back from the most recent (index 0). A single image is ' +
|
|
1513
|
+
'written to `path` as given (e.g. "project/photos/cat.png"); when `count` > 1 ' +
|
|
1514
|
+
'the range index..index+count-1 is saved with numeric suffixes ' +
|
|
1515
|
+
'("cat-0.png", "cat-1.png", …; 0 = the newest of the range). Saved files are ' +
|
|
1516
|
+
'visible via workspace tools and the /files/ endpoint.',
|
|
1517
|
+
inputSchema: {
|
|
1518
|
+
type: 'object',
|
|
1519
|
+
properties: {
|
|
1520
|
+
path: {
|
|
1521
|
+
type: 'string',
|
|
1522
|
+
description: 'Mount-prefixed destination path, e.g. "project/photos/name.png".',
|
|
1523
|
+
},
|
|
1524
|
+
index: {
|
|
1525
|
+
type: 'number',
|
|
1526
|
+
description: 'Which image, counting back from the most recent (0 = most recent). Default 0.',
|
|
1527
|
+
},
|
|
1528
|
+
count: {
|
|
1529
|
+
type: 'number',
|
|
1530
|
+
description: 'How many images to save, starting at `index` and going further back. Default 1.',
|
|
1531
|
+
},
|
|
1532
|
+
},
|
|
1533
|
+
required: ['path'],
|
|
1534
|
+
},
|
|
1535
|
+
};
|
|
1326
1536
|
static AGENT_SETTINGS_TOOL = {
|
|
1327
1537
|
name: 'agent_settings',
|
|
1328
1538
|
description: 'Read or change your hot runtime settings. This intentionally exposes only ' +
|
|
1329
|
-
'context budget, recent raw tail size, and
|
|
1539
|
+
'context budget, recent raw tail size, transition pace, and same-round think text routing; model, prompts, ' +
|
|
1330
1540
|
'folding strategy, and other restart-bound configuration are not mutable here. ' +
|
|
1331
1541
|
'Lower context budgets converge gradually under the transition pace before ' +
|
|
1332
1542
|
'becoming the hard live limit; increases take effect immediately.',
|
|
@@ -1337,9 +1547,15 @@ export class AgentFramework {
|
|
|
1337
1547
|
context_budget_tokens: { type: 'number', description: 'Total input context budget, including the reserved response allowance.' },
|
|
1338
1548
|
tail_tokens: { type: 'number', description: 'Recent raw context retained verbatim.' },
|
|
1339
1549
|
transition_pace_tokens: { type: 'number', description: 'Maximum ordinary KV re-read/perturbation per compile while converging.' },
|
|
1550
|
+
same_round_think_text_policy: {
|
|
1551
|
+
type: 'string',
|
|
1552
|
+
enum: ['public', 'private'],
|
|
1553
|
+
description: 'Routing policy for ordinary text emitted in the same native assistant round as think(). ' +
|
|
1554
|
+
"Omitted in the recipe preserves the compatibility carry-forward: public.",
|
|
1555
|
+
},
|
|
1340
1556
|
settings: {
|
|
1341
1557
|
type: 'array',
|
|
1342
|
-
items: { type: 'string', enum: ['context_budget_tokens', 'tail_tokens', 'transition_pace_tokens'] },
|
|
1558
|
+
items: { type: 'string', enum: ['context_budget_tokens', 'tail_tokens', 'transition_pace_tokens', 'same_round_think_text_policy'] },
|
|
1343
1559
|
description: 'For reset: settings to restore to recipe values. Omit to reset all.',
|
|
1344
1560
|
},
|
|
1345
1561
|
},
|
|
@@ -1727,8 +1943,25 @@ export class AgentFramework {
|
|
|
1727
1943
|
};
|
|
1728
1944
|
}
|
|
1729
1945
|
const target = allMessages[allMessages.length - 1 - n];
|
|
1730
|
-
const
|
|
1946
|
+
const discarded = allMessages.slice(allMessages.length - n);
|
|
1947
|
+
const removedRefs = extractDiscordAwarenessRefs(discarded);
|
|
1948
|
+
const sourceBranch = this.store.currentBranch().name;
|
|
1949
|
+
const targetBranch = `undo-msgs/${agentName}/${Date.now()}`;
|
|
1950
|
+
// Prepare the external side effect before switching Chronicle. If the
|
|
1951
|
+
// process dies after the switch but before activate(), startup promotes
|
|
1952
|
+
// this batch by matching targetBranch to the active branch.
|
|
1953
|
+
const markerBatch = this.discordAwarenessOutbox?.prepare({
|
|
1954
|
+
agentName,
|
|
1955
|
+
sourceBranch,
|
|
1956
|
+
targetBranch,
|
|
1957
|
+
refs: removedRefs,
|
|
1958
|
+
emoji: this.discordAwarenessEmoji,
|
|
1959
|
+
}) ?? null;
|
|
1960
|
+
const branchName = cm.branchAt(target.id, targetBranch);
|
|
1731
1961
|
await cm.switchBranch(branchName);
|
|
1962
|
+
if (markerBatch)
|
|
1963
|
+
this.discordAwarenessOutbox.activate(markerBatch.id);
|
|
1964
|
+
await this.syncDiscordAwarenessMarkers();
|
|
1732
1965
|
// Materialize config files from the new branch (fire-and-forget; gate
|
|
1733
1966
|
// picks up via mtime) — mirrors undoLastTurn.
|
|
1734
1967
|
const wsUndo = this.moduleRegistry.getModule('workspace');
|
|
@@ -1742,6 +1975,7 @@ export class AgentFramework {
|
|
|
1742
1975
|
return {
|
|
1743
1976
|
ok: true,
|
|
1744
1977
|
messagesRemoved: n,
|
|
1978
|
+
removedRefs,
|
|
1745
1979
|
lastVisible: await this.lastVisiblePreview(agentName),
|
|
1746
1980
|
};
|
|
1747
1981
|
}
|
|
@@ -1768,6 +2002,7 @@ export class AgentFramework {
|
|
|
1768
2002
|
if (undone === 0) {
|
|
1769
2003
|
return { ok: true, undone: 0, requested, lastVisible: null };
|
|
1770
2004
|
}
|
|
2005
|
+
await this.syncDiscordAwarenessMarkers();
|
|
1771
2006
|
return { ok: true, undone, requested, lastVisible: await this.lastVisiblePreview(agentName) };
|
|
1772
2007
|
}
|
|
1773
2008
|
/**
|
|
@@ -2264,8 +2499,9 @@ export class AgentFramework {
|
|
|
2264
2499
|
});
|
|
2265
2500
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
2266
2501
|
const restoredSettings = this.readAgentRuntimeSettings(config.name);
|
|
2267
|
-
if (restoredSettings)
|
|
2268
|
-
agent.restoreRuntimeSettings(restoredSettings);
|
|
2502
|
+
if (restoredSettings) {
|
|
2503
|
+
agent.restoreRuntimeSettings(this.validatePersistedAgentRuntimeSettings(config.name, restoredSettings));
|
|
2504
|
+
}
|
|
2269
2505
|
this.agents.set(config.name, agent);
|
|
2270
2506
|
this.agentConfigs.set(config.name, config);
|
|
2271
2507
|
// First non-ephemeral agent becomes the primary for message routing
|
|
@@ -2294,13 +2530,22 @@ export class AgentFramework {
|
|
|
2294
2530
|
this.sweepExpiredConversations();
|
|
2295
2531
|
// Check for inference requests
|
|
2296
2532
|
await this.processInferenceRequests();
|
|
2297
|
-
// Yield to the event loop between iterations.
|
|
2298
|
-
//
|
|
2299
|
-
// (
|
|
2300
|
-
|
|
2533
|
+
// Yield to the event loop between iterations. A pending inference request
|
|
2534
|
+
// is not necessarily runnable: while its agent is streaming or waiting for
|
|
2535
|
+
// tools, processInferenceRequests() deliberately requeues it. Treating that
|
|
2536
|
+
// requeued request as "work made progress" creates a microtask-only polling
|
|
2537
|
+
// loop. If activeStreams bookkeeping is absent/stale at the same time, the
|
|
2538
|
+
// old code had no await at all and starved the tool-result and HTTP I/O that
|
|
2539
|
+
// could make the agent runnable again (the Sol outage, 2026-07-15).
|
|
2540
|
+
//
|
|
2541
|
+
// No queue event means no foreground progress, so always take the normal
|
|
2542
|
+
// polling backoff. After an event, retain the low-latency macrotask yield
|
|
2543
|
+
// while background work remains. Both paths give Bun/Node a real event-loop
|
|
2544
|
+
// turn; neither can recurse forever through already-resolved promises.
|
|
2545
|
+
if (!event) {
|
|
2301
2546
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
2302
2547
|
}
|
|
2303
|
-
else if (this.activeStreams.size > 0) {
|
|
2548
|
+
else if (this.pendingRequests.length > 0 || this.activeStreams.size > 0) {
|
|
2304
2549
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2305
2550
|
}
|
|
2306
2551
|
}
|
|
@@ -2403,6 +2648,21 @@ export class AgentFramework {
|
|
|
2403
2648
|
}
|
|
2404
2649
|
}
|
|
2405
2650
|
}
|
|
2651
|
+
// A newly injected message begins a new conversational round inside
|
|
2652
|
+
// the same provider inference. Remember that boundary for
|
|
2653
|
+
// driveStream: an explicit send in the preceding round must not
|
|
2654
|
+
// silence the reply, and the newest channel-bearing injection is
|
|
2655
|
+
// now the reply locus. Map presence matters even without a channel
|
|
2656
|
+
// (CLI/module input): it still clears the prior send suppression.
|
|
2657
|
+
if (midTurnInjections.length > 0) {
|
|
2658
|
+
const injectedChannelId = midTurnInjections.reduce((latest, injection) => {
|
|
2659
|
+
const candidate = injection.metadata?.channelId;
|
|
2660
|
+
return typeof candidate === 'string' && candidate.length > 0
|
|
2661
|
+
? candidate
|
|
2662
|
+
: latest;
|
|
2663
|
+
}, null);
|
|
2664
|
+
this.midTurnRoutingResets.set(agent.name, injectedChannelId);
|
|
2665
|
+
}
|
|
2406
2666
|
// Check if any tool result requested endTurn
|
|
2407
2667
|
const shouldEndTurn = currentState.toolResults.some(tc => tc.result.endTurn);
|
|
2408
2668
|
// Check if accumulated input tokens exceed the agent's budget
|
|
@@ -2964,7 +3224,13 @@ export class AgentFramework {
|
|
|
2964
3224
|
if (agent.state.status === 'inferring' || agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') {
|
|
2965
3225
|
// Re-queue requests, but warn if they've been pending too long
|
|
2966
3226
|
const oldest = Math.min(...requests.map(r => r.timestamp));
|
|
2967
|
-
if (now - oldest > STALE_REQUEST_MS
|
|
3227
|
+
if (now - oldest > STALE_REQUEST_MS &&
|
|
3228
|
+
(this.staleWarnAt.get(agentName) ?? 0) < now - 60_000) {
|
|
3229
|
+
// Trace and stderr share the throttle. The old code throttled only
|
|
3230
|
+
// stderr, so a long-running tool call generated one trace per poll
|
|
3231
|
+
// (up to 100/sec after the scheduler backoff), adding avoidable work
|
|
3232
|
+
// precisely while the agent was already under pressure.
|
|
3233
|
+
this.staleWarnAt.set(agentName, now);
|
|
2968
3234
|
this.emitTrace({
|
|
2969
3235
|
type: 'inference:request_stale',
|
|
2970
3236
|
agentName,
|
|
@@ -2972,12 +3238,8 @@ export class AgentFramework {
|
|
|
2972
3238
|
requestCount: requests.length,
|
|
2973
3239
|
oldestRequestAge: now - oldest,
|
|
2974
3240
|
});
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
this.staleWarnAt.set(agentName, now);
|
|
2978
|
-
console.error(`[inference-stale] agent=${agentName} busy (${agent.state.status}) — ` +
|
|
2979
|
-
`${requests.length} request(s) waiting ${Math.round((now - oldest) / 1000)}s`);
|
|
2980
|
-
}
|
|
3241
|
+
console.error(`[inference-stale] agent=${agentName} busy (${agent.state.status}) — ` +
|
|
3242
|
+
`${requests.length} request(s) waiting ${Math.round((now - oldest) / 1000)}s`);
|
|
2981
3243
|
}
|
|
2982
3244
|
this.pendingRequests.push(...requests);
|
|
2983
3245
|
continue;
|
|
@@ -3028,7 +3290,8 @@ export class AgentFramework {
|
|
|
3028
3290
|
this.eventGate?.onInferenceStarted(agent.name);
|
|
3029
3291
|
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
|
|
3030
3292
|
try {
|
|
3031
|
-
const
|
|
3293
|
+
const requestSnapshot = this.captureInferenceToolSnapshot(agent);
|
|
3294
|
+
const allTools = this.getToolsForAgent(agent.name, requestSnapshot);
|
|
3032
3295
|
const tools = allTools.filter((t) => agent.canUseTool(t.name));
|
|
3033
3296
|
// Gather context from modules (pull-based) and MCPL hooks (push-based)
|
|
3034
3297
|
// Both produce ContextInjection[] that get merged before inference.
|
|
@@ -3061,7 +3324,7 @@ export class AgentFramework {
|
|
|
3061
3324
|
}
|
|
3062
3325
|
}
|
|
3063
3326
|
const { stream, request: compiledRequest } = await agent.startStreamWithInjections(tools, injections);
|
|
3064
|
-
const handle = this.driveStream(agent, stream, trigger, attempt, compiledRequest);
|
|
3327
|
+
const handle = this.driveStream(agent, stream, requestSnapshot, trigger, attempt, compiledRequest);
|
|
3065
3328
|
this.activeStreams.set(agent.name, handle);
|
|
3066
3329
|
}
|
|
3067
3330
|
catch (error) {
|
|
@@ -3104,23 +3367,21 @@ export class AgentFramework {
|
|
|
3104
3367
|
}
|
|
3105
3368
|
}
|
|
3106
3369
|
}
|
|
3107
|
-
async driveStream(agent, stream, trigger, attempt = 0, compiledRequest) {
|
|
3370
|
+
async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest) {
|
|
3108
3371
|
const startTime = Date.now();
|
|
3109
3372
|
const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
|
|
3110
3373
|
const myStreamId = agent.streamId;
|
|
3111
3374
|
let hadToolCalls = false;
|
|
3112
3375
|
// ---- Present-while-acting turn state ---------------------------------
|
|
3113
|
-
// Output locus for
|
|
3114
|
-
//
|
|
3115
|
-
//
|
|
3116
|
-
//
|
|
3117
|
-
//
|
|
3118
|
-
//
|
|
3119
|
-
// null resolution is NOT pinned — a turn whose locus is unresolvable
|
|
3120
|
-
// early (e.g. heartbeat-triggered) may still resolve a channel by the
|
|
3121
|
-
// time later prose routes.
|
|
3376
|
+
// Output locus for the current CONVERSATIONAL ROUND: resolved lazily and
|
|
3377
|
+
// kept stable across tool-only rounds. A channel message injected at a
|
|
3378
|
+
// tool boundary replaces the pin before the next model round, because a
|
|
3379
|
+
// single provider inference can now contain several conversations. The
|
|
3380
|
+
// pin lives in `turnLocusPins` so it survives a context-budget restart. A
|
|
3381
|
+
// null resolution is not pinned.
|
|
3122
3382
|
if (trigger?.reason !== 'context_budget_restart') {
|
|
3123
3383
|
this.turnLocusPins.delete(agent.name);
|
|
3384
|
+
this.midTurnRoutingResets.delete(agent.name);
|
|
3124
3385
|
}
|
|
3125
3386
|
const resolveTurnLocus = () => {
|
|
3126
3387
|
let locus = this.turnLocusPins.get(agent.name) ?? null;
|
|
@@ -3146,10 +3407,9 @@ export class AgentFramework {
|
|
|
3146
3407
|
})
|
|
3147
3408
|
.catch((err) => console.error('mid-turn speech routing failed:', err));
|
|
3148
3409
|
};
|
|
3149
|
-
// Sticky
|
|
3150
|
-
//
|
|
3151
|
-
//
|
|
3152
|
-
// rounds' prose was already delivered live and cannot be retracted.
|
|
3410
|
+
// Sticky explicit-send suppression: prose after send_message stays quiet
|
|
3411
|
+
// to prevent a redundant "sent it" postscript. Fresh injected input
|
|
3412
|
+
// clears it, because the following prose is a reply to a new message.
|
|
3153
3413
|
let turnSilenced = false;
|
|
3154
3414
|
// Live routing is only trusted when the membrane provides verbatim
|
|
3155
3415
|
// round-scoped blocks (roundContent, native tool mode, membrane ≥0.5.64).
|
|
@@ -3164,11 +3424,29 @@ export class AgentFramework {
|
|
|
3164
3424
|
// responding to, for the whole duration of this turn. Started here (paired
|
|
3165
3425
|
// with the finally below, so it can never leak) and refreshed on a 7s
|
|
3166
3426
|
// interval by the ChannelRegistry until stopped on any exit path.
|
|
3167
|
-
|
|
3427
|
+
let typingChannel = this.channelRegistry
|
|
3168
3428
|
? trigger?.channelId ?? this.channelRegistry.getDefaultPublishChannel()
|
|
3169
3429
|
: null;
|
|
3170
3430
|
if (typingChannel)
|
|
3171
3431
|
this.channelRegistry.startTyping(typingChannel);
|
|
3432
|
+
const adoptInjectedRound = () => {
|
|
3433
|
+
if (!this.midTurnRoutingResets.has(agent.name))
|
|
3434
|
+
return;
|
|
3435
|
+
const nextLocus = this.midTurnRoutingResets.get(agent.name) ?? null;
|
|
3436
|
+
this.midTurnRoutingResets.delete(agent.name);
|
|
3437
|
+
// A new human/agent message, not merely a tool result, means any earlier
|
|
3438
|
+
// explicit delivery has completed its conversational job.
|
|
3439
|
+
turnSilenced = false;
|
|
3440
|
+
if (nextLocus) {
|
|
3441
|
+
this.turnLocusPins.set(agent.name, nextLocus);
|
|
3442
|
+
if (typingChannel !== nextLocus) {
|
|
3443
|
+
if (typingChannel)
|
|
3444
|
+
this.channelRegistry?.stopTyping(typingChannel);
|
|
3445
|
+
typingChannel = nextLocus;
|
|
3446
|
+
this.channelRegistry?.startTyping(nextLocus);
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
3449
|
+
};
|
|
3172
3450
|
try {
|
|
3173
3451
|
for await (const event of stream) {
|
|
3174
3452
|
this.touchEphemeralRun(agent.name, true);
|
|
@@ -3194,6 +3472,7 @@ export class AgentFramework {
|
|
|
3194
3472
|
break;
|
|
3195
3473
|
}
|
|
3196
3474
|
case 'tool-calls': {
|
|
3475
|
+
adoptInjectedRound();
|
|
3197
3476
|
hadToolCalls = true;
|
|
3198
3477
|
this.recordEphemeralToolCalls(agent.name, event.calls.length);
|
|
3199
3478
|
this.emitTrace({
|
|
@@ -3245,6 +3524,8 @@ export class AgentFramework {
|
|
|
3245
3524
|
// note above — the fallback preamble is cumulative in XML mode).
|
|
3246
3525
|
if (this.channelRegistry) {
|
|
3247
3526
|
const roundToolNames = event.calls.map((c) => c.name);
|
|
3527
|
+
const hasSameRoundPrivateThink = roundToolNames.includes('think') &&
|
|
3528
|
+
requestSnapshot.sameRoundThinkTextPolicy === 'private';
|
|
3248
3529
|
if (roundToolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)))) {
|
|
3249
3530
|
turnSilenced = true;
|
|
3250
3531
|
}
|
|
@@ -3255,6 +3536,9 @@ export class AgentFramework {
|
|
|
3255
3536
|
if (turnSilenced) {
|
|
3256
3537
|
console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`);
|
|
3257
3538
|
}
|
|
3539
|
+
else if (hasSameRoundPrivateThink) {
|
|
3540
|
+
console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (same_round_think_text_policy=private)`);
|
|
3541
|
+
}
|
|
3258
3542
|
else {
|
|
3259
3543
|
const locus = resolveTurnLocus();
|
|
3260
3544
|
console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> routing ${roundSegments.length} prose segment(s) live -> ${locus ?? '(default)'}`);
|
|
@@ -3269,6 +3553,7 @@ export class AgentFramework {
|
|
|
3269
3553
|
break;
|
|
3270
3554
|
}
|
|
3271
3555
|
case 'complete': {
|
|
3556
|
+
adoptInjectedRound();
|
|
3272
3557
|
const durationMs = Date.now() - startTime;
|
|
3273
3558
|
const response = event.response;
|
|
3274
3559
|
// If the agent is still waiting_for_tools when 'complete' fires
|
|
@@ -3546,15 +3831,17 @@ export class AgentFramework {
|
|
|
3546
3831
|
// speech/thoughts split is left untouched (module/TUI rendering
|
|
3547
3832
|
// unaffected) — this only governs what reaches the channel.
|
|
3548
3833
|
//
|
|
3549
|
-
//
|
|
3550
|
-
//
|
|
3551
|
-
//
|
|
3552
|
-
//
|
|
3834
|
+
// Native round routing uses the live sticky flag, which a fresh
|
|
3835
|
+
// injected message can reset. The legacy/fallback path has no
|
|
3836
|
+
// reliable round boundaries, so retain its historical turn-wide
|
|
3837
|
+
// scan to avoid double-posting.
|
|
3553
3838
|
const toolNames = response.content
|
|
3554
3839
|
.filter((b) => b.type === 'tool_use')
|
|
3555
3840
|
.map((b) => b.name)
|
|
3556
3841
|
.filter((n) => typeof n === 'string');
|
|
3557
|
-
const silenced =
|
|
3842
|
+
const silenced = liveProseRouting
|
|
3843
|
+
? turnSilenced
|
|
3844
|
+
: turnSilenced || toolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)));
|
|
3558
3845
|
const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content);
|
|
3559
3846
|
// Preserve in-channel ordering: everything enqueued live must
|
|
3560
3847
|
// land before the trailing prose. Awaited even when silenced —
|
|
@@ -3907,6 +4194,49 @@ export class AgentFramework {
|
|
|
3907
4194
|
return maxTokens * 4;
|
|
3908
4195
|
return undefined;
|
|
3909
4196
|
}
|
|
4197
|
+
approximateDecodedBase64Bytes(base64) {
|
|
4198
|
+
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
|
|
4199
|
+
return Math.max(0, Math.floor(base64.length * 3 / 4) - padding);
|
|
4200
|
+
}
|
|
4201
|
+
sanitizeProcessLogValue(value, seen = new WeakMap()) {
|
|
4202
|
+
if (value && typeof value === 'object') {
|
|
4203
|
+
const seenValue = seen.get(value);
|
|
4204
|
+
if (seenValue)
|
|
4205
|
+
return seenValue;
|
|
4206
|
+
}
|
|
4207
|
+
if (Array.isArray(value)) {
|
|
4208
|
+
const clone = [];
|
|
4209
|
+
seen.set(value, clone);
|
|
4210
|
+
for (const item of value) {
|
|
4211
|
+
clone.push(this.sanitizeProcessLogValue(item, seen));
|
|
4212
|
+
}
|
|
4213
|
+
return clone;
|
|
4214
|
+
}
|
|
4215
|
+
if (!value || typeof value !== 'object') {
|
|
4216
|
+
return value;
|
|
4217
|
+
}
|
|
4218
|
+
const record = value;
|
|
4219
|
+
if (record.type === 'image' && typeof record.data === 'string' && typeof record.mimeType === 'string') {
|
|
4220
|
+
const clone = {};
|
|
4221
|
+
seen.set(value, clone);
|
|
4222
|
+
for (const [key, child] of Object.entries(record)) {
|
|
4223
|
+
if (key === 'data' || key === 'mimeType')
|
|
4224
|
+
continue;
|
|
4225
|
+
clone[key] = this.sanitizeProcessLogValue(child, seen);
|
|
4226
|
+
}
|
|
4227
|
+
clone.type = 'image';
|
|
4228
|
+
clone.mimeType = record.mimeType;
|
|
4229
|
+
clone.approxByteLength = this.approximateDecodedBase64Bytes(record.data);
|
|
4230
|
+
clone.redacted = true;
|
|
4231
|
+
return clone;
|
|
4232
|
+
}
|
|
4233
|
+
const clone = {};
|
|
4234
|
+
seen.set(value, clone);
|
|
4235
|
+
for (const [key, child] of Object.entries(record)) {
|
|
4236
|
+
clone[key] = this.sanitizeProcessLogValue(child, seen);
|
|
4237
|
+
}
|
|
4238
|
+
return clone;
|
|
4239
|
+
}
|
|
3910
4240
|
logInference(entry) {
|
|
3911
4241
|
// Store large request/response as blobs
|
|
3912
4242
|
const entryToStore = { ...entry };
|
|
@@ -3935,13 +4265,13 @@ export class AgentFramework {
|
|
|
3935
4265
|
logProcessEvent(event, responses) {
|
|
3936
4266
|
const entry = {
|
|
3937
4267
|
timestamp: Date.now(),
|
|
3938
|
-
processEvent: event,
|
|
3939
|
-
responses,
|
|
4268
|
+
processEvent: this.sanitizeProcessLogValue(event),
|
|
4269
|
+
responses: this.sanitizeProcessLogValue(responses),
|
|
3940
4270
|
};
|
|
3941
4271
|
// Blob threshold: 10KB
|
|
3942
4272
|
const BLOB_THRESHOLD = 10000;
|
|
3943
4273
|
const entryToStore = { ...entry };
|
|
3944
|
-
const responsesJson = JSON.stringify(responses);
|
|
4274
|
+
const responsesJson = JSON.stringify(entry.responses);
|
|
3945
4275
|
if (responsesJson.length > BLOB_THRESHOLD) {
|
|
3946
4276
|
const blobId = this.store.storeBlob(Buffer.from(responsesJson), 'application/json');
|
|
3947
4277
|
entryToStore.responses = { blobId };
|
|
@@ -3980,6 +4310,10 @@ export class AgentFramework {
|
|
|
3980
4310
|
this.dispatchAgentSettingsToolCall(agentName, enrichedCall);
|
|
3981
4311
|
return;
|
|
3982
4312
|
}
|
|
4313
|
+
if (enrichedCall.name === 'save_recent_image') {
|
|
4314
|
+
this.dispatchSaveImageToolCall(agentName, enrichedCall);
|
|
4315
|
+
return;
|
|
4316
|
+
}
|
|
3983
4317
|
// Route gate_status tool
|
|
3984
4318
|
if (enrichedCall.name === 'gate_status' && this.eventGate) {
|
|
3985
4319
|
this.dispatchGateToolCall(agentName, enrichedCall);
|
|
@@ -4248,6 +4582,16 @@ export class AgentFramework {
|
|
|
4248
4582
|
* every ~15 min instead of flooding the channel on every occurrence.
|
|
4249
4583
|
* See connectome docs/observability.md.
|
|
4250
4584
|
*/
|
|
4585
|
+
/**
|
|
4586
|
+
* Public ops-alert entry point for host-wired alarms that originate
|
|
4587
|
+
* OUTSIDE the framework (e.g. context-manager's repeating
|
|
4588
|
+
* compression-quarantine klaxon, wired in the host at strategy
|
|
4589
|
+
* construction). Same sinks and cooldown as internal alerts:
|
|
4590
|
+
* failures.log + ops:alert trace + CONNECTOME_OPS_WEBHOOK.
|
|
4591
|
+
*/
|
|
4592
|
+
notifyOps(kind, agentName, message, data) {
|
|
4593
|
+
this.opsAlert(kind, agentName, message, { data });
|
|
4594
|
+
}
|
|
4251
4595
|
opsAlert(kind, agentName, message, opts) {
|
|
4252
4596
|
if (!opts?.skipLog) {
|
|
4253
4597
|
this.logFailure({ agent: agentName, kind, reason: message, ...opts?.data });
|
|
@@ -4322,6 +4666,16 @@ export class AgentFramework {
|
|
|
4322
4666
|
}
|
|
4323
4667
|
return {};
|
|
4324
4668
|
}
|
|
4669
|
+
/**
|
|
4670
|
+
* Public entry to the ops-alert pipeline (failures.log + ops:alert trace +
|
|
4671
|
+
* throttled webhook) for host-level detectors OUTSIDE the framework's own
|
|
4672
|
+
* inference driver — e.g. the host's logging adapter catching refusals on
|
|
4673
|
+
* off-path calls (compression/summarizer, maintenance) that noteRefusal
|
|
4674
|
+
* never sees. Same per-(agent,kind) cooldown as everything else.
|
|
4675
|
+
*/
|
|
4676
|
+
notifyOpsAlert(kind, agentName, message, data) {
|
|
4677
|
+
this.opsAlert(kind, agentName, message, { data });
|
|
4678
|
+
}
|
|
4325
4679
|
noteInferenceExhausted(agentName, reason, retryable, errorType) {
|
|
4326
4680
|
const streak = (this.consecutiveInferenceFailures.get(agentName) ?? 0) + 1;
|
|
4327
4681
|
this.consecutiveInferenceFailures.set(agentName, streak);
|
|
@@ -4534,9 +4888,17 @@ export class AgentFramework {
|
|
|
4534
4888
|
};
|
|
4535
4889
|
for (const config of serverConfigs) {
|
|
4536
4890
|
try {
|
|
4537
|
-
|
|
4891
|
+
// Stage every startup connection with both planes closed. This removes
|
|
4892
|
+
// server-order dependence: no early heartbeat/shell push can become
|
|
4893
|
+
// model-visible before a later Discord connection has joined the same
|
|
4894
|
+
// awareness generation.
|
|
4895
|
+
await this.connectMcplServerInternal(config, true);
|
|
4538
4896
|
}
|
|
4539
4897
|
catch (error) {
|
|
4898
|
+
if (error instanceof DiscordAwarenessAccountingError) {
|
|
4899
|
+
await this.mcplServerRegistry.closeAll();
|
|
4900
|
+
throw error;
|
|
4901
|
+
}
|
|
4540
4902
|
// Fail-open: log and continue with remaining servers
|
|
4541
4903
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
4542
4904
|
console.error(`Failed to connect MCPL server "${config.id}":`, err.message);
|
|
@@ -4553,16 +4915,289 @@ export class AgentFramework {
|
|
|
4553
4915
|
});
|
|
4554
4916
|
}
|
|
4555
4917
|
}
|
|
4918
|
+
// Start the durable drain only after all startup connections are staged.
|
|
4919
|
+
// The awareness tools/call is written before control-plane registration is
|
|
4920
|
+
// flushed, preserving the server-side queue -> registration -> call order.
|
|
4921
|
+
const startupBarrier = this.installMcplDataPlaneGate();
|
|
4922
|
+
this.releaseMcplDataPlaneGate(startupBarrier);
|
|
4923
|
+
try {
|
|
4924
|
+
await startupBarrier.promise;
|
|
4925
|
+
this.completeMcplDataPlaneGate(startupBarrier);
|
|
4926
|
+
}
|
|
4927
|
+
catch (error) {
|
|
4928
|
+
await this.mcplServerRegistry.closeAll();
|
|
4929
|
+
throw error;
|
|
4930
|
+
}
|
|
4556
4931
|
// Discover tools from all connected servers
|
|
4557
4932
|
await this.refreshMcplTools();
|
|
4558
4933
|
}
|
|
4934
|
+
/** Reconcile the durable ledger with Chronicle, then deliver every server's work. */
|
|
4935
|
+
async syncDiscordAwarenessMarkers(_onlyServerId) {
|
|
4936
|
+
if (!this.discordAwarenessOutbox)
|
|
4937
|
+
return;
|
|
4938
|
+
// A targeted retry may discover pending work for other Discord servers
|
|
4939
|
+
// during reconciliation. Safety is global, so one generation accounts for
|
|
4940
|
+
// all pending operations before releasing any MCPL data plane.
|
|
4941
|
+
const barrier = this.installMcplDataPlaneGate();
|
|
4942
|
+
this.releaseMcplDataPlaneGate(barrier);
|
|
4943
|
+
try {
|
|
4944
|
+
await barrier.promise;
|
|
4945
|
+
this.completeMcplDataPlaneGate(barrier);
|
|
4946
|
+
}
|
|
4947
|
+
catch (error) {
|
|
4948
|
+
await this.failMcplDataPlaneGate(barrier, 'explicit synchronization', error);
|
|
4949
|
+
throw error;
|
|
4950
|
+
}
|
|
4951
|
+
}
|
|
4952
|
+
async resumePreparedDiscordSuppressions() {
|
|
4953
|
+
if (!this.discordAwarenessOutbox)
|
|
4954
|
+
return;
|
|
4955
|
+
const batches = this.discordAwarenessOutbox.preparedSuppressionsForBranch(this.store.currentBranch().name, this.store.listBranches());
|
|
4956
|
+
for (const batch of batches) {
|
|
4957
|
+
const agent = this.agents.get(batch.agentName);
|
|
4958
|
+
if (!agent) {
|
|
4959
|
+
throw new Error(`Cannot resume Discord suppression ${batch.id}: agent ${batch.agentName} is not configured`);
|
|
4960
|
+
}
|
|
4961
|
+
const cm = agent.getContextManager();
|
|
4962
|
+
for (const interval of [...(batch.suppressionIntervals ?? [])].reverse()) {
|
|
4963
|
+
const from = cm.getMessage(interval.fromId);
|
|
4964
|
+
const to = cm.getMessage(interval.toId);
|
|
4965
|
+
if (!from && !to)
|
|
4966
|
+
continue; // This interval committed before the crash.
|
|
4967
|
+
if (!from || !to) {
|
|
4968
|
+
throw new Error(`Cannot safely resume Discord suppression ${batch.id}: interval ` +
|
|
4969
|
+
`${interval.fromId}..${interval.toId} is only partially present`);
|
|
4970
|
+
}
|
|
4971
|
+
if (interval.fromId === interval.toId)
|
|
4972
|
+
cm.removeMessage(from.id);
|
|
4973
|
+
else
|
|
4974
|
+
cm.removeMessages(from.id, to.id);
|
|
4975
|
+
}
|
|
4976
|
+
this.discordAwarenessOutbox.activate(batch.id);
|
|
4977
|
+
console.error(`[discord-awareness] resumed suppression batch ${batch.id}`);
|
|
4978
|
+
}
|
|
4979
|
+
}
|
|
4980
|
+
/**
|
|
4981
|
+
* Deliver every due ref independently. Permanent Discord failures are kept
|
|
4982
|
+
* in the ledger for audit but do not block later operations; retryable
|
|
4983
|
+
* failures remain pending for the next reconnect/list-change attempt.
|
|
4984
|
+
*/
|
|
4985
|
+
drainDiscordAwarenessOutbox(serverId) {
|
|
4986
|
+
if (!this.discordAwarenessOutbox) {
|
|
4987
|
+
return Promise.resolve({ status: 'delivered', delivered: 0, failed: 0 });
|
|
4988
|
+
}
|
|
4989
|
+
const existing = this.discordAwarenessDrains.get(serverId);
|
|
4990
|
+
if (existing)
|
|
4991
|
+
return existing;
|
|
4992
|
+
const drain = (async () => {
|
|
4993
|
+
const connection = this.mcplServerRegistry?.getServer(serverId);
|
|
4994
|
+
if (!connection?.isConnected) {
|
|
4995
|
+
const operations = this.readDiscordAwarenessPending(serverId);
|
|
4996
|
+
for (const operation of operations) {
|
|
4997
|
+
this.writeDiscordAwarenessFailure(operation.batchId, operation.ref, operation.action, 'Awareness delivery not attempted: MCPL connection unavailable', false);
|
|
4998
|
+
}
|
|
4999
|
+
if (operations.length > 0) {
|
|
5000
|
+
console.error(`[discord-awareness] ${serverId}: connection unavailable; ` +
|
|
5001
|
+
`durably deferred=${operations.length}`);
|
|
5002
|
+
}
|
|
5003
|
+
return { status: 'unavailable', accounted: operations.length };
|
|
5004
|
+
}
|
|
5005
|
+
let delivered = 0;
|
|
5006
|
+
let failed = 0;
|
|
5007
|
+
const attempted = new Set();
|
|
5008
|
+
while (true) {
|
|
5009
|
+
const operations = this.readDiscordAwarenessPending(serverId).filter((operation) => {
|
|
5010
|
+
const key = `${operation.batchId}\0${operation.ref.channelId}\0${operation.ref.messageId}\0${operation.action}`;
|
|
5011
|
+
if (attempted.has(key))
|
|
5012
|
+
return false;
|
|
5013
|
+
attempted.add(key);
|
|
5014
|
+
return true;
|
|
5015
|
+
});
|
|
5016
|
+
if (operations.length === 0)
|
|
5017
|
+
break;
|
|
5018
|
+
for (const operation of operations) {
|
|
5019
|
+
const ref = operation.ref;
|
|
5020
|
+
const channelId = ref.channelId.startsWith('discord:')
|
|
5021
|
+
? ref.channelId.split(':').at(-1)
|
|
5022
|
+
: ref.channelId;
|
|
5023
|
+
let deliveryError;
|
|
5024
|
+
try {
|
|
5025
|
+
const tool = operation.action === 'add' ? 'add_reaction' : 'remove_reaction';
|
|
5026
|
+
const result = await connection.sendToolsCallWithDeadline(tool, {
|
|
5027
|
+
channelId,
|
|
5028
|
+
messageId: ref.messageId,
|
|
5029
|
+
emoji: operation.emoji,
|
|
5030
|
+
}, this.discordAwarenessDeadlineMs);
|
|
5031
|
+
if (result.isError) {
|
|
5032
|
+
deliveryError = result.content
|
|
5033
|
+
.map((content) => content.text ?? '')
|
|
5034
|
+
.filter(Boolean)
|
|
5035
|
+
.join('; ') || `Discord ${tool} returned an error`;
|
|
5036
|
+
}
|
|
5037
|
+
}
|
|
5038
|
+
catch (error) {
|
|
5039
|
+
deliveryError = error instanceof Error ? error.message : String(error);
|
|
5040
|
+
}
|
|
5041
|
+
if (deliveryError !== undefined) {
|
|
5042
|
+
const permanent = isPermanentDiscordReactionFailure(deliveryError);
|
|
5043
|
+
this.writeDiscordAwarenessFailure(operation.batchId, ref, operation.action, deliveryError, permanent);
|
|
5044
|
+
failed++;
|
|
5045
|
+
console.error(`[discord-awareness] ${operation.action} failed for ${ref.channelId}/${ref.messageId}` +
|
|
5046
|
+
` (${permanent ? 'permanent' : 'retryable'}): ${deliveryError}`);
|
|
5047
|
+
}
|
|
5048
|
+
else {
|
|
5049
|
+
this.writeDiscordAwarenessSuccess(operation.batchId, ref, operation.action);
|
|
5050
|
+
delivered++;
|
|
5051
|
+
}
|
|
5052
|
+
}
|
|
5053
|
+
}
|
|
5054
|
+
if (delivered > 0 || failed > 0) {
|
|
5055
|
+
console.error(`[discord-awareness] ${serverId}: delivered=${delivered} failed=${failed}`);
|
|
5056
|
+
}
|
|
5057
|
+
return { status: 'delivered', delivered, failed };
|
|
5058
|
+
})().finally(() => {
|
|
5059
|
+
this.discordAwarenessDrains.delete(serverId);
|
|
5060
|
+
});
|
|
5061
|
+
this.discordAwarenessDrains.set(serverId, drain);
|
|
5062
|
+
return drain;
|
|
5063
|
+
}
|
|
5064
|
+
readDiscordAwarenessPending(serverId) {
|
|
5065
|
+
try {
|
|
5066
|
+
return this.discordAwarenessOutbox.pending(serverId);
|
|
5067
|
+
}
|
|
5068
|
+
catch (error) {
|
|
5069
|
+
throw new DiscordAwarenessAccountingError(`ledger read${serverId ? ` for ${serverId}` : ''}`, error);
|
|
5070
|
+
}
|
|
5071
|
+
}
|
|
5072
|
+
writeDiscordAwarenessSuccess(batchId, ref, action) {
|
|
5073
|
+
try {
|
|
5074
|
+
this.discordAwarenessOutbox.recordSuccess(batchId, ref, action);
|
|
5075
|
+
}
|
|
5076
|
+
catch (error) {
|
|
5077
|
+
throw new DiscordAwarenessAccountingError('recordSuccess ledger write', error);
|
|
5078
|
+
}
|
|
5079
|
+
}
|
|
5080
|
+
writeDiscordAwarenessFailure(batchId, ref, action, errorMessage, permanent) {
|
|
5081
|
+
try {
|
|
5082
|
+
this.discordAwarenessOutbox.recordFailure(batchId, ref, action, errorMessage, permanent);
|
|
5083
|
+
}
|
|
5084
|
+
catch (error) {
|
|
5085
|
+
throw new DiscordAwarenessAccountingError('recordFailure ledger write', error);
|
|
5086
|
+
}
|
|
5087
|
+
}
|
|
5088
|
+
beginDiscordAwarenessBarrier() {
|
|
5089
|
+
if (!this.discordAwarenessOutbox) {
|
|
5090
|
+
return {
|
|
5091
|
+
requiresBarrier: false,
|
|
5092
|
+
promise: Promise.resolve({ status: 'delivered', delivered: 0, failed: 0 }),
|
|
5093
|
+
};
|
|
5094
|
+
}
|
|
5095
|
+
try {
|
|
5096
|
+
this.discordAwarenessOutbox.reconcileForBranch(this.store.currentBranch().name, this.store.listBranches());
|
|
5097
|
+
}
|
|
5098
|
+
catch (error) {
|
|
5099
|
+
throw new DiscordAwarenessAccountingError('branch reconciliation', error);
|
|
5100
|
+
}
|
|
5101
|
+
const pending = this.readDiscordAwarenessPending();
|
|
5102
|
+
if (pending.length === 0) {
|
|
5103
|
+
return {
|
|
5104
|
+
requiresBarrier: false,
|
|
5105
|
+
promise: Promise.resolve({ status: 'delivered', delivered: 0, failed: 0 }),
|
|
5106
|
+
};
|
|
5107
|
+
}
|
|
5108
|
+
const serverIds = [...new Set(pending.map((operation) => operation.ref.serverId))];
|
|
5109
|
+
const promise = Promise.all(serverIds.map((serverId) => this.drainDiscordAwarenessOutbox(serverId))).then((outcomes) => {
|
|
5110
|
+
if (outcomes.every((outcome) => outcome.status === 'unavailable')) {
|
|
5111
|
+
return {
|
|
5112
|
+
status: 'unavailable',
|
|
5113
|
+
accounted: outcomes.reduce((count, outcome) => count + (outcome.status === 'unavailable' ? outcome.accounted : 0), 0),
|
|
5114
|
+
};
|
|
5115
|
+
}
|
|
5116
|
+
return {
|
|
5117
|
+
status: 'delivered',
|
|
5118
|
+
delivered: outcomes.reduce((count, outcome) => count + (outcome.status === 'delivered' ? outcome.delivered : 0), 0),
|
|
5119
|
+
failed: outcomes.reduce((count, outcome) => count + (outcome.status === 'delivered' ? outcome.failed : 0), 0),
|
|
5120
|
+
};
|
|
5121
|
+
});
|
|
5122
|
+
return { requiresBarrier: true, promise };
|
|
5123
|
+
}
|
|
5124
|
+
/**
|
|
5125
|
+
* Install one framework-global Discord-awareness generation before releasing
|
|
5126
|
+
* any inference-bearing event. With pending work, every MCPL data plane is
|
|
5127
|
+
* paused while all control planes remain live. With no pending work, ready()
|
|
5128
|
+
* runs synchronously so request responders preserve their historical
|
|
5129
|
+
* same-stack behavior.
|
|
5130
|
+
*/
|
|
5131
|
+
installMcplDataPlaneGate() {
|
|
5132
|
+
for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) {
|
|
5133
|
+
connection.pauseDataPlane();
|
|
5134
|
+
}
|
|
5135
|
+
const generation = ++this.discordAwarenessBarrierGeneration;
|
|
5136
|
+
let begun;
|
|
5137
|
+
try {
|
|
5138
|
+
begun = this.beginDiscordAwarenessBarrier();
|
|
5139
|
+
}
|
|
5140
|
+
catch (error) {
|
|
5141
|
+
begun = {
|
|
5142
|
+
requiresBarrier: true,
|
|
5143
|
+
promise: Promise.reject(error),
|
|
5144
|
+
};
|
|
5145
|
+
}
|
|
5146
|
+
const barrier = { generation, ...begun };
|
|
5147
|
+
this.discordAwarenessBarrier = barrier;
|
|
5148
|
+
return barrier;
|
|
5149
|
+
}
|
|
5150
|
+
releaseMcplDataPlaneGate(barrier) {
|
|
5151
|
+
if (this.discordAwarenessBarrier !== barrier)
|
|
5152
|
+
return;
|
|
5153
|
+
if (barrier.requiresBarrier) {
|
|
5154
|
+
for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) {
|
|
5155
|
+
connection.readyControlPlane();
|
|
5156
|
+
}
|
|
5157
|
+
return;
|
|
5158
|
+
}
|
|
5159
|
+
this.completeMcplDataPlaneGate(barrier);
|
|
5160
|
+
}
|
|
5161
|
+
completeMcplDataPlaneGate(barrier) {
|
|
5162
|
+
if (this.discordAwarenessBarrier !== barrier)
|
|
5163
|
+
return false;
|
|
5164
|
+
this.discordAwarenessBarrier = null;
|
|
5165
|
+
for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) {
|
|
5166
|
+
// ready() can synchronously flush a nested list-change notification that
|
|
5167
|
+
// installs a newer global generation. Never let this older completion
|
|
5168
|
+
// release any remaining server behind that newer gate.
|
|
5169
|
+
if (this.discordAwarenessBarrier !== null)
|
|
5170
|
+
return false;
|
|
5171
|
+
connection.ready();
|
|
5172
|
+
}
|
|
5173
|
+
return this.discordAwarenessBarrier === null;
|
|
5174
|
+
}
|
|
5175
|
+
async failMcplDataPlaneGate(barrier, context, error) {
|
|
5176
|
+
if (this.discordAwarenessBarrier !== barrier)
|
|
5177
|
+
return;
|
|
5178
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
5179
|
+
console.error(`[discord-awareness] ${context} failed globally:`, err.message);
|
|
5180
|
+
const connections = this.mcplServerRegistry?.getAllServers() ?? [];
|
|
5181
|
+
for (const connection of connections) {
|
|
5182
|
+
this.emitTrace({
|
|
5183
|
+
type: 'mcpl:server-error',
|
|
5184
|
+
serverId: connection.id,
|
|
5185
|
+
error: `Discord awareness accounting unhealthy: ${err.message}`,
|
|
5186
|
+
});
|
|
5187
|
+
}
|
|
5188
|
+
await Promise.all(connections.map(async (connection) => {
|
|
5189
|
+
await connection.reconnectAfterFailure().catch((closeError) => {
|
|
5190
|
+
console.error(`[discord-awareness] could not recycle unhealthy connection ${connection.id}:`, closeError instanceof Error ? closeError.message : closeError);
|
|
5191
|
+
});
|
|
5192
|
+
}));
|
|
5193
|
+
}
|
|
4559
5194
|
/**
|
|
4560
5195
|
* Connect a single MCPL server: register routing entries, open the
|
|
4561
5196
|
* connection, wire events, and initialize feature sets / scopes /
|
|
4562
5197
|
* checkpoints. Shared by startup (initializeMcpl) and the runtime
|
|
4563
5198
|
* lifecycle API (connectMcplServer). Throws on connection failure.
|
|
4564
5199
|
*/
|
|
4565
|
-
async connectMcplServerInternal(config) {
|
|
5200
|
+
async connectMcplServerInternal(config, deferAwareness = false) {
|
|
4566
5201
|
if (!this.mcplServerRegistry || !this.mcplHostCapabilities) {
|
|
4567
5202
|
throw new Error('MCPL subsystem is not initialized');
|
|
4568
5203
|
}
|
|
@@ -4577,12 +5212,28 @@ export class AgentFramework {
|
|
|
4577
5212
|
this.channelRegistry.setSubscriptionPolicy(config.id, config.channelSubscription ?? 'manual');
|
|
4578
5213
|
}
|
|
4579
5214
|
const connection = await this.mcplServerRegistry.addServer(config, this.mcplHostCapabilities);
|
|
4580
|
-
// Wire
|
|
4581
|
-
//
|
|
5215
|
+
// Wire listeners before either startup staging or the runtime global gate
|
|
5216
|
+
// releases control traffic needed for registration and marker service.
|
|
4582
5217
|
this.wireMcplEvents(connection);
|
|
4583
|
-
connection.ready();
|
|
4584
5218
|
// Initialize feature sets if server advertises MCPL capabilities
|
|
4585
5219
|
this.registerMcplServerFeatures(config, connection);
|
|
5220
|
+
if (deferAwareness) {
|
|
5221
|
+
this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
|
|
5222
|
+
return;
|
|
5223
|
+
}
|
|
5224
|
+
const awarenessBarrier = this.installMcplDataPlaneGate();
|
|
5225
|
+
this.releaseMcplDataPlaneGate(awarenessBarrier);
|
|
5226
|
+
try {
|
|
5227
|
+
await awarenessBarrier.promise;
|
|
5228
|
+
this.completeMcplDataPlaneGate(awarenessBarrier);
|
|
5229
|
+
}
|
|
5230
|
+
catch (error) {
|
|
5231
|
+
// Startup cannot fail open on a broken awareness ledger. Disable the
|
|
5232
|
+
// reconnecting stub/connection before propagating the distinct error to
|
|
5233
|
+
// initializeMcpl, which tears down any other servers and aborts create().
|
|
5234
|
+
await connection.close().catch(() => { });
|
|
5235
|
+
throw error;
|
|
5236
|
+
}
|
|
4586
5237
|
this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
|
|
4587
5238
|
}
|
|
4588
5239
|
/**
|
|
@@ -4749,12 +5400,18 @@ export class AgentFramework {
|
|
|
4749
5400
|
}
|
|
4750
5401
|
});
|
|
4751
5402
|
// Handle push events (Step 6)
|
|
4752
|
-
connection.on('push-event', (params, responder) => {
|
|
5403
|
+
connection.on('push-event', async (params, responder) => {
|
|
5404
|
+
const barrier = this.discordAwarenessBarrier;
|
|
5405
|
+
if (barrier)
|
|
5406
|
+
await barrier.promise;
|
|
4753
5407
|
this.pushHandler?.handlePushEvent(connection.id, params, responder);
|
|
4754
5408
|
});
|
|
4755
5409
|
// Handle server-initiated inference requests (Step 6)
|
|
4756
5410
|
connection.on('inference-request', async (params, responder) => {
|
|
4757
5411
|
if (this.inferenceRouter && responder) {
|
|
5412
|
+
const barrier = this.discordAwarenessBarrier;
|
|
5413
|
+
if (barrier)
|
|
5414
|
+
await barrier.promise;
|
|
4758
5415
|
await this.inferenceRouter.handleInferenceRequest(connection.id, params, {
|
|
4759
5416
|
respond: responder.respond,
|
|
4760
5417
|
respondError: responder.respondError,
|
|
@@ -4771,7 +5428,10 @@ export class AgentFramework {
|
|
|
4771
5428
|
await this.channelRegistry?.handleChanged(connection.id, params);
|
|
4772
5429
|
});
|
|
4773
5430
|
// Handle incoming channel messages (Step 7)
|
|
4774
|
-
connection.on('channels-incoming', (params, responder) => {
|
|
5431
|
+
connection.on('channels-incoming', async (params, responder) => {
|
|
5432
|
+
const barrier = this.discordAwarenessBarrier;
|
|
5433
|
+
if (barrier)
|
|
5434
|
+
await barrier.promise;
|
|
4775
5435
|
this.channelRegistry?.handleIncoming(connection.id, params, responder);
|
|
4776
5436
|
});
|
|
4777
5437
|
// Handle host-level admin commands from a surface (e.g. Discord /undo)
|
|
@@ -4779,6 +5439,9 @@ export class AgentFramework {
|
|
|
4779
5439
|
if (!responder)
|
|
4780
5440
|
return;
|
|
4781
5441
|
try {
|
|
5442
|
+
const barrier = this.discordAwarenessBarrier;
|
|
5443
|
+
if (barrier)
|
|
5444
|
+
await barrier.promise;
|
|
4782
5445
|
const result = await this.handleHostCommand(connection.id, params ?? {});
|
|
4783
5446
|
responder.respond(result);
|
|
4784
5447
|
}
|
|
@@ -4789,7 +5452,15 @@ export class AgentFramework {
|
|
|
4789
5452
|
});
|
|
4790
5453
|
// Handle dynamic tool list changes (notifications/tools/list_changed)
|
|
4791
5454
|
connection.on('tools-list-changed', () => {
|
|
5455
|
+
// Pause before starting any async refresh. This listener runs
|
|
5456
|
+
// synchronously in the transport line callback, so a following inbound
|
|
5457
|
+
// data event cannot pass before the new barrier exists.
|
|
5458
|
+
const awarenessBarrier = this.installMcplDataPlaneGate();
|
|
5459
|
+
this.releaseMcplDataPlaneGate(awarenessBarrier);
|
|
4792
5460
|
this.handleToolsListChanged(connection.id);
|
|
5461
|
+
void awarenessBarrier.promise.then(() => {
|
|
5462
|
+
this.completeMcplDataPlaneGate(awarenessBarrier);
|
|
5463
|
+
}).catch((error) => this.failMcplDataPlaneGate(awarenessBarrier, 'tools-list reconciliation', error));
|
|
4793
5464
|
});
|
|
4794
5465
|
// Re-establish full server registration on reconnect. The 'close' handler
|
|
4795
5466
|
// removed the feature-set state, so pushes / inference requests from the
|
|
@@ -4799,6 +5470,10 @@ export class AgentFramework {
|
|
|
4799
5470
|
// preserved across the transient close are resumed by idempotent
|
|
4800
5471
|
// registration. Then refresh tools (server may have different tools).
|
|
4801
5472
|
connection.on('reconnect', (info) => {
|
|
5473
|
+
// Install the barrier synchronously so any inbound event emitted after
|
|
5474
|
+
// reconnect observes it before doing work that could wake an agent. The
|
|
5475
|
+
// connection paused its data plane before wiring the fresh transport.
|
|
5476
|
+
const awarenessBarrier = this.installMcplDataPlaneGate();
|
|
4802
5477
|
try {
|
|
4803
5478
|
const config = this.mcplServerConfigs.get(connection.id);
|
|
4804
5479
|
if (config) {
|
|
@@ -4808,15 +5483,21 @@ export class AgentFramework {
|
|
|
4808
5483
|
catch (error) {
|
|
4809
5484
|
console.error(`MCPL server "${connection.id}" re-registration after reconnect failed:`, error instanceof Error ? error.message : error);
|
|
4810
5485
|
}
|
|
5486
|
+
this.releaseMcplDataPlaneGate(awarenessBarrier);
|
|
4811
5487
|
this.handleToolsListChanged(connection.id);
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
5488
|
+
void awarenessBarrier.promise.then(() => {
|
|
5489
|
+
if (awarenessBarrier.requiresBarrier
|
|
5490
|
+
&& !this.completeMcplDataPlaneGate(awarenessBarrier))
|
|
5491
|
+
return;
|
|
5492
|
+
this.emitTrace({
|
|
5493
|
+
type: 'mcpl:server-reconnected',
|
|
5494
|
+
serverId: connection.id,
|
|
5495
|
+
attempts: info?.attempts ?? 0,
|
|
5496
|
+
});
|
|
5497
|
+
// Mirror the module:removed emitted on 'close' so module-lifecycle
|
|
5498
|
+
// consumers see the server come back, not just vanish.
|
|
5499
|
+
this.emitTrace({ type: 'module:added', moduleName: `mcpl:${connection.id}` });
|
|
5500
|
+
}).catch((error) => this.failMcplDataPlaneGate(awarenessBarrier, 'reconnect reconciliation', error));
|
|
4820
5501
|
});
|
|
4821
5502
|
// Surface connect/reconnect failures. Before these traces existed the
|
|
4822
5503
|
// only receipt was a console.error on the host's own stderr — invisible
|
|
@@ -4855,6 +5536,7 @@ export class AgentFramework {
|
|
|
4855
5536
|
type: 'mcpl:orphaned-response',
|
|
4856
5537
|
serverId: connection.id,
|
|
4857
5538
|
responseId: info.id,
|
|
5539
|
+
method: info.method,
|
|
4858
5540
|
hadState: info.hadState,
|
|
4859
5541
|
hadCheckpoint: info.hadCheckpoint,
|
|
4860
5542
|
});
|
|
@@ -5280,6 +5962,143 @@ export class AgentFramework {
|
|
|
5280
5962
|
endTurn: true,
|
|
5281
5963
|
});
|
|
5282
5964
|
}
|
|
5965
|
+
/**
|
|
5966
|
+
* Handle the synthesized `save_recent_image` tool: locate the requested
|
|
5967
|
+
* image blocks in the calling agent's context (blobs re-inlined, counted
|
|
5968
|
+
* back from the most recent) and write their bytes to a workspace mount
|
|
5969
|
+
* via WorkspaceModule.writeBinary.
|
|
5970
|
+
*/
|
|
5971
|
+
dispatchSaveImageToolCall(agentName, call) {
|
|
5972
|
+
this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
|
|
5973
|
+
const finish = (result) => {
|
|
5974
|
+
this.emitTrace({
|
|
5975
|
+
type: result.isError ? 'tool:failed' : 'tool:completed',
|
|
5976
|
+
module: 'workspace',
|
|
5977
|
+
tool: call.name,
|
|
5978
|
+
callId: call.id,
|
|
5979
|
+
durationMs: 0,
|
|
5980
|
+
...(result.isError ? { error: result.error } : {}),
|
|
5981
|
+
});
|
|
5982
|
+
this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace', result });
|
|
5983
|
+
};
|
|
5984
|
+
void (async () => {
|
|
5985
|
+
try {
|
|
5986
|
+
const workspace = this.getWorkspaceModule();
|
|
5987
|
+
if (!workspace)
|
|
5988
|
+
throw new Error('save_recent_image requires a workspace module');
|
|
5989
|
+
const agent = this.agents.get(agentName);
|
|
5990
|
+
if (!agent)
|
|
5991
|
+
throw new Error(`Unknown agent: ${agentName}`);
|
|
5992
|
+
const input = (call.input ?? {});
|
|
5993
|
+
if (typeof input.path !== 'string' || input.path.length === 0) {
|
|
5994
|
+
throw new Error('save_recent_image: `path` (mount-prefixed) is required');
|
|
5995
|
+
}
|
|
5996
|
+
const index = input.index === undefined ? 0 : Number(input.index);
|
|
5997
|
+
if (!Number.isInteger(index) || index < 0) {
|
|
5998
|
+
throw new Error('save_recent_image: `index` must be a non-negative integer');
|
|
5999
|
+
}
|
|
6000
|
+
const count = input.count === undefined ? 1 : Number(input.count);
|
|
6001
|
+
const MAX_COUNT = 20;
|
|
6002
|
+
if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) {
|
|
6003
|
+
throw new Error(`save_recent_image: \`count\` must be an integer in 1..${MAX_COUNT}`);
|
|
6004
|
+
}
|
|
6005
|
+
const lastWanted = index + count - 1;
|
|
6006
|
+
// Walk the message store tail-first in bounded windows, re-inlining
|
|
6007
|
+
// blob media, until we've collected the requested range. Scanning is
|
|
6008
|
+
// capped so a pathological index can't drag the whole store through
|
|
6009
|
+
// blob resolution.
|
|
6010
|
+
const MAX_SCAN = 500;
|
|
6011
|
+
const WINDOW = 25;
|
|
6012
|
+
const cm = agent.getContextManager();
|
|
6013
|
+
const total = cm.getMessageCount();
|
|
6014
|
+
let seen = 0;
|
|
6015
|
+
let scanned = 0;
|
|
6016
|
+
const found = [];
|
|
6017
|
+
for (let end = total; end > 0 && scanned < MAX_SCAN && found.length < count; end -= WINDOW) {
|
|
6018
|
+
const start = Math.max(0, end - WINDOW);
|
|
6019
|
+
const { messages } = cm.getMessageWindow(start, end - start, { resolveBlobs: true });
|
|
6020
|
+
scanned += end - start;
|
|
6021
|
+
for (let i = messages.length - 1; i >= 0 && found.length < count; i--) {
|
|
6022
|
+
const content = messages[i]?.content;
|
|
6023
|
+
if (!Array.isArray(content))
|
|
6024
|
+
continue;
|
|
6025
|
+
for (let b = content.length - 1; b >= 0 && found.length < count; b--) {
|
|
6026
|
+
const block = content[b];
|
|
6027
|
+
if (block?.type !== 'image')
|
|
6028
|
+
continue;
|
|
6029
|
+
if (seen >= index && seen <= lastWanted) {
|
|
6030
|
+
if (block.source?.type !== 'base64' || typeof block.source.data !== 'string') {
|
|
6031
|
+
throw new Error(`save_recent_image: image at index ${seen} is not stored inline (base64) — cannot save it`);
|
|
6032
|
+
}
|
|
6033
|
+
found.push({
|
|
6034
|
+
rangeOffset: seen - index,
|
|
6035
|
+
data: block.source.data,
|
|
6036
|
+
mediaType: block.source.mediaType ?? 'image/png',
|
|
6037
|
+
messagesBack: total - (start + i),
|
|
6038
|
+
});
|
|
6039
|
+
}
|
|
6040
|
+
seen++;
|
|
6041
|
+
}
|
|
6042
|
+
}
|
|
6043
|
+
}
|
|
6044
|
+
if (found.length === 0) {
|
|
6045
|
+
throw new Error(seen === 0
|
|
6046
|
+
? `save_recent_image: no images found in the most recent ${Math.min(scanned, MAX_SCAN)} messages`
|
|
6047
|
+
: `save_recent_image: only ${seen} image(s) found in the most recent ${Math.min(scanned, MAX_SCAN)} messages (asked for index ${index})`);
|
|
6048
|
+
}
|
|
6049
|
+
// Single image → path as given. Range → numeric suffix before the
|
|
6050
|
+
// extension ("cat.png" → "cat-0.png"), 0 = newest of the range.
|
|
6051
|
+
const pathFor = (rangeOffset) => {
|
|
6052
|
+
if (count === 1)
|
|
6053
|
+
return input.path;
|
|
6054
|
+
const p = input.path;
|
|
6055
|
+
const dot = p.lastIndexOf('.');
|
|
6056
|
+
const slash = p.lastIndexOf('/');
|
|
6057
|
+
return dot > slash
|
|
6058
|
+
? `${p.slice(0, dot)}-${rangeOffset}${p.slice(dot)}`
|
|
6059
|
+
: `${p}-${rangeOffset}`;
|
|
6060
|
+
};
|
|
6061
|
+
const savedFiles = [];
|
|
6062
|
+
for (const image of found) {
|
|
6063
|
+
const destination = pathFor(image.rangeOffset);
|
|
6064
|
+
const bytes = Buffer.from(image.data, 'base64');
|
|
6065
|
+
const result = await workspace.writeBinary(destination, bytes, image.mediaType);
|
|
6066
|
+
if (!result.success) {
|
|
6067
|
+
finish({
|
|
6068
|
+
success: false,
|
|
6069
|
+
error: `save_recent_image: failed writing "${destination}": ${result.error}` +
|
|
6070
|
+
(savedFiles.length > 0
|
|
6071
|
+
? ` (already saved: ${savedFiles.map((f) => f.path).join(', ')})`
|
|
6072
|
+
: ''),
|
|
6073
|
+
isError: true,
|
|
6074
|
+
});
|
|
6075
|
+
return;
|
|
6076
|
+
}
|
|
6077
|
+
savedFiles.push({
|
|
6078
|
+
...result.data,
|
|
6079
|
+
imageIndex: index + image.rangeOffset,
|
|
6080
|
+
messagesBack: image.messagesBack,
|
|
6081
|
+
});
|
|
6082
|
+
}
|
|
6083
|
+
finish({
|
|
6084
|
+
success: true,
|
|
6085
|
+
data: {
|
|
6086
|
+
saved: savedFiles,
|
|
6087
|
+
...(found.length < count
|
|
6088
|
+
? { note: `only ${found.length} of ${count} requested images exist in the scanned window` }
|
|
6089
|
+
: {}),
|
|
6090
|
+
},
|
|
6091
|
+
});
|
|
6092
|
+
}
|
|
6093
|
+
catch (error) {
|
|
6094
|
+
finish({
|
|
6095
|
+
success: false,
|
|
6096
|
+
error: error instanceof Error ? error.message : String(error),
|
|
6097
|
+
isError: true,
|
|
6098
|
+
});
|
|
6099
|
+
}
|
|
6100
|
+
})();
|
|
6101
|
+
}
|
|
5283
6102
|
dispatchAgentSettingsToolCall(agentName, call) {
|
|
5284
6103
|
this.emitTrace({
|
|
5285
6104
|
type: 'tool:started',
|
|
@@ -5291,9 +6110,20 @@ export class AgentFramework {
|
|
|
5291
6110
|
let result;
|
|
5292
6111
|
try {
|
|
5293
6112
|
const input = (call.input ?? {});
|
|
6113
|
+
const extensions = this.collectAgentSettingsExtensions();
|
|
6114
|
+
/** Extension values merged flat alongside the core snapshot. */
|
|
6115
|
+
const extGet = () => {
|
|
6116
|
+
const out = {};
|
|
6117
|
+
for (const ext of extensions.values())
|
|
6118
|
+
Object.assign(out, ext.get(agentName));
|
|
6119
|
+
return out;
|
|
6120
|
+
};
|
|
5294
6121
|
switch (input.action) {
|
|
5295
6122
|
case 'get':
|
|
5296
|
-
result = {
|
|
6123
|
+
result = {
|
|
6124
|
+
success: true,
|
|
6125
|
+
data: { ...this.getAgentRuntimeSettings(agentName), ...extGet() },
|
|
6126
|
+
};
|
|
5297
6127
|
break;
|
|
5298
6128
|
case 'cancel':
|
|
5299
6129
|
result = { success: true, data: this.cancelAgentRuntimeSettingsTransition(agentName) };
|
|
@@ -5308,11 +6138,46 @@ export class AgentFramework {
|
|
|
5308
6138
|
if (input.transition_pace_tokens !== undefined) {
|
|
5309
6139
|
patch.transitionPaceTokens = Number(input.transition_pace_tokens);
|
|
5310
6140
|
}
|
|
5311
|
-
|
|
6141
|
+
if (input.same_round_think_text_policy !== undefined) {
|
|
6142
|
+
patch.sameRoundThinkTextPolicy = input.same_round_think_text_policy;
|
|
6143
|
+
}
|
|
6144
|
+
// Route extension-owned keys to their modules; apply the core patch
|
|
6145
|
+
// only when it touches core keys (an extension-only update must not
|
|
6146
|
+
// disturb a converging budget transition).
|
|
6147
|
+
const extResults = {};
|
|
6148
|
+
for (const ext of extensions.values()) {
|
|
6149
|
+
const slice = {};
|
|
6150
|
+
for (const key of ext.keys) {
|
|
6151
|
+
if (input[key] !== undefined)
|
|
6152
|
+
slice[key] = input[key];
|
|
6153
|
+
}
|
|
6154
|
+
if (Object.keys(slice).length > 0) {
|
|
6155
|
+
Object.assign(extResults, ext.update(agentName, slice));
|
|
6156
|
+
}
|
|
6157
|
+
}
|
|
6158
|
+
const coreTouched = Object.keys(patch).length > 0;
|
|
6159
|
+
const core = coreTouched
|
|
6160
|
+
? this.updateAgentRuntimeSettings(agentName, patch)
|
|
6161
|
+
: this.getAgentRuntimeSettings(agentName);
|
|
6162
|
+
result = {
|
|
6163
|
+
success: true,
|
|
6164
|
+
data: {
|
|
6165
|
+
...core,
|
|
6166
|
+
...extGet(),
|
|
6167
|
+
...extResults,
|
|
6168
|
+
...(patch.sameRoundThinkTextPolicy !== undefined
|
|
6169
|
+
? {
|
|
6170
|
+
sameRoundThinkTextPolicyUpdateNote: 'Stored now; applies to provider think routing and description beginning with the next inference.',
|
|
6171
|
+
}
|
|
6172
|
+
: {}),
|
|
6173
|
+
},
|
|
6174
|
+
};
|
|
5312
6175
|
break;
|
|
5313
6176
|
}
|
|
5314
6177
|
case 'reset': {
|
|
5315
6178
|
let keys;
|
|
6179
|
+
const extResetKeys = new Map();
|
|
6180
|
+
const resetsAllSettings = input.settings === undefined;
|
|
5316
6181
|
if (input.settings !== undefined) {
|
|
5317
6182
|
if (!Array.isArray(input.settings))
|
|
5318
6183
|
throw new Error('reset `settings` must be an array');
|
|
@@ -5320,15 +6185,56 @@ export class AgentFramework {
|
|
|
5320
6185
|
context_budget_tokens: 'contextBudgetTokens',
|
|
5321
6186
|
tail_tokens: 'tailTokens',
|
|
5322
6187
|
transition_pace_tokens: 'transitionPaceTokens',
|
|
6188
|
+
same_round_think_text_policy: 'sameRoundThinkTextPolicy',
|
|
5323
6189
|
};
|
|
5324
|
-
keys =
|
|
5325
|
-
|
|
5326
|
-
|
|
6190
|
+
keys = [];
|
|
6191
|
+
for (const name of input.settings) {
|
|
6192
|
+
if (typeof name === 'string' && names[name]) {
|
|
6193
|
+
keys.push(names[name]);
|
|
6194
|
+
continue;
|
|
5327
6195
|
}
|
|
5328
|
-
|
|
5329
|
-
|
|
6196
|
+
const owner = [...extensions.values()].find((ext) => typeof name === 'string' && ext.keys.includes(name));
|
|
6197
|
+
if (!owner)
|
|
6198
|
+
throw new Error(`Unknown reset setting: ${String(name)}`);
|
|
6199
|
+
const list = extResetKeys.get(owner) ?? [];
|
|
6200
|
+
list.push(name);
|
|
6201
|
+
extResetKeys.set(owner, list);
|
|
6202
|
+
}
|
|
6203
|
+
if (keys.length === 0)
|
|
6204
|
+
keys = undefined;
|
|
6205
|
+
}
|
|
6206
|
+
const touchedSameRoundThinkTextPolicy = resetsAllSettings || keys?.includes('sameRoundThinkTextPolicy') === true;
|
|
6207
|
+
const extResults = {};
|
|
6208
|
+
if (input.settings === undefined) {
|
|
6209
|
+
// Reset-all covers extensions too.
|
|
6210
|
+
for (const ext of extensions.values()) {
|
|
6211
|
+
if (ext.reset)
|
|
6212
|
+
Object.assign(extResults, ext.reset(agentName));
|
|
6213
|
+
}
|
|
5330
6214
|
}
|
|
5331
|
-
|
|
6215
|
+
else {
|
|
6216
|
+
for (const [ext, list] of extResetKeys) {
|
|
6217
|
+
if (ext.reset)
|
|
6218
|
+
Object.assign(extResults, ext.reset(agentName, list));
|
|
6219
|
+
}
|
|
6220
|
+
}
|
|
6221
|
+
const coreTouched = input.settings === undefined || keys !== undefined;
|
|
6222
|
+
const core = coreTouched
|
|
6223
|
+
? this.resetAgentRuntimeSettings(agentName, keys)
|
|
6224
|
+
: this.getAgentRuntimeSettings(agentName);
|
|
6225
|
+
result = {
|
|
6226
|
+
success: true,
|
|
6227
|
+
data: {
|
|
6228
|
+
...core,
|
|
6229
|
+
...extGet(),
|
|
6230
|
+
...extResults,
|
|
6231
|
+
...(touchedSameRoundThinkTextPolicy
|
|
6232
|
+
? {
|
|
6233
|
+
sameRoundThinkTextPolicyUpdateNote: 'Reset now; restored provider think routing and description apply beginning with the next inference.',
|
|
6234
|
+
}
|
|
6235
|
+
: {}),
|
|
6236
|
+
},
|
|
6237
|
+
};
|
|
5332
6238
|
break;
|
|
5333
6239
|
}
|
|
5334
6240
|
default:
|