@animalabs/agent-framework 0.6.6 → 0.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -0
- package/dist/src/agent.d.ts +5 -1
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +35 -1
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/mcp-server.js +0 -0
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +7 -2
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/framework.d.ts +91 -3
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +988 -82
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +9 -2
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +3 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +29 -15
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +36 -3
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +142 -28
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +12 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +661 -2
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +4 -0
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.d.ts +99 -0
- package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -0
- package/dist/src/recovery/discord-awareness-outbox.js +334 -0
- package/dist/src/recovery/discord-awareness-outbox.js.map +1 -0
- package/dist/src/recovery/offline-branch.d.ts +45 -0
- package/dist/src/recovery/offline-branch.d.ts.map +1 -0
- package/dist/src/recovery/offline-branch.js +362 -0
- package/dist/src/recovery/offline-branch.js.map +1 -0
- package/dist/src/recovery/recover-cli.d.ts +3 -0
- package/dist/src/recovery/recover-cli.d.ts.map +1 -0
- package/dist/src/recovery/recover-cli.js +129 -0
- package/dist/src/recovery/recover-cli.js.map +1 -0
- package/dist/src/types/agent.d.ts +10 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +14 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +31 -0
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +2 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/agent-runtime-settings.test.js +200 -93
- package/dist/test/agent-runtime-settings.test.js.map +1 -1
- package/dist/test/busy-agent-scheduler.test.d.ts +2 -0
- package/dist/test/busy-agent-scheduler.test.d.ts.map +1 -0
- package/dist/test/busy-agent-scheduler.test.js +62 -0
- package/dist/test/busy-agent-scheduler.test.js.map +1 -0
- package/dist/test/channel-lifecycle-provenance.test.d.ts +2 -0
- package/dist/test/channel-lifecycle-provenance.test.d.ts.map +1 -0
- package/dist/test/channel-lifecycle-provenance.test.js +47 -0
- package/dist/test/channel-lifecycle-provenance.test.js.map +1 -0
- package/dist/test/discord-awareness-outbox.test.d.ts +2 -0
- package/dist/test/discord-awareness-outbox.test.d.ts.map +1 -0
- package/dist/test/discord-awareness-outbox.test.js +248 -0
- package/dist/test/discord-awareness-outbox.test.js.map +1 -0
- package/dist/test/maintenance-timer.test.js +100 -0
- package/dist/test/maintenance-timer.test.js.map +1 -1
- package/dist/test/mcpl-awareness-barrier.test.d.ts +2 -0
- package/dist/test/mcpl-awareness-barrier.test.d.ts.map +1 -0
- package/dist/test/mcpl-awareness-barrier.test.js +618 -0
- package/dist/test/mcpl-awareness-barrier.test.js.map +1 -0
- package/dist/test/mcpl-reconnect-reregistration.test.js +22 -3
- package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
- package/dist/test/mcpl-request-timeout.test.js +15 -0
- package/dist/test/mcpl-request-timeout.test.js.map +1 -1
- package/dist/test/offline-recovery-branch.test.d.ts +2 -0
- package/dist/test/offline-recovery-branch.test.d.ts.map +1 -0
- package/dist/test/offline-recovery-branch.test.js +226 -0
- package/dist/test/offline-recovery-branch.test.js.map +1 -0
- package/dist/test/present-while-acting.test.d.ts +3 -2
- package/dist/test/present-while-acting.test.d.ts.map +1 -1
- package/dist/test/present-while-acting.test.js +31 -3
- package/dist/test/present-while-acting.test.js.map +1 -1
- package/dist/test/refusal-single-call.test.d.ts +2 -0
- package/dist/test/refusal-single-call.test.d.ts.map +1 -0
- package/dist/test/refusal-single-call.test.js +177 -0
- package/dist/test/refusal-single-call.test.js.map +1 -0
- package/dist/test/same-round-think-policy.test.d.ts +2 -0
- package/dist/test/same-round-think-policy.test.d.ts.map +1 -0
- package/dist/test/same-round-think-policy.test.js +853 -0
- package/dist/test/same-round-think-policy.test.js.map +1 -0
- package/dist/test/workspace-read-image.test.d.ts +2 -0
- package/dist/test/workspace-read-image.test.d.ts.map +1 -0
- package/dist/test/workspace-read-image.test.js +518 -0
- package/dist/test/workspace-read-image.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -3
- package/src/agent.ts +39 -2
- package/src/api/server.ts +8 -2
- package/src/framework.ts +1180 -79
- package/src/gate/event-gate.ts +15 -3
- package/src/index.ts +20 -0
- package/src/mcpl/channel-registry.ts +31 -16
- package/src/mcpl/server-connection.ts +163 -28
- package/src/modules/workspace/index.ts +759 -2
- package/src/modules/workspace/types.ts +5 -0
- package/src/recovery/discord-awareness-outbox.ts +459 -0
- package/src/recovery/offline-branch.ts +510 -0
- package/src/recovery/recover-cli.ts +130 -0
- package/src/types/agent.ts +14 -0
- package/src/types/framework.ts +17 -0
- package/src/types/module.ts +33 -0
- package/src/types/trace.ts +2 -0
package/src/framework.ts
CHANGED
|
@@ -47,6 +47,8 @@ import type {
|
|
|
47
47
|
AgentRuntimeSettingsPatch,
|
|
48
48
|
AgentRuntimeSettingsOverrides,
|
|
49
49
|
AgentRuntimeSettingsSnapshot,
|
|
50
|
+
AgentSettingsExtension,
|
|
51
|
+
SameRoundThinkTextPolicy,
|
|
50
52
|
} from './types/index.js';
|
|
51
53
|
import { ProcessQueueImpl } from './queue.js';
|
|
52
54
|
import { Agent } from './agent.js';
|
|
@@ -60,6 +62,7 @@ import { InferenceRouter } from './mcpl/inference-router.js';
|
|
|
60
62
|
import { ChannelRegistry } from './mcpl/channel-registry.js';
|
|
61
63
|
import { ConversationRouter } from './mcpl/conversation-router.js';
|
|
62
64
|
import { safeSlice } from './safe-slice.js';
|
|
65
|
+
import type { WorkspaceModule } from './modules/workspace/index.js';
|
|
63
66
|
import { toolResultDataToHistoryString } from './tool-result-history.js';
|
|
64
67
|
import { splitProseSegments } from './prose-segments.js';
|
|
65
68
|
|
|
@@ -71,15 +74,14 @@ import { splitProseSegments } from './prose-segments.js';
|
|
|
71
74
|
* - explicit delivery tools (channel_publish / *send_message /
|
|
72
75
|
* *reply_message / *send_dm) — already sent the message, so routing the
|
|
73
76
|
* prose again would double-post.
|
|
74
|
-
* `think` is deliberately NOT here: it is silent *reasoning*,
|
|
75
|
-
*
|
|
77
|
+
* `think` is deliberately NOT here: it is silent *reasoning*, and same-round
|
|
78
|
+
* prose beside it is governed separately by same_round_think_text_policy.
|
|
76
79
|
*
|
|
77
|
-
* Scope:
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* double-post guard.
|
|
80
|
+
* Scope: an explicit delivery suppresses prose from that round onward until
|
|
81
|
+
* another external message is injected. The suppression prevents a
|
|
82
|
+
* `send_message` followed by "sent it" from double-posting, but a new message
|
|
83
|
+
* starts a new conversational round and must be answerable with plain prose.
|
|
84
|
+
* `skip_reply` ends the turn at the tool-result boundary.
|
|
83
85
|
*/
|
|
84
86
|
const SILENCING_TOOLS = new Set([
|
|
85
87
|
'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
|
|
@@ -108,6 +110,12 @@ import type {
|
|
|
108
110
|
} from './mcpl/types.js';
|
|
109
111
|
import type { ContextInjection } from '@animalabs/context-manager';
|
|
110
112
|
import { formatZonedDateTime, resolveTimeZone } from './timezone.js';
|
|
113
|
+
import {
|
|
114
|
+
DEFAULT_DISCORD_AWARENESS_EMOJI,
|
|
115
|
+
DiscordAwarenessOutbox,
|
|
116
|
+
defaultDiscordAwarenessOutboxPath,
|
|
117
|
+
extractDiscordAwarenessRefs,
|
|
118
|
+
} from './recovery/discord-awareness-outbox.js';
|
|
111
119
|
|
|
112
120
|
const FRAMEWORK_STATE_ID = 'framework/state';
|
|
113
121
|
const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router';
|
|
@@ -118,6 +126,9 @@ const TURN_CHECKPOINTS_TREE_ID = 'framework/turn-checkpoints/tree';
|
|
|
118
126
|
|
|
119
127
|
/** Maximum number of turn checkpoints to keep per agent. */
|
|
120
128
|
const MAX_TURN_CHECKPOINTS = 20;
|
|
129
|
+
const DEFAULT_DISCORD_AWARENESS_DEADLINE_MS = 10_000;
|
|
130
|
+
const MIN_DISCORD_AWARENESS_DEADLINE_MS = 50;
|
|
131
|
+
const MAX_DISCORD_AWARENESS_DEADLINE_MS = 60_000;
|
|
121
132
|
|
|
122
133
|
interface TurnCheckpoint {
|
|
123
134
|
agentName: string;
|
|
@@ -127,11 +138,43 @@ interface TurnCheckpoint {
|
|
|
127
138
|
timestamp: number;
|
|
128
139
|
}
|
|
129
140
|
|
|
141
|
+
type DiscordAwarenessDrainOutcome =
|
|
142
|
+
| { status: 'delivered'; delivered: number; failed: number }
|
|
143
|
+
| { status: 'unavailable'; accounted: number };
|
|
144
|
+
|
|
145
|
+
interface DiscordAwarenessBarrier {
|
|
146
|
+
generation: number;
|
|
147
|
+
requiresBarrier: boolean;
|
|
148
|
+
promise: Promise<DiscordAwarenessDrainOutcome>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function normalizeDiscordAwarenessDeadline(value: number | undefined): number {
|
|
152
|
+
if (value === undefined || !Number.isFinite(value) || value <= 0) {
|
|
153
|
+
return DEFAULT_DISCORD_AWARENESS_DEADLINE_MS;
|
|
154
|
+
}
|
|
155
|
+
return Math.max(
|
|
156
|
+
MIN_DISCORD_AWARENESS_DEADLINE_MS,
|
|
157
|
+
Math.min(MAX_DISCORD_AWARENESS_DEADLINE_MS, Math.floor(value)),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
class DiscordAwarenessAccountingError extends Error {
|
|
162
|
+
constructor(operation: string, cause: unknown) {
|
|
163
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
164
|
+
super(`Discord awareness accounting failed during ${operation}: ${detail}`, { cause });
|
|
165
|
+
this.name = 'DiscordAwarenessAccountingError';
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
130
169
|
interface RedoEntry {
|
|
131
170
|
branchName: string;
|
|
132
171
|
checkpoint: TurnCheckpoint;
|
|
133
172
|
}
|
|
134
173
|
|
|
174
|
+
interface InferenceToolSnapshot {
|
|
175
|
+
sameRoundThinkTextPolicy: SameRoundThinkTextPolicy;
|
|
176
|
+
}
|
|
177
|
+
|
|
135
178
|
/**
|
|
136
179
|
* Default inference policy - infer if any request exists for the agent.
|
|
137
180
|
*/
|
|
@@ -145,6 +188,11 @@ class DefaultInferencePolicy implements InferencePolicy {
|
|
|
145
188
|
}
|
|
146
189
|
}
|
|
147
190
|
|
|
191
|
+
function isPermanentDiscordReactionFailure(message: string): boolean {
|
|
192
|
+
return /unknown message|unknown channel|missing access|missing permissions|missing permission|cannot access|channel .* not found|message .* not found/i
|
|
193
|
+
.test(message);
|
|
194
|
+
}
|
|
195
|
+
|
|
148
196
|
/**
|
|
149
197
|
* Default error policy - retry with exponential backoff.
|
|
150
198
|
*
|
|
@@ -312,7 +360,7 @@ export class AgentFramework {
|
|
|
312
360
|
private maintenanceRunId = 0;
|
|
313
361
|
private currentMaintenanceRun: ContextMaintenanceRun | null = null;
|
|
314
362
|
private maintenanceHistory: ContextMaintenanceRun[] = [];
|
|
315
|
-
/** Last time we
|
|
363
|
+
/** Last time we reported stale (busy-requeued) inference requests, per agent. */
|
|
316
364
|
private staleWarnAt = new Map<string, number>();
|
|
317
365
|
/** Per-agent last inference activity (epoch ms), for /healthz + doctor tooling. */
|
|
318
366
|
private lastInferenceAt = new Map<string, { startedAt?: number; endedAt?: number; failedAt?: number; lastError?: string }>();
|
|
@@ -323,9 +371,17 @@ export class AgentFramework {
|
|
|
323
371
|
/** Per-agent output locus pinned for the CURRENT logical turn (see
|
|
324
372
|
* resolveTurnLocus in driveStream). Lives here — not in driveStream
|
|
325
373
|
* locals — so the pin survives a context-budget stream restart, which
|
|
326
|
-
* continues the same logical turn in a fresh driveStream.
|
|
327
|
-
*
|
|
374
|
+
* continues the same logical turn in a fresh driveStream. A mid-turn
|
|
375
|
+
* injected channel message replaces the pin for the next conversational
|
|
376
|
+
* round; otherwise it remains stable. Cleared at the start of every
|
|
377
|
+
* non-restart turn. */
|
|
328
378
|
private turnLocusPins: Map<string, string> = new Map();
|
|
379
|
+
/** A tool boundary injected fresh external input into the live stream.
|
|
380
|
+
* Presence (including a null value) tells driveStream to clear sticky
|
|
381
|
+
* explicit-send suppression before handling the next model round. A
|
|
382
|
+
* string also moves that round's reply locus to the newest injected
|
|
383
|
+
* channel. */
|
|
384
|
+
private midTurnRoutingResets: Map<string, string | null> = new Map();
|
|
329
385
|
private pendingAssistantBlocks: Map<string, ContentBlock[]> = new Map();
|
|
330
386
|
/** Streams the FRAMEWORK cancelled for non-terminal reasons, keyed
|
|
331
387
|
* `${agentName}:${streamId}`: an endTurn tool result or a context-budget
|
|
@@ -439,6 +495,15 @@ export class AgentFramework {
|
|
|
439
495
|
* subsystem can be lazily initialized by connectMcplServer when the
|
|
440
496
|
* framework started with zero configured servers. */
|
|
441
497
|
private mcplInferenceRoutingConfig: import('./mcpl/types.js').InferenceRoutingPolicy | null = null;
|
|
498
|
+
/** Durable, non-Chronicle projection queue for messages removed by a branch. */
|
|
499
|
+
private discordAwarenessOutbox: DiscordAwarenessOutbox | null = null;
|
|
500
|
+
private discordAwarenessEmoji = DEFAULT_DISCORD_AWARENESS_EMOJI;
|
|
501
|
+
private discordAwarenessDeadlineMs = DEFAULT_DISCORD_AWARENESS_DEADLINE_MS;
|
|
502
|
+
/** Serialize per-server drains so reconnect and an online undo cannot race. */
|
|
503
|
+
private discordAwarenessDrains: Map<string, Promise<DiscordAwarenessDrainOutcome>> = new Map();
|
|
504
|
+
/** Framework-global inference gate; older generations cannot release it. */
|
|
505
|
+
private discordAwarenessBarrier: DiscordAwarenessBarrier | null = null;
|
|
506
|
+
private discordAwarenessBarrierGeneration = 0;
|
|
442
507
|
|
|
443
508
|
// EventGate (null when FrameworkConfig.gate is omitted)
|
|
444
509
|
private eventGate: EventGate | null = null;
|
|
@@ -459,6 +524,9 @@ export class AgentFramework {
|
|
|
459
524
|
processLoggingPersist: boolean,
|
|
460
525
|
processLoggingBroadcast: boolean,
|
|
461
526
|
timeZone: string,
|
|
527
|
+
discordAwarenessOutbox: DiscordAwarenessOutbox | null,
|
|
528
|
+
discordAwarenessEmoji: string,
|
|
529
|
+
discordAwarenessDeadlineMs: number,
|
|
462
530
|
) {
|
|
463
531
|
this.store = store;
|
|
464
532
|
this.ownsStore = ownsStore;
|
|
@@ -470,6 +538,9 @@ export class AgentFramework {
|
|
|
470
538
|
this.processLoggingPersist = processLoggingPersist;
|
|
471
539
|
this.processLoggingBroadcast = processLoggingBroadcast;
|
|
472
540
|
this.timeZone = timeZone;
|
|
541
|
+
this.discordAwarenessOutbox = discordAwarenessOutbox;
|
|
542
|
+
this.discordAwarenessEmoji = discordAwarenessEmoji;
|
|
543
|
+
this.discordAwarenessDeadlineMs = discordAwarenessDeadlineMs;
|
|
473
544
|
this.queue = new ProcessQueueImpl();
|
|
474
545
|
this.usageTracker = new UsageTracker({
|
|
475
546
|
emitTrace: (e: UsageUpdatedEvent) => this.emitTrace({ ...e }),
|
|
@@ -551,6 +622,12 @@ export class AgentFramework {
|
|
|
551
622
|
}
|
|
552
623
|
}
|
|
553
624
|
|
|
625
|
+
const discordAwarenessOutboxPath = config.discordAwarenessOutboxPath
|
|
626
|
+
?? (config.storePath ? defaultDiscordAwarenessOutboxPath(config.storePath) : undefined);
|
|
627
|
+
const discordAwarenessOutbox = discordAwarenessOutboxPath
|
|
628
|
+
? new DiscordAwarenessOutbox(discordAwarenessOutboxPath)
|
|
629
|
+
: null;
|
|
630
|
+
|
|
554
631
|
const framework = new AgentFramework(
|
|
555
632
|
store,
|
|
556
633
|
ownsStore,
|
|
@@ -562,8 +639,33 @@ export class AgentFramework {
|
|
|
562
639
|
processLoggingPersist,
|
|
563
640
|
processLoggingBroadcast,
|
|
564
641
|
resolveTimeZone(config.timeZone),
|
|
642
|
+
discordAwarenessOutbox,
|
|
643
|
+
config.discordAwarenessEmoji ?? DEFAULT_DISCORD_AWARENESS_EMOJI,
|
|
644
|
+
normalizeDiscordAwarenessDeadline(config.discordAwarenessDeadlineMs),
|
|
565
645
|
);
|
|
566
646
|
|
|
647
|
+
// If an offline recovery process crashed after switching Chronicle but
|
|
648
|
+
// before committing its prepared marker batch, the active branch is the
|
|
649
|
+
// commit record. Promote it now; no quarantined content is read.
|
|
650
|
+
if (discordAwarenessOutbox) {
|
|
651
|
+
try {
|
|
652
|
+
const activated = discordAwarenessOutbox.activatePreparedForBranch(
|
|
653
|
+
store.currentBranch().name,
|
|
654
|
+
store.listBranches(),
|
|
655
|
+
);
|
|
656
|
+
if (activated > 0) {
|
|
657
|
+
console.error(
|
|
658
|
+
`[discord-awareness] recovered ${activated} prepared batch(es) for active branch ${store.currentBranch().name}`,
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
} catch (error) {
|
|
662
|
+
// The branch may be safe, but reporting the framework ready while its
|
|
663
|
+
// durable awareness projection is unreadable creates a half-ready
|
|
664
|
+
// host whose data plane can never be released safely.
|
|
665
|
+
throw new DiscordAwarenessAccountingError('startup reconciliation', error);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
567
669
|
// Restore persisted usage data (if any) from prior session
|
|
568
670
|
framework.restoreUsageState();
|
|
569
671
|
|
|
@@ -572,6 +674,10 @@ export class AgentFramework {
|
|
|
572
674
|
await framework.createAgent(agentConfig);
|
|
573
675
|
}
|
|
574
676
|
|
|
677
|
+
// Finish any branch-local suppression interrupted after Chronicle switched
|
|
678
|
+
// branches. This runs before modules, MCPL connections, or inbound traffic.
|
|
679
|
+
await framework.resumePreparedDiscordSuppressions();
|
|
680
|
+
|
|
575
681
|
// Add modules
|
|
576
682
|
for (const module of config.modules) {
|
|
577
683
|
await framework.addModule(module);
|
|
@@ -821,10 +927,9 @@ export class AgentFramework {
|
|
|
821
927
|
}
|
|
822
928
|
|
|
823
929
|
private async runQueuedMaintenance(): Promise<void> {
|
|
824
|
-
const allTools = this.getAllTools();
|
|
825
930
|
const queued = [...this.agents.values()].flatMap((agent) => {
|
|
826
931
|
const cm = agent.getContextManager();
|
|
827
|
-
const tools =
|
|
932
|
+
const tools = this.getToolsForAgent(agent.name).filter((tool) => agent.canUseTool(tool.name));
|
|
828
933
|
cm.setToolDefinitions(tools);
|
|
829
934
|
if (cm.isReady()) return [];
|
|
830
935
|
const pending = cm.getPendingWork()?.description;
|
|
@@ -1028,10 +1133,138 @@ export class AgentFramework {
|
|
|
1028
1133
|
...this.mcplTools,
|
|
1029
1134
|
...channelTools,
|
|
1030
1135
|
...gateTools,
|
|
1031
|
-
|
|
1136
|
+
this.buildAgentSettingsTool(),
|
|
1137
|
+
...(this.getWorkspaceModule() ? [AgentFramework.SAVE_IMAGE_TOOL] : []),
|
|
1032
1138
|
];
|
|
1033
1139
|
}
|
|
1034
1140
|
|
|
1141
|
+
/** The registered workspace module, if any (used by save_image). */
|
|
1142
|
+
private getWorkspaceModule(): WorkspaceModule | undefined {
|
|
1143
|
+
const module = this.moduleRegistry.getAllModules().find((m) => m.name === 'workspace');
|
|
1144
|
+
return module && typeof (module as WorkspaceModule).writeBinary === 'function'
|
|
1145
|
+
? (module as WorkspaceModule)
|
|
1146
|
+
: undefined;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** Core agent_settings keys — extension keys must not collide with these. */
|
|
1150
|
+
private static readonly AGENT_SETTINGS_CORE_KEYS = [
|
|
1151
|
+
'context_budget_tokens',
|
|
1152
|
+
'tail_tokens',
|
|
1153
|
+
'transition_pace_tokens',
|
|
1154
|
+
'same_round_think_text_policy',
|
|
1155
|
+
];
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Collect module-declared agent_settings extensions (Module.getAgentSettingsExtension),
|
|
1159
|
+
* keyed by owning module name. Extensions whose keys collide with the core
|
|
1160
|
+
* settings or an earlier extension are skipped loudly — silent shadowing
|
|
1161
|
+
* would make updates route to the wrong owner.
|
|
1162
|
+
*/
|
|
1163
|
+
private collectAgentSettingsExtensions(): Map<string, AgentSettingsExtension> {
|
|
1164
|
+
const result = new Map<string, AgentSettingsExtension>();
|
|
1165
|
+
const taken = new Set<string>(AgentFramework.AGENT_SETTINGS_CORE_KEYS);
|
|
1166
|
+
for (const module of this.moduleRegistry.getAllModules()) {
|
|
1167
|
+
const ext = module.getAgentSettingsExtension?.();
|
|
1168
|
+
if (!ext) continue;
|
|
1169
|
+
const collision = ext.keys.find((k) => taken.has(k));
|
|
1170
|
+
if (collision) {
|
|
1171
|
+
console.error(
|
|
1172
|
+
`[agent-settings] extension from module '${module.name}' skipped: key '${collision}' already taken`,
|
|
1173
|
+
);
|
|
1174
|
+
continue;
|
|
1175
|
+
}
|
|
1176
|
+
ext.keys.forEach((k) => taken.add(k));
|
|
1177
|
+
result.set(module.name, ext);
|
|
1178
|
+
}
|
|
1179
|
+
return result;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
private captureInferenceToolSnapshot(agent: Agent): InferenceToolSnapshot {
|
|
1183
|
+
return {
|
|
1184
|
+
sameRoundThinkTextPolicy: agent.getEffectiveSameRoundThinkTextPolicy(),
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
private buildThinkTool(
|
|
1189
|
+
policy: SameRoundThinkTextPolicy,
|
|
1190
|
+
): import('./types/index.js').ToolDefinition {
|
|
1191
|
+
return {
|
|
1192
|
+
name: 'think',
|
|
1193
|
+
description:
|
|
1194
|
+
'Reason privately. The think({content}) argument stays in your own context and is NOT sent ' +
|
|
1195
|
+
'to channels or other surfaces. ' +
|
|
1196
|
+
(policy === 'private'
|
|
1197
|
+
? '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.'
|
|
1198
|
+
: '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.'
|
|
1199
|
+
) +
|
|
1200
|
+
' 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.',
|
|
1201
|
+
inputSchema: {
|
|
1202
|
+
type: 'object' as const,
|
|
1203
|
+
properties: {
|
|
1204
|
+
content: {
|
|
1205
|
+
type: 'string',
|
|
1206
|
+
description: 'Your private thought / reasoning (optional; not sent anywhere).',
|
|
1207
|
+
},
|
|
1208
|
+
},
|
|
1209
|
+
required: [],
|
|
1210
|
+
},
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/** agent_settings tool definition with module extension fields merged in. */
|
|
1215
|
+
private buildAgentSettingsTool(): import('./types/index.js').ToolDefinition {
|
|
1216
|
+
const base = AgentFramework.AGENT_SETTINGS_TOOL;
|
|
1217
|
+
const extensions = this.collectAgentSettingsExtensions();
|
|
1218
|
+
if (extensions.size === 0) return base;
|
|
1219
|
+
const baseSchema = base.inputSchema as {
|
|
1220
|
+
type: string;
|
|
1221
|
+
properties: Record<string, unknown>;
|
|
1222
|
+
required?: string[];
|
|
1223
|
+
};
|
|
1224
|
+
const extProperties: Record<string, unknown> = {};
|
|
1225
|
+
const extKeys: string[] = [];
|
|
1226
|
+
for (const ext of extensions.values()) {
|
|
1227
|
+
Object.assign(extProperties, ext.properties);
|
|
1228
|
+
extKeys.push(...ext.keys);
|
|
1229
|
+
}
|
|
1230
|
+
const settingsProp = baseSchema.properties.settings as { items?: { enum?: string[] } };
|
|
1231
|
+
return {
|
|
1232
|
+
...base,
|
|
1233
|
+
description:
|
|
1234
|
+
base.description +
|
|
1235
|
+
` Additional host-managed settings also live here: ${extKeys.join(', ')}.`,
|
|
1236
|
+
inputSchema: {
|
|
1237
|
+
...baseSchema,
|
|
1238
|
+
properties: {
|
|
1239
|
+
...baseSchema.properties,
|
|
1240
|
+
...extProperties,
|
|
1241
|
+
settings: {
|
|
1242
|
+
...(baseSchema.properties.settings as Record<string, unknown>),
|
|
1243
|
+
items: {
|
|
1244
|
+
...(settingsProp.items as Record<string, unknown>),
|
|
1245
|
+
enum: [...(settingsProp.items?.enum ?? []), ...extKeys],
|
|
1246
|
+
},
|
|
1247
|
+
},
|
|
1248
|
+
},
|
|
1249
|
+
} as unknown as import('./types/index.js').ToolDefinition['inputSchema'],
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
private getToolsForAgent(
|
|
1254
|
+
agentName: string,
|
|
1255
|
+
snapshot?: InferenceToolSnapshot,
|
|
1256
|
+
): import('./types/index.js').ToolDefinition[] {
|
|
1257
|
+
return this.getAllTools().map((tool) => {
|
|
1258
|
+
if (tool.name === 'think') {
|
|
1259
|
+
return this.buildThinkTool(
|
|
1260
|
+
snapshot?.sameRoundThinkTextPolicy
|
|
1261
|
+
?? this.getAgentRuntimeSettings(agentName).sameRoundThinkTextPolicy,
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1264
|
+
return tool;
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1035
1268
|
getAgentRuntimeSettings(agentName: string): AgentRuntimeSettingsSnapshot {
|
|
1036
1269
|
const agent = this.agents.get(agentName);
|
|
1037
1270
|
if (!agent) throw new Error(`Unknown agent: ${agentName}`);
|
|
@@ -1124,7 +1357,7 @@ export class AgentFramework {
|
|
|
1124
1357
|
throw new Error(`Agent not found: ${agentName}`);
|
|
1125
1358
|
}
|
|
1126
1359
|
|
|
1127
|
-
const tools = this.
|
|
1360
|
+
const tools = this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name));
|
|
1128
1361
|
|
|
1129
1362
|
// Default: no dynamic injection gathering → fully transparent (no
|
|
1130
1363
|
// inference, no Chronicle writes, no external RPC). Opt in explicitly.
|
|
@@ -1279,6 +1512,22 @@ export class AgentFramework {
|
|
|
1279
1512
|
}
|
|
1280
1513
|
}
|
|
1281
1514
|
|
|
1515
|
+
private validatePersistedAgentRuntimeSettings(
|
|
1516
|
+
agentName: string,
|
|
1517
|
+
overrides: AgentRuntimeSettingsOverrides,
|
|
1518
|
+
): AgentRuntimeSettingsOverrides {
|
|
1519
|
+
if (
|
|
1520
|
+
overrides.sameRoundThinkTextPolicy !== undefined &&
|
|
1521
|
+
overrides.sameRoundThinkTextPolicy !== 'public' &&
|
|
1522
|
+
overrides.sameRoundThinkTextPolicy !== 'private'
|
|
1523
|
+
) {
|
|
1524
|
+
throw new Error(
|
|
1525
|
+
`Invalid persisted sameRoundThinkTextPolicy for agent "${agentName}": ${JSON.stringify(overrides.sameRoundThinkTextPolicy)}`,
|
|
1526
|
+
);
|
|
1527
|
+
}
|
|
1528
|
+
return overrides;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1282
1531
|
private persistAgentRuntimeSettings(
|
|
1283
1532
|
agentName: string,
|
|
1284
1533
|
overrides: AgentRuntimeSettingsOverrides,
|
|
@@ -1633,11 +1882,44 @@ export class AgentFramework {
|
|
|
1633
1882
|
inputSchema: { type: 'object' },
|
|
1634
1883
|
};
|
|
1635
1884
|
|
|
1885
|
+
/** Synthesized save_image tool — present when a workspace module is
|
|
1886
|
+
* registered. Lets the agent persist an image it has already seen in its
|
|
1887
|
+
* own context (Discord attachments arrive inlined as base64 and are
|
|
1888
|
+
* otherwise unreachable as files). */
|
|
1889
|
+
private static readonly SAVE_IMAGE_TOOL: import('./types/index.js').ToolDefinition = {
|
|
1890
|
+
name: 'save_recent_image',
|
|
1891
|
+
description:
|
|
1892
|
+
'Save one or more recent images from your own context to workspace files. ' +
|
|
1893
|
+
'Images are counted back from the most recent (index 0). A single image is ' +
|
|
1894
|
+
'written to `path` as given (e.g. "project/photos/cat.png"); when `count` > 1 ' +
|
|
1895
|
+
'the range index..index+count-1 is saved with numeric suffixes ' +
|
|
1896
|
+
'("cat-0.png", "cat-1.png", …; 0 = the newest of the range). Saved files are ' +
|
|
1897
|
+
'visible via workspace tools and the /files/ endpoint.',
|
|
1898
|
+
inputSchema: {
|
|
1899
|
+
type: 'object',
|
|
1900
|
+
properties: {
|
|
1901
|
+
path: {
|
|
1902
|
+
type: 'string',
|
|
1903
|
+
description: 'Mount-prefixed destination path, e.g. "project/photos/name.png".',
|
|
1904
|
+
},
|
|
1905
|
+
index: {
|
|
1906
|
+
type: 'number',
|
|
1907
|
+
description: 'Which image, counting back from the most recent (0 = most recent). Default 0.',
|
|
1908
|
+
},
|
|
1909
|
+
count: {
|
|
1910
|
+
type: 'number',
|
|
1911
|
+
description: 'How many images to save, starting at `index` and going further back. Default 1.',
|
|
1912
|
+
},
|
|
1913
|
+
},
|
|
1914
|
+
required: ['path'],
|
|
1915
|
+
},
|
|
1916
|
+
};
|
|
1917
|
+
|
|
1636
1918
|
private static readonly AGENT_SETTINGS_TOOL: import('./types/index.js').ToolDefinition = {
|
|
1637
1919
|
name: 'agent_settings',
|
|
1638
1920
|
description:
|
|
1639
1921
|
'Read or change your hot runtime settings. This intentionally exposes only ' +
|
|
1640
|
-
'context budget, recent raw tail size, and
|
|
1922
|
+
'context budget, recent raw tail size, transition pace, and same-round think text routing; model, prompts, ' +
|
|
1641
1923
|
'folding strategy, and other restart-bound configuration are not mutable here. ' +
|
|
1642
1924
|
'Lower context budgets converge gradually under the transition pace before ' +
|
|
1643
1925
|
'becoming the hard live limit; increases take effect immediately.',
|
|
@@ -1648,9 +1930,16 @@ export class AgentFramework {
|
|
|
1648
1930
|
context_budget_tokens: { type: 'number', description: 'Total input context budget, including the reserved response allowance.' },
|
|
1649
1931
|
tail_tokens: { type: 'number', description: 'Recent raw context retained verbatim.' },
|
|
1650
1932
|
transition_pace_tokens: { type: 'number', description: 'Maximum ordinary KV re-read/perturbation per compile while converging.' },
|
|
1933
|
+
same_round_think_text_policy: {
|
|
1934
|
+
type: 'string',
|
|
1935
|
+
enum: ['public', 'private'],
|
|
1936
|
+
description:
|
|
1937
|
+
'Routing policy for ordinary text emitted in the same native assistant round as think(). ' +
|
|
1938
|
+
"Omitted in the recipe preserves the compatibility carry-forward: public.",
|
|
1939
|
+
},
|
|
1651
1940
|
settings: {
|
|
1652
1941
|
type: 'array',
|
|
1653
|
-
items: { type: 'string', enum: ['context_budget_tokens', 'tail_tokens', 'transition_pace_tokens'] },
|
|
1942
|
+
items: { type: 'string', enum: ['context_budget_tokens', 'tail_tokens', 'transition_pace_tokens', 'same_round_think_text_policy'] },
|
|
1654
1943
|
description: 'For reset: settings to restore to recipe values. Omit to reset all.',
|
|
1655
1944
|
},
|
|
1656
1945
|
},
|
|
@@ -1943,6 +2232,9 @@ export class AgentFramework {
|
|
|
1943
2232
|
undone?: number;
|
|
1944
2233
|
requested?: number;
|
|
1945
2234
|
messagesRemoved?: number;
|
|
2235
|
+
/** Discord addresses removed by message-granular undo. The durable outbox
|
|
2236
|
+
* owns eventual delivery; this is also returned for immediate surfaces. */
|
|
2237
|
+
removedRefs?: Array<{ serverId: string; channelId: string; messageId: string }>;
|
|
1946
2238
|
hidden?: number;
|
|
1947
2239
|
/** For `hide`: the Discord (channelId, messageId) of each removed message
|
|
1948
2240
|
* that carried one — so the surface can mark them with a reaction. */
|
|
@@ -2089,8 +2381,26 @@ export class AgentFramework {
|
|
|
2089
2381
|
};
|
|
2090
2382
|
}
|
|
2091
2383
|
const target = allMessages[allMessages.length - 1 - n];
|
|
2092
|
-
const
|
|
2384
|
+
const discarded = allMessages.slice(allMessages.length - n);
|
|
2385
|
+
const removedRefs = extractDiscordAwarenessRefs(discarded);
|
|
2386
|
+
const sourceBranch = this.store.currentBranch().name;
|
|
2387
|
+
const targetBranch = `undo-msgs/${agentName}/${Date.now()}`;
|
|
2388
|
+
|
|
2389
|
+
// Prepare the external side effect before switching Chronicle. If the
|
|
2390
|
+
// process dies after the switch but before activate(), startup promotes
|
|
2391
|
+
// this batch by matching targetBranch to the active branch.
|
|
2392
|
+
const markerBatch = this.discordAwarenessOutbox?.prepare({
|
|
2393
|
+
agentName,
|
|
2394
|
+
sourceBranch,
|
|
2395
|
+
targetBranch,
|
|
2396
|
+
refs: removedRefs,
|
|
2397
|
+
emoji: this.discordAwarenessEmoji,
|
|
2398
|
+
}) ?? null;
|
|
2399
|
+
|
|
2400
|
+
const branchName = cm.branchAt(target.id, targetBranch);
|
|
2093
2401
|
await cm.switchBranch(branchName);
|
|
2402
|
+
if (markerBatch) this.discordAwarenessOutbox!.activate(markerBatch.id);
|
|
2403
|
+
await this.syncDiscordAwarenessMarkers();
|
|
2094
2404
|
|
|
2095
2405
|
// Materialize config files from the new branch (fire-and-forget; gate
|
|
2096
2406
|
// picks up via mtime) — mirrors undoLastTurn.
|
|
@@ -2109,6 +2419,7 @@ export class AgentFramework {
|
|
|
2109
2419
|
return {
|
|
2110
2420
|
ok: true,
|
|
2111
2421
|
messagesRemoved: n,
|
|
2422
|
+
removedRefs,
|
|
2112
2423
|
lastVisible: await this.lastVisiblePreview(agentName),
|
|
2113
2424
|
};
|
|
2114
2425
|
}
|
|
@@ -2138,6 +2449,8 @@ export class AgentFramework {
|
|
|
2138
2449
|
return { ok: true, undone: 0, requested, lastVisible: null };
|
|
2139
2450
|
}
|
|
2140
2451
|
|
|
2452
|
+
await this.syncDiscordAwarenessMarkers();
|
|
2453
|
+
|
|
2141
2454
|
return { ok: true, undone, requested, lastVisible: await this.lastVisiblePreview(agentName) };
|
|
2142
2455
|
}
|
|
2143
2456
|
|
|
@@ -2727,7 +3040,11 @@ export class AgentFramework {
|
|
|
2727
3040
|
|
|
2728
3041
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
2729
3042
|
const restoredSettings = this.readAgentRuntimeSettings(config.name);
|
|
2730
|
-
if (restoredSettings)
|
|
3043
|
+
if (restoredSettings) {
|
|
3044
|
+
agent.restoreRuntimeSettings(
|
|
3045
|
+
this.validatePersistedAgentRuntimeSettings(config.name, restoredSettings),
|
|
3046
|
+
);
|
|
3047
|
+
}
|
|
2731
3048
|
this.agents.set(config.name, agent);
|
|
2732
3049
|
this.agentConfigs.set(config.name, config);
|
|
2733
3050
|
|
|
@@ -2763,12 +3080,21 @@ export class AgentFramework {
|
|
|
2763
3080
|
// Check for inference requests
|
|
2764
3081
|
await this.processInferenceRequests();
|
|
2765
3082
|
|
|
2766
|
-
// Yield to the event loop between iterations.
|
|
2767
|
-
//
|
|
2768
|
-
// (
|
|
2769
|
-
|
|
3083
|
+
// Yield to the event loop between iterations. A pending inference request
|
|
3084
|
+
// is not necessarily runnable: while its agent is streaming or waiting for
|
|
3085
|
+
// tools, processInferenceRequests() deliberately requeues it. Treating that
|
|
3086
|
+
// requeued request as "work made progress" creates a microtask-only polling
|
|
3087
|
+
// loop. If activeStreams bookkeeping is absent/stale at the same time, the
|
|
3088
|
+
// old code had no await at all and starved the tool-result and HTTP I/O that
|
|
3089
|
+
// could make the agent runnable again (the Sol outage, 2026-07-15).
|
|
3090
|
+
//
|
|
3091
|
+
// No queue event means no foreground progress, so always take the normal
|
|
3092
|
+
// polling backoff. After an event, retain the low-latency macrotask yield
|
|
3093
|
+
// while background work remains. Both paths give Bun/Node a real event-loop
|
|
3094
|
+
// turn; neither can recurse forever through already-resolved promises.
|
|
3095
|
+
if (!event) {
|
|
2770
3096
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
2771
|
-
} else if (this.activeStreams.size > 0) {
|
|
3097
|
+
} else if (this.pendingRequests.length > 0 || this.activeStreams.size > 0) {
|
|
2772
3098
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2773
3099
|
}
|
|
2774
3100
|
}
|
|
@@ -2883,6 +3209,25 @@ export class AgentFramework {
|
|
|
2883
3209
|
}
|
|
2884
3210
|
}
|
|
2885
3211
|
|
|
3212
|
+
// A newly injected message begins a new conversational round inside
|
|
3213
|
+
// the same provider inference. Remember that boundary for
|
|
3214
|
+
// driveStream: an explicit send in the preceding round must not
|
|
3215
|
+
// silence the reply, and the newest channel-bearing injection is
|
|
3216
|
+
// now the reply locus. Map presence matters even without a channel
|
|
3217
|
+
// (CLI/module input): it still clears the prior send suppression.
|
|
3218
|
+
if (midTurnInjections.length > 0) {
|
|
3219
|
+
const injectedChannelId = midTurnInjections.reduce<string | null>(
|
|
3220
|
+
(latest, injection) => {
|
|
3221
|
+
const candidate = injection.metadata?.channelId;
|
|
3222
|
+
return typeof candidate === 'string' && candidate.length > 0
|
|
3223
|
+
? candidate
|
|
3224
|
+
: latest;
|
|
3225
|
+
},
|
|
3226
|
+
null,
|
|
3227
|
+
);
|
|
3228
|
+
this.midTurnRoutingResets.set(agent.name, injectedChannelId);
|
|
3229
|
+
}
|
|
3230
|
+
|
|
2886
3231
|
// Check if any tool result requested endTurn
|
|
2887
3232
|
const shouldEndTurn = currentState.toolResults.some(tc => tc.result.endTurn);
|
|
2888
3233
|
|
|
@@ -3546,7 +3891,15 @@ export class AgentFramework {
|
|
|
3546
3891
|
if (agent.state.status === 'inferring' || agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') {
|
|
3547
3892
|
// Re-queue requests, but warn if they've been pending too long
|
|
3548
3893
|
const oldest = Math.min(...requests.map(r => r.timestamp));
|
|
3549
|
-
if (
|
|
3894
|
+
if (
|
|
3895
|
+
now - oldest > STALE_REQUEST_MS &&
|
|
3896
|
+
(this.staleWarnAt.get(agentName) ?? 0) < now - 60_000
|
|
3897
|
+
) {
|
|
3898
|
+
// Trace and stderr share the throttle. The old code throttled only
|
|
3899
|
+
// stderr, so a long-running tool call generated one trace per poll
|
|
3900
|
+
// (up to 100/sec after the scheduler backoff), adding avoidable work
|
|
3901
|
+
// precisely while the agent was already under pressure.
|
|
3902
|
+
this.staleWarnAt.set(agentName, now);
|
|
3550
3903
|
this.emitTrace({
|
|
3551
3904
|
type: 'inference:request_stale',
|
|
3552
3905
|
agentName,
|
|
@@ -3554,14 +3907,10 @@ export class AgentFramework {
|
|
|
3554
3907
|
requestCount: requests.length,
|
|
3555
3908
|
oldestRequestAge: now - oldest,
|
|
3556
3909
|
});
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
`[inference-stale] agent=${agentName} busy (${agent.state.status}) — ` +
|
|
3562
|
-
`${requests.length} request(s) waiting ${Math.round((now - oldest) / 1000)}s`,
|
|
3563
|
-
);
|
|
3564
|
-
}
|
|
3910
|
+
console.error(
|
|
3911
|
+
`[inference-stale] agent=${agentName} busy (${agent.state.status}) — ` +
|
|
3912
|
+
`${requests.length} request(s) waiting ${Math.round((now - oldest) / 1000)}s`,
|
|
3913
|
+
);
|
|
3565
3914
|
}
|
|
3566
3915
|
this.pendingRequests.push(...requests);
|
|
3567
3916
|
continue;
|
|
@@ -3622,7 +3971,8 @@ export class AgentFramework {
|
|
|
3622
3971
|
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
|
|
3623
3972
|
|
|
3624
3973
|
try {
|
|
3625
|
-
const
|
|
3974
|
+
const requestSnapshot = this.captureInferenceToolSnapshot(agent);
|
|
3975
|
+
const allTools = this.getToolsForAgent(agent.name, requestSnapshot);
|
|
3626
3976
|
const tools = allTools.filter((t) => agent.canUseTool(t.name));
|
|
3627
3977
|
|
|
3628
3978
|
// Gather context from modules (pull-based) and MCPL hooks (push-based)
|
|
@@ -3664,7 +4014,14 @@ export class AgentFramework {
|
|
|
3664
4014
|
|
|
3665
4015
|
const { stream, request: compiledRequest } = await agent.startStreamWithInjections(tools, injections);
|
|
3666
4016
|
|
|
3667
|
-
const handle = this.driveStream(
|
|
4017
|
+
const handle = this.driveStream(
|
|
4018
|
+
agent,
|
|
4019
|
+
stream,
|
|
4020
|
+
requestSnapshot,
|
|
4021
|
+
trigger,
|
|
4022
|
+
attempt,
|
|
4023
|
+
compiledRequest,
|
|
4024
|
+
);
|
|
3668
4025
|
this.activeStreams.set(agent.name, handle);
|
|
3669
4026
|
} catch (error) {
|
|
3670
4027
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
@@ -3710,6 +4067,7 @@ export class AgentFramework {
|
|
|
3710
4067
|
private async driveStream(
|
|
3711
4068
|
agent: Agent,
|
|
3712
4069
|
stream: YieldingStream,
|
|
4070
|
+
requestSnapshot: InferenceToolSnapshot,
|
|
3713
4071
|
trigger?: InferenceRequest,
|
|
3714
4072
|
attempt = 0,
|
|
3715
4073
|
compiledRequest?: NormalizedRequest
|
|
@@ -3720,17 +4078,15 @@ export class AgentFramework {
|
|
|
3720
4078
|
let hadToolCalls = false;
|
|
3721
4079
|
|
|
3722
4080
|
// ---- Present-while-acting turn state ---------------------------------
|
|
3723
|
-
// Output locus for
|
|
3724
|
-
//
|
|
3725
|
-
//
|
|
3726
|
-
//
|
|
3727
|
-
//
|
|
3728
|
-
//
|
|
3729
|
-
// null resolution is NOT pinned — a turn whose locus is unresolvable
|
|
3730
|
-
// early (e.g. heartbeat-triggered) may still resolve a channel by the
|
|
3731
|
-
// time later prose routes.
|
|
4081
|
+
// Output locus for the current CONVERSATIONAL ROUND: resolved lazily and
|
|
4082
|
+
// kept stable across tool-only rounds. A channel message injected at a
|
|
4083
|
+
// tool boundary replaces the pin before the next model round, because a
|
|
4084
|
+
// single provider inference can now contain several conversations. The
|
|
4085
|
+
// pin lives in `turnLocusPins` so it survives a context-budget restart. A
|
|
4086
|
+
// null resolution is not pinned.
|
|
3732
4087
|
if (trigger?.reason !== 'context_budget_restart') {
|
|
3733
4088
|
this.turnLocusPins.delete(agent.name);
|
|
4089
|
+
this.midTurnRoutingResets.delete(agent.name);
|
|
3734
4090
|
}
|
|
3735
4091
|
const resolveTurnLocus = (): string | null => {
|
|
3736
4092
|
let locus = this.turnLocusPins.get(agent.name) ?? null;
|
|
@@ -3757,10 +4113,9 @@ export class AgentFramework {
|
|
|
3757
4113
|
.catch((err) => console.error('mid-turn speech routing failed:', err));
|
|
3758
4114
|
};
|
|
3759
4115
|
|
|
3760
|
-
// Sticky
|
|
3761
|
-
//
|
|
3762
|
-
//
|
|
3763
|
-
// rounds' prose was already delivered live and cannot be retracted.
|
|
4116
|
+
// Sticky explicit-send suppression: prose after send_message stays quiet
|
|
4117
|
+
// to prevent a redundant "sent it" postscript. Fresh injected input
|
|
4118
|
+
// clears it, because the following prose is a reply to a new message.
|
|
3764
4119
|
let turnSilenced = false;
|
|
3765
4120
|
|
|
3766
4121
|
// Live routing is only trusted when the membrane provides verbatim
|
|
@@ -3777,11 +4132,30 @@ export class AgentFramework {
|
|
|
3777
4132
|
// responding to, for the whole duration of this turn. Started here (paired
|
|
3778
4133
|
// with the finally below, so it can never leak) and refreshed on a 7s
|
|
3779
4134
|
// interval by the ChannelRegistry until stopped on any exit path.
|
|
3780
|
-
|
|
4135
|
+
let typingChannel = this.channelRegistry
|
|
3781
4136
|
? trigger?.channelId ?? this.channelRegistry.getDefaultPublishChannel()
|
|
3782
4137
|
: null;
|
|
3783
4138
|
if (typingChannel) this.channelRegistry!.startTyping(typingChannel);
|
|
3784
4139
|
|
|
4140
|
+
const adoptInjectedRound = (): void => {
|
|
4141
|
+
if (!this.midTurnRoutingResets.has(agent.name)) return;
|
|
4142
|
+
const nextLocus = this.midTurnRoutingResets.get(agent.name) ?? null;
|
|
4143
|
+
this.midTurnRoutingResets.delete(agent.name);
|
|
4144
|
+
|
|
4145
|
+
// A new human/agent message, not merely a tool result, means any earlier
|
|
4146
|
+
// explicit delivery has completed its conversational job.
|
|
4147
|
+
turnSilenced = false;
|
|
4148
|
+
|
|
4149
|
+
if (nextLocus) {
|
|
4150
|
+
this.turnLocusPins.set(agent.name, nextLocus);
|
|
4151
|
+
if (typingChannel !== nextLocus) {
|
|
4152
|
+
if (typingChannel) this.channelRegistry?.stopTyping(typingChannel);
|
|
4153
|
+
typingChannel = nextLocus;
|
|
4154
|
+
this.channelRegistry?.startTyping(nextLocus);
|
|
4155
|
+
}
|
|
4156
|
+
}
|
|
4157
|
+
};
|
|
4158
|
+
|
|
3785
4159
|
try {
|
|
3786
4160
|
for await (const event of stream) {
|
|
3787
4161
|
this.touchEphemeralRun(agent.name, true);
|
|
@@ -3809,6 +4183,7 @@ export class AgentFramework {
|
|
|
3809
4183
|
}
|
|
3810
4184
|
|
|
3811
4185
|
case 'tool-calls': {
|
|
4186
|
+
adoptInjectedRound();
|
|
3812
4187
|
hadToolCalls = true;
|
|
3813
4188
|
this.recordEphemeralToolCalls(agent.name, event.calls.length);
|
|
3814
4189
|
this.emitTrace({
|
|
@@ -3866,6 +4241,9 @@ export class AgentFramework {
|
|
|
3866
4241
|
// note above — the fallback preamble is cumulative in XML mode).
|
|
3867
4242
|
if (this.channelRegistry) {
|
|
3868
4243
|
const roundToolNames = event.calls.map((c) => c.name);
|
|
4244
|
+
const hasSameRoundPrivateThink =
|
|
4245
|
+
roundToolNames.includes('think') &&
|
|
4246
|
+
requestSnapshot.sameRoundThinkTextPolicy === 'private';
|
|
3869
4247
|
if (roundToolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)))) {
|
|
3870
4248
|
turnSilenced = true;
|
|
3871
4249
|
}
|
|
@@ -3877,6 +4255,10 @@ export class AgentFramework {
|
|
|
3877
4255
|
console.error(
|
|
3878
4256
|
`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`,
|
|
3879
4257
|
);
|
|
4258
|
+
} else if (hasSameRoundPrivateThink) {
|
|
4259
|
+
console.error(
|
|
4260
|
+
`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (same_round_think_text_policy=private)`,
|
|
4261
|
+
);
|
|
3880
4262
|
} else {
|
|
3881
4263
|
const locus = resolveTurnLocus();
|
|
3882
4264
|
console.error(
|
|
@@ -3894,6 +4276,7 @@ export class AgentFramework {
|
|
|
3894
4276
|
}
|
|
3895
4277
|
|
|
3896
4278
|
case 'complete': {
|
|
4279
|
+
adoptInjectedRound();
|
|
3897
4280
|
const durationMs = Date.now() - startTime;
|
|
3898
4281
|
const response = event.response;
|
|
3899
4282
|
|
|
@@ -4209,15 +4592,17 @@ export class AgentFramework {
|
|
|
4209
4592
|
// speech/thoughts split is left untouched (module/TUI rendering
|
|
4210
4593
|
// unaffected) — this only governs what reaches the channel.
|
|
4211
4594
|
//
|
|
4212
|
-
//
|
|
4213
|
-
//
|
|
4214
|
-
//
|
|
4215
|
-
//
|
|
4595
|
+
// Native round routing uses the live sticky flag, which a fresh
|
|
4596
|
+
// injected message can reset. The legacy/fallback path has no
|
|
4597
|
+
// reliable round boundaries, so retain its historical turn-wide
|
|
4598
|
+
// scan to avoid double-posting.
|
|
4216
4599
|
const toolNames = response.content
|
|
4217
4600
|
.filter((b) => b.type === 'tool_use')
|
|
4218
4601
|
.map((b) => (b as unknown as { name?: string }).name)
|
|
4219
4602
|
.filter((n): n is string => typeof n === 'string');
|
|
4220
|
-
const silenced =
|
|
4603
|
+
const silenced = liveProseRouting
|
|
4604
|
+
? turnSilenced
|
|
4605
|
+
: turnSilenced || toolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)));
|
|
4221
4606
|
|
|
4222
4607
|
const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content);
|
|
4223
4608
|
|
|
@@ -4589,6 +4974,51 @@ export class AgentFramework {
|
|
|
4589
4974
|
return undefined;
|
|
4590
4975
|
}
|
|
4591
4976
|
|
|
4977
|
+
private approximateDecodedBase64Bytes(base64: string): number {
|
|
4978
|
+
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
|
|
4979
|
+
return Math.max(0, Math.floor(base64.length * 3 / 4) - padding);
|
|
4980
|
+
}
|
|
4981
|
+
|
|
4982
|
+
private sanitizeProcessLogValue(value: unknown, seen = new WeakMap<object, unknown>()): unknown {
|
|
4983
|
+
if (value && typeof value === 'object') {
|
|
4984
|
+
const seenValue = seen.get(value);
|
|
4985
|
+
if (seenValue) return seenValue;
|
|
4986
|
+
}
|
|
4987
|
+
if (Array.isArray(value)) {
|
|
4988
|
+
const clone: unknown[] = [];
|
|
4989
|
+
seen.set(value, clone);
|
|
4990
|
+
for (const item of value) {
|
|
4991
|
+
clone.push(this.sanitizeProcessLogValue(item, seen));
|
|
4992
|
+
}
|
|
4993
|
+
return clone;
|
|
4994
|
+
}
|
|
4995
|
+
if (!value || typeof value !== 'object') {
|
|
4996
|
+
return value;
|
|
4997
|
+
}
|
|
4998
|
+
|
|
4999
|
+
const record = value as Record<string, unknown>;
|
|
5000
|
+
if (record.type === 'image' && typeof record.data === 'string' && typeof record.mimeType === 'string') {
|
|
5001
|
+
const clone: Record<string, unknown> = {};
|
|
5002
|
+
seen.set(value, clone);
|
|
5003
|
+
for (const [key, child] of Object.entries(record)) {
|
|
5004
|
+
if (key === 'data' || key === 'mimeType') continue;
|
|
5005
|
+
clone[key] = this.sanitizeProcessLogValue(child, seen);
|
|
5006
|
+
}
|
|
5007
|
+
clone.type = 'image';
|
|
5008
|
+
clone.mimeType = record.mimeType;
|
|
5009
|
+
clone.approxByteLength = this.approximateDecodedBase64Bytes(record.data);
|
|
5010
|
+
clone.redacted = true;
|
|
5011
|
+
return clone;
|
|
5012
|
+
}
|
|
5013
|
+
|
|
5014
|
+
const clone: Record<string, unknown> = {};
|
|
5015
|
+
seen.set(value, clone);
|
|
5016
|
+
for (const [key, child] of Object.entries(record)) {
|
|
5017
|
+
clone[key] = this.sanitizeProcessLogValue(child, seen);
|
|
5018
|
+
}
|
|
5019
|
+
return clone;
|
|
5020
|
+
}
|
|
5021
|
+
|
|
4592
5022
|
private logInference(entry: InferenceLogEntry): void {
|
|
4593
5023
|
// Store large request/response as blobs
|
|
4594
5024
|
const entryToStore = { ...entry };
|
|
@@ -4622,15 +5052,15 @@ export class AgentFramework {
|
|
|
4622
5052
|
private logProcessEvent(event: ProcessEvent, responses: ModuleProcessResponse[]): void {
|
|
4623
5053
|
const entry: ProcessLogEntry = {
|
|
4624
5054
|
timestamp: Date.now(),
|
|
4625
|
-
processEvent: event,
|
|
4626
|
-
responses,
|
|
5055
|
+
processEvent: this.sanitizeProcessLogValue(event) as ProcessEvent,
|
|
5056
|
+
responses: this.sanitizeProcessLogValue(responses) as ModuleProcessResponse[],
|
|
4627
5057
|
};
|
|
4628
5058
|
|
|
4629
5059
|
// Blob threshold: 10KB
|
|
4630
5060
|
const BLOB_THRESHOLD = 10000;
|
|
4631
5061
|
|
|
4632
5062
|
const entryToStore = { ...entry };
|
|
4633
|
-
const responsesJson = JSON.stringify(responses);
|
|
5063
|
+
const responsesJson = JSON.stringify(entry.responses);
|
|
4634
5064
|
if (responsesJson.length > BLOB_THRESHOLD) {
|
|
4635
5065
|
const blobId = this.store.storeBlob(Buffer.from(responsesJson), 'application/json');
|
|
4636
5066
|
entryToStore.responses = { blobId };
|
|
@@ -4676,6 +5106,11 @@ export class AgentFramework {
|
|
|
4676
5106
|
return;
|
|
4677
5107
|
}
|
|
4678
5108
|
|
|
5109
|
+
if (enrichedCall.name === 'save_recent_image') {
|
|
5110
|
+
this.dispatchSaveImageToolCall(agentName, enrichedCall);
|
|
5111
|
+
return;
|
|
5112
|
+
}
|
|
5113
|
+
|
|
4679
5114
|
// Route gate_status tool
|
|
4680
5115
|
if (enrichedCall.name === 'gate_status' && this.eventGate) {
|
|
4681
5116
|
this.dispatchGateToolCall(agentName, enrichedCall);
|
|
@@ -4979,6 +5414,22 @@ export class AgentFramework {
|
|
|
4979
5414
|
* every ~15 min instead of flooding the channel on every occurrence.
|
|
4980
5415
|
* See connectome docs/observability.md.
|
|
4981
5416
|
*/
|
|
5417
|
+
/**
|
|
5418
|
+
* Public ops-alert entry point for host-wired alarms that originate
|
|
5419
|
+
* OUTSIDE the framework (e.g. context-manager's repeating
|
|
5420
|
+
* compression-quarantine klaxon, wired in the host at strategy
|
|
5421
|
+
* construction). Same sinks and cooldown as internal alerts:
|
|
5422
|
+
* failures.log + ops:alert trace + CONNECTOME_OPS_WEBHOOK.
|
|
5423
|
+
*/
|
|
5424
|
+
notifyOps(
|
|
5425
|
+
kind: string,
|
|
5426
|
+
agentName: string,
|
|
5427
|
+
message: string,
|
|
5428
|
+
data?: Record<string, unknown>,
|
|
5429
|
+
): void {
|
|
5430
|
+
this.opsAlert(kind, agentName, message, { data });
|
|
5431
|
+
}
|
|
5432
|
+
|
|
4982
5433
|
private opsAlert(
|
|
4983
5434
|
kind: string,
|
|
4984
5435
|
agentName: string,
|
|
@@ -5074,6 +5525,17 @@ export class AgentFramework {
|
|
|
5074
5525
|
return {};
|
|
5075
5526
|
}
|
|
5076
5527
|
|
|
5528
|
+
/**
|
|
5529
|
+
* Public entry to the ops-alert pipeline (failures.log + ops:alert trace +
|
|
5530
|
+
* throttled webhook) for host-level detectors OUTSIDE the framework's own
|
|
5531
|
+
* inference driver — e.g. the host's logging adapter catching refusals on
|
|
5532
|
+
* off-path calls (compression/summarizer, maintenance) that noteRefusal
|
|
5533
|
+
* never sees. Same per-(agent,kind) cooldown as everything else.
|
|
5534
|
+
*/
|
|
5535
|
+
notifyOpsAlert(kind: string, agentName: string, message: string, data?: Record<string, unknown>): void {
|
|
5536
|
+
this.opsAlert(kind, agentName, message, { data });
|
|
5537
|
+
}
|
|
5538
|
+
|
|
5077
5539
|
private noteInferenceExhausted(
|
|
5078
5540
|
agentName: string,
|
|
5079
5541
|
reason: string,
|
|
@@ -5355,8 +5817,16 @@ export class AgentFramework {
|
|
|
5355
5817
|
|
|
5356
5818
|
for (const config of serverConfigs) {
|
|
5357
5819
|
try {
|
|
5358
|
-
|
|
5820
|
+
// Stage every startup connection with both planes closed. This removes
|
|
5821
|
+
// server-order dependence: no early heartbeat/shell push can become
|
|
5822
|
+
// model-visible before a later Discord connection has joined the same
|
|
5823
|
+
// awareness generation.
|
|
5824
|
+
await this.connectMcplServerInternal(config, true);
|
|
5359
5825
|
} catch (error) {
|
|
5826
|
+
if (error instanceof DiscordAwarenessAccountingError) {
|
|
5827
|
+
await this.mcplServerRegistry.closeAll();
|
|
5828
|
+
throw error;
|
|
5829
|
+
}
|
|
5360
5830
|
// Fail-open: log and continue with remaining servers
|
|
5361
5831
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
5362
5832
|
console.error(`Failed to connect MCPL server "${config.id}":`, err.message);
|
|
@@ -5374,10 +5844,353 @@ export class AgentFramework {
|
|
|
5374
5844
|
}
|
|
5375
5845
|
}
|
|
5376
5846
|
|
|
5847
|
+
// Start the durable drain only after all startup connections are staged.
|
|
5848
|
+
// The awareness tools/call is written before control-plane registration is
|
|
5849
|
+
// flushed, preserving the server-side queue -> registration -> call order.
|
|
5850
|
+
const startupBarrier = this.installMcplDataPlaneGate();
|
|
5851
|
+
this.releaseMcplDataPlaneGate(startupBarrier);
|
|
5852
|
+
try {
|
|
5853
|
+
await startupBarrier.promise;
|
|
5854
|
+
this.completeMcplDataPlaneGate(startupBarrier);
|
|
5855
|
+
} catch (error) {
|
|
5856
|
+
await this.mcplServerRegistry.closeAll();
|
|
5857
|
+
throw error;
|
|
5858
|
+
}
|
|
5859
|
+
|
|
5377
5860
|
// Discover tools from all connected servers
|
|
5378
5861
|
await this.refreshMcplTools();
|
|
5379
5862
|
}
|
|
5380
5863
|
|
|
5864
|
+
/** Reconcile the durable ledger with Chronicle, then deliver every server's work. */
|
|
5865
|
+
async syncDiscordAwarenessMarkers(_onlyServerId?: string): Promise<void> {
|
|
5866
|
+
if (!this.discordAwarenessOutbox) return;
|
|
5867
|
+
// A targeted retry may discover pending work for other Discord servers
|
|
5868
|
+
// during reconciliation. Safety is global, so one generation accounts for
|
|
5869
|
+
// all pending operations before releasing any MCPL data plane.
|
|
5870
|
+
const barrier = this.installMcplDataPlaneGate();
|
|
5871
|
+
this.releaseMcplDataPlaneGate(barrier);
|
|
5872
|
+
try {
|
|
5873
|
+
await barrier.promise;
|
|
5874
|
+
this.completeMcplDataPlaneGate(barrier);
|
|
5875
|
+
} catch (error) {
|
|
5876
|
+
await this.failMcplDataPlaneGate(barrier, 'explicit synchronization', error);
|
|
5877
|
+
throw error;
|
|
5878
|
+
}
|
|
5879
|
+
}
|
|
5880
|
+
|
|
5881
|
+
private async resumePreparedDiscordSuppressions(): Promise<void> {
|
|
5882
|
+
if (!this.discordAwarenessOutbox) return;
|
|
5883
|
+
const batches = this.discordAwarenessOutbox.preparedSuppressionsForBranch(
|
|
5884
|
+
this.store.currentBranch().name,
|
|
5885
|
+
this.store.listBranches(),
|
|
5886
|
+
);
|
|
5887
|
+
for (const batch of batches) {
|
|
5888
|
+
const agent = this.agents.get(batch.agentName);
|
|
5889
|
+
if (!agent) {
|
|
5890
|
+
throw new Error(
|
|
5891
|
+
`Cannot resume Discord suppression ${batch.id}: agent ${batch.agentName} is not configured`,
|
|
5892
|
+
);
|
|
5893
|
+
}
|
|
5894
|
+
const cm = agent.getContextManager();
|
|
5895
|
+
for (const interval of [...(batch.suppressionIntervals ?? [])].reverse()) {
|
|
5896
|
+
const from = cm.getMessage(interval.fromId as MessageId);
|
|
5897
|
+
const to = cm.getMessage(interval.toId as MessageId);
|
|
5898
|
+
if (!from && !to) continue; // This interval committed before the crash.
|
|
5899
|
+
if (!from || !to) {
|
|
5900
|
+
throw new Error(
|
|
5901
|
+
`Cannot safely resume Discord suppression ${batch.id}: interval ` +
|
|
5902
|
+
`${interval.fromId}..${interval.toId} is only partially present`,
|
|
5903
|
+
);
|
|
5904
|
+
}
|
|
5905
|
+
if (interval.fromId === interval.toId) cm.removeMessage(from.id);
|
|
5906
|
+
else cm.removeMessages(from.id, to.id);
|
|
5907
|
+
}
|
|
5908
|
+
this.discordAwarenessOutbox.activate(batch.id);
|
|
5909
|
+
console.error(`[discord-awareness] resumed suppression batch ${batch.id}`);
|
|
5910
|
+
}
|
|
5911
|
+
}
|
|
5912
|
+
|
|
5913
|
+
/**
|
|
5914
|
+
* Deliver every due ref independently. Permanent Discord failures are kept
|
|
5915
|
+
* in the ledger for audit but do not block later operations; retryable
|
|
5916
|
+
* failures remain pending for the next reconnect/list-change attempt.
|
|
5917
|
+
*/
|
|
5918
|
+
private drainDiscordAwarenessOutbox(
|
|
5919
|
+
serverId: string,
|
|
5920
|
+
): Promise<DiscordAwarenessDrainOutcome> {
|
|
5921
|
+
if (!this.discordAwarenessOutbox) {
|
|
5922
|
+
return Promise.resolve({ status: 'delivered', delivered: 0, failed: 0 });
|
|
5923
|
+
}
|
|
5924
|
+
const existing = this.discordAwarenessDrains.get(serverId);
|
|
5925
|
+
if (existing) return existing;
|
|
5926
|
+
|
|
5927
|
+
const drain = (async () => {
|
|
5928
|
+
const connection = this.mcplServerRegistry?.getServer(serverId);
|
|
5929
|
+
if (!connection?.isConnected) {
|
|
5930
|
+
const operations = this.readDiscordAwarenessPending(serverId);
|
|
5931
|
+
for (const operation of operations) {
|
|
5932
|
+
this.writeDiscordAwarenessFailure(
|
|
5933
|
+
operation.batchId,
|
|
5934
|
+
operation.ref,
|
|
5935
|
+
operation.action,
|
|
5936
|
+
'Awareness delivery not attempted: MCPL connection unavailable',
|
|
5937
|
+
false,
|
|
5938
|
+
);
|
|
5939
|
+
}
|
|
5940
|
+
if (operations.length > 0) {
|
|
5941
|
+
console.error(
|
|
5942
|
+
`[discord-awareness] ${serverId}: connection unavailable; ` +
|
|
5943
|
+
`durably deferred=${operations.length}`,
|
|
5944
|
+
);
|
|
5945
|
+
}
|
|
5946
|
+
return { status: 'unavailable' as const, accounted: operations.length };
|
|
5947
|
+
}
|
|
5948
|
+
|
|
5949
|
+
let delivered = 0;
|
|
5950
|
+
let failed = 0;
|
|
5951
|
+
const attempted = new Set<string>();
|
|
5952
|
+
while (true) {
|
|
5953
|
+
const operations = this.readDiscordAwarenessPending(serverId).filter((operation) => {
|
|
5954
|
+
const key = `${operation.batchId}\0${operation.ref.channelId}\0${operation.ref.messageId}\0${operation.action}`;
|
|
5955
|
+
if (attempted.has(key)) return false;
|
|
5956
|
+
attempted.add(key);
|
|
5957
|
+
return true;
|
|
5958
|
+
});
|
|
5959
|
+
if (operations.length === 0) break;
|
|
5960
|
+
for (const operation of operations) {
|
|
5961
|
+
const ref = operation.ref;
|
|
5962
|
+
const channelId = ref.channelId.startsWith('discord:')
|
|
5963
|
+
? ref.channelId.split(':').at(-1)!
|
|
5964
|
+
: ref.channelId;
|
|
5965
|
+
let deliveryError: string | undefined;
|
|
5966
|
+
try {
|
|
5967
|
+
const tool = operation.action === 'add' ? 'add_reaction' : 'remove_reaction';
|
|
5968
|
+
const result = await connection.sendToolsCallWithDeadline(tool, {
|
|
5969
|
+
channelId,
|
|
5970
|
+
messageId: ref.messageId,
|
|
5971
|
+
emoji: operation.emoji,
|
|
5972
|
+
}, this.discordAwarenessDeadlineMs);
|
|
5973
|
+
if (result.isError) {
|
|
5974
|
+
deliveryError = result.content
|
|
5975
|
+
.map((content) => content.text ?? '')
|
|
5976
|
+
.filter(Boolean)
|
|
5977
|
+
.join('; ') || `Discord ${tool} returned an error`;
|
|
5978
|
+
}
|
|
5979
|
+
} catch (error) {
|
|
5980
|
+
deliveryError = error instanceof Error ? error.message : String(error);
|
|
5981
|
+
}
|
|
5982
|
+
|
|
5983
|
+
if (deliveryError !== undefined) {
|
|
5984
|
+
const permanent = isPermanentDiscordReactionFailure(deliveryError);
|
|
5985
|
+
this.writeDiscordAwarenessFailure(
|
|
5986
|
+
operation.batchId,
|
|
5987
|
+
ref,
|
|
5988
|
+
operation.action,
|
|
5989
|
+
deliveryError,
|
|
5990
|
+
permanent,
|
|
5991
|
+
);
|
|
5992
|
+
failed++;
|
|
5993
|
+
console.error(
|
|
5994
|
+
`[discord-awareness] ${operation.action} failed for ${ref.channelId}/${ref.messageId}` +
|
|
5995
|
+
` (${permanent ? 'permanent' : 'retryable'}): ${deliveryError}`,
|
|
5996
|
+
);
|
|
5997
|
+
} else {
|
|
5998
|
+
this.writeDiscordAwarenessSuccess(operation.batchId, ref, operation.action);
|
|
5999
|
+
delivered++;
|
|
6000
|
+
}
|
|
6001
|
+
}
|
|
6002
|
+
}
|
|
6003
|
+
if (delivered > 0 || failed > 0) {
|
|
6004
|
+
console.error(
|
|
6005
|
+
`[discord-awareness] ${serverId}: delivered=${delivered} failed=${failed}`,
|
|
6006
|
+
);
|
|
6007
|
+
}
|
|
6008
|
+
return { status: 'delivered' as const, delivered, failed };
|
|
6009
|
+
})().finally(() => {
|
|
6010
|
+
this.discordAwarenessDrains.delete(serverId);
|
|
6011
|
+
});
|
|
6012
|
+
|
|
6013
|
+
this.discordAwarenessDrains.set(serverId, drain);
|
|
6014
|
+
return drain;
|
|
6015
|
+
}
|
|
6016
|
+
|
|
6017
|
+
private readDiscordAwarenessPending(serverId?: string) {
|
|
6018
|
+
try {
|
|
6019
|
+
return this.discordAwarenessOutbox!.pending(serverId);
|
|
6020
|
+
} catch (error) {
|
|
6021
|
+
throw new DiscordAwarenessAccountingError(
|
|
6022
|
+
`ledger read${serverId ? ` for ${serverId}` : ''}`,
|
|
6023
|
+
error,
|
|
6024
|
+
);
|
|
6025
|
+
}
|
|
6026
|
+
}
|
|
6027
|
+
|
|
6028
|
+
private writeDiscordAwarenessSuccess(
|
|
6029
|
+
batchId: string,
|
|
6030
|
+
ref: import('./recovery/discord-awareness-outbox.js').DiscordAwarenessRef,
|
|
6031
|
+
action: import('./recovery/discord-awareness-outbox.js').DiscordAwarenessAction,
|
|
6032
|
+
): void {
|
|
6033
|
+
try {
|
|
6034
|
+
this.discordAwarenessOutbox!.recordSuccess(batchId, ref, action);
|
|
6035
|
+
} catch (error) {
|
|
6036
|
+
throw new DiscordAwarenessAccountingError('recordSuccess ledger write', error);
|
|
6037
|
+
}
|
|
6038
|
+
}
|
|
6039
|
+
|
|
6040
|
+
private writeDiscordAwarenessFailure(
|
|
6041
|
+
batchId: string,
|
|
6042
|
+
ref: import('./recovery/discord-awareness-outbox.js').DiscordAwarenessRef,
|
|
6043
|
+
action: import('./recovery/discord-awareness-outbox.js').DiscordAwarenessAction,
|
|
6044
|
+
errorMessage: string,
|
|
6045
|
+
permanent: boolean,
|
|
6046
|
+
): void {
|
|
6047
|
+
try {
|
|
6048
|
+
this.discordAwarenessOutbox!.recordFailure(
|
|
6049
|
+
batchId,
|
|
6050
|
+
ref,
|
|
6051
|
+
action,
|
|
6052
|
+
errorMessage,
|
|
6053
|
+
permanent,
|
|
6054
|
+
);
|
|
6055
|
+
} catch (error) {
|
|
6056
|
+
throw new DiscordAwarenessAccountingError('recordFailure ledger write', error);
|
|
6057
|
+
}
|
|
6058
|
+
}
|
|
6059
|
+
|
|
6060
|
+
private beginDiscordAwarenessBarrier(): {
|
|
6061
|
+
requiresBarrier: boolean;
|
|
6062
|
+
promise: Promise<DiscordAwarenessDrainOutcome>;
|
|
6063
|
+
} {
|
|
6064
|
+
if (!this.discordAwarenessOutbox) {
|
|
6065
|
+
return {
|
|
6066
|
+
requiresBarrier: false,
|
|
6067
|
+
promise: Promise.resolve({ status: 'delivered', delivered: 0, failed: 0 }),
|
|
6068
|
+
};
|
|
6069
|
+
}
|
|
6070
|
+
try {
|
|
6071
|
+
this.discordAwarenessOutbox.reconcileForBranch(
|
|
6072
|
+
this.store.currentBranch().name,
|
|
6073
|
+
this.store.listBranches(),
|
|
6074
|
+
);
|
|
6075
|
+
} catch (error) {
|
|
6076
|
+
throw new DiscordAwarenessAccountingError('branch reconciliation', error);
|
|
6077
|
+
}
|
|
6078
|
+
const pending = this.readDiscordAwarenessPending();
|
|
6079
|
+
if (pending.length === 0) {
|
|
6080
|
+
return {
|
|
6081
|
+
requiresBarrier: false,
|
|
6082
|
+
promise: Promise.resolve({ status: 'delivered', delivered: 0, failed: 0 }),
|
|
6083
|
+
};
|
|
6084
|
+
}
|
|
6085
|
+
const serverIds = [...new Set(pending.map((operation) => operation.ref.serverId))];
|
|
6086
|
+
const promise = Promise.all(
|
|
6087
|
+
serverIds.map((serverId) => this.drainDiscordAwarenessOutbox(serverId)),
|
|
6088
|
+
).then((outcomes): DiscordAwarenessDrainOutcome => {
|
|
6089
|
+
if (outcomes.every((outcome) => outcome.status === 'unavailable')) {
|
|
6090
|
+
return {
|
|
6091
|
+
status: 'unavailable',
|
|
6092
|
+
accounted: outcomes.reduce(
|
|
6093
|
+
(count, outcome) => count + (outcome.status === 'unavailable' ? outcome.accounted : 0),
|
|
6094
|
+
0,
|
|
6095
|
+
),
|
|
6096
|
+
};
|
|
6097
|
+
}
|
|
6098
|
+
return {
|
|
6099
|
+
status: 'delivered',
|
|
6100
|
+
delivered: outcomes.reduce(
|
|
6101
|
+
(count, outcome) => count + (outcome.status === 'delivered' ? outcome.delivered : 0),
|
|
6102
|
+
0,
|
|
6103
|
+
),
|
|
6104
|
+
failed: outcomes.reduce(
|
|
6105
|
+
(count, outcome) => count + (outcome.status === 'delivered' ? outcome.failed : 0),
|
|
6106
|
+
0,
|
|
6107
|
+
),
|
|
6108
|
+
};
|
|
6109
|
+
});
|
|
6110
|
+
return { requiresBarrier: true, promise };
|
|
6111
|
+
}
|
|
6112
|
+
|
|
6113
|
+
/**
|
|
6114
|
+
* Install one framework-global Discord-awareness generation before releasing
|
|
6115
|
+
* any inference-bearing event. With pending work, every MCPL data plane is
|
|
6116
|
+
* paused while all control planes remain live. With no pending work, ready()
|
|
6117
|
+
* runs synchronously so request responders preserve their historical
|
|
6118
|
+
* same-stack behavior.
|
|
6119
|
+
*/
|
|
6120
|
+
private installMcplDataPlaneGate(): DiscordAwarenessBarrier {
|
|
6121
|
+
for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) {
|
|
6122
|
+
connection.pauseDataPlane();
|
|
6123
|
+
}
|
|
6124
|
+
const generation = ++this.discordAwarenessBarrierGeneration;
|
|
6125
|
+
|
|
6126
|
+
let begun: ReturnType<AgentFramework['beginDiscordAwarenessBarrier']>;
|
|
6127
|
+
try {
|
|
6128
|
+
begun = this.beginDiscordAwarenessBarrier();
|
|
6129
|
+
} catch (error) {
|
|
6130
|
+
begun = {
|
|
6131
|
+
requiresBarrier: true,
|
|
6132
|
+
promise: Promise.reject(error),
|
|
6133
|
+
};
|
|
6134
|
+
}
|
|
6135
|
+
const barrier: DiscordAwarenessBarrier = { generation, ...begun };
|
|
6136
|
+
this.discordAwarenessBarrier = barrier;
|
|
6137
|
+
return barrier;
|
|
6138
|
+
}
|
|
6139
|
+
|
|
6140
|
+
private releaseMcplDataPlaneGate(
|
|
6141
|
+
barrier: DiscordAwarenessBarrier,
|
|
6142
|
+
): void {
|
|
6143
|
+
if (this.discordAwarenessBarrier !== barrier) return;
|
|
6144
|
+
if (barrier.requiresBarrier) {
|
|
6145
|
+
for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) {
|
|
6146
|
+
connection.readyControlPlane();
|
|
6147
|
+
}
|
|
6148
|
+
return;
|
|
6149
|
+
}
|
|
6150
|
+
this.completeMcplDataPlaneGate(barrier);
|
|
6151
|
+
}
|
|
6152
|
+
|
|
6153
|
+
private completeMcplDataPlaneGate(
|
|
6154
|
+
barrier: DiscordAwarenessBarrier,
|
|
6155
|
+
): boolean {
|
|
6156
|
+
if (this.discordAwarenessBarrier !== barrier) return false;
|
|
6157
|
+
this.discordAwarenessBarrier = null;
|
|
6158
|
+
for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) {
|
|
6159
|
+
// ready() can synchronously flush a nested list-change notification that
|
|
6160
|
+
// installs a newer global generation. Never let this older completion
|
|
6161
|
+
// release any remaining server behind that newer gate.
|
|
6162
|
+
if (this.discordAwarenessBarrier !== null) return false;
|
|
6163
|
+
connection.ready();
|
|
6164
|
+
}
|
|
6165
|
+
return this.discordAwarenessBarrier === null;
|
|
6166
|
+
}
|
|
6167
|
+
|
|
6168
|
+
private async failMcplDataPlaneGate(
|
|
6169
|
+
barrier: DiscordAwarenessBarrier,
|
|
6170
|
+
context: string,
|
|
6171
|
+
error: unknown,
|
|
6172
|
+
): Promise<void> {
|
|
6173
|
+
if (this.discordAwarenessBarrier !== barrier) return;
|
|
6174
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
6175
|
+
console.error(`[discord-awareness] ${context} failed globally:`, err.message);
|
|
6176
|
+
const connections = this.mcplServerRegistry?.getAllServers() ?? [];
|
|
6177
|
+
for (const connection of connections) {
|
|
6178
|
+
this.emitTrace({
|
|
6179
|
+
type: 'mcpl:server-error',
|
|
6180
|
+
serverId: connection.id,
|
|
6181
|
+
error: `Discord awareness accounting unhealthy: ${err.message}`,
|
|
6182
|
+
});
|
|
6183
|
+
}
|
|
6184
|
+
await Promise.all(connections.map(async (connection) => {
|
|
6185
|
+
await connection.reconnectAfterFailure().catch((closeError) => {
|
|
6186
|
+
console.error(
|
|
6187
|
+
`[discord-awareness] could not recycle unhealthy connection ${connection.id}:`,
|
|
6188
|
+
closeError instanceof Error ? closeError.message : closeError,
|
|
6189
|
+
);
|
|
6190
|
+
});
|
|
6191
|
+
}));
|
|
6192
|
+
}
|
|
6193
|
+
|
|
5381
6194
|
/**
|
|
5382
6195
|
* Connect a single MCPL server: register routing entries, open the
|
|
5383
6196
|
* connection, wire events, and initialize feature sets / scopes /
|
|
@@ -5386,6 +6199,7 @@ export class AgentFramework {
|
|
|
5386
6199
|
*/
|
|
5387
6200
|
private async connectMcplServerInternal(
|
|
5388
6201
|
config: import('./mcpl/types.js').McplServerConfig,
|
|
6202
|
+
deferAwareness = false,
|
|
5389
6203
|
): Promise<void> {
|
|
5390
6204
|
if (!this.mcplServerRegistry || !this.mcplHostCapabilities) {
|
|
5391
6205
|
throw new Error('MCPL subsystem is not initialized');
|
|
@@ -5408,14 +6222,31 @@ export class AgentFramework {
|
|
|
5408
6222
|
|
|
5409
6223
|
const connection = await this.mcplServerRegistry.addServer(config, this.mcplHostCapabilities);
|
|
5410
6224
|
|
|
5411
|
-
// Wire
|
|
5412
|
-
//
|
|
6225
|
+
// Wire listeners before either startup staging or the runtime global gate
|
|
6226
|
+
// releases control traffic needed for registration and marker service.
|
|
5413
6227
|
this.wireMcplEvents(connection);
|
|
5414
|
-
connection.ready();
|
|
5415
6228
|
|
|
5416
6229
|
// Initialize feature sets if server advertises MCPL capabilities
|
|
5417
6230
|
this.registerMcplServerFeatures(config, connection);
|
|
5418
6231
|
|
|
6232
|
+
if (deferAwareness) {
|
|
6233
|
+
this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
|
|
6234
|
+
return;
|
|
6235
|
+
}
|
|
6236
|
+
|
|
6237
|
+
const awarenessBarrier = this.installMcplDataPlaneGate();
|
|
6238
|
+
this.releaseMcplDataPlaneGate(awarenessBarrier);
|
|
6239
|
+
try {
|
|
6240
|
+
await awarenessBarrier.promise;
|
|
6241
|
+
this.completeMcplDataPlaneGate(awarenessBarrier);
|
|
6242
|
+
} catch (error) {
|
|
6243
|
+
// Startup cannot fail open on a broken awareness ledger. Disable the
|
|
6244
|
+
// reconnecting stub/connection before propagating the distinct error to
|
|
6245
|
+
// initializeMcpl, which tears down any other servers and aborts create().
|
|
6246
|
+
await connection.close().catch(() => {});
|
|
6247
|
+
throw error;
|
|
6248
|
+
}
|
|
6249
|
+
|
|
5419
6250
|
this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
|
|
5420
6251
|
}
|
|
5421
6252
|
|
|
@@ -5629,10 +6460,12 @@ export class AgentFramework {
|
|
|
5629
6460
|
});
|
|
5630
6461
|
|
|
5631
6462
|
// Handle push events (Step 6)
|
|
5632
|
-
connection.on('push-event', (
|
|
6463
|
+
connection.on('push-event', async (
|
|
5633
6464
|
params: PushEventParams,
|
|
5634
6465
|
responder?: { respond: (result: unknown) => void; respondError: (code: number, message: string) => void },
|
|
5635
6466
|
) => {
|
|
6467
|
+
const barrier = this.discordAwarenessBarrier;
|
|
6468
|
+
if (barrier) await barrier.promise;
|
|
5636
6469
|
this.pushHandler?.handlePushEvent(connection.id, params, responder as never);
|
|
5637
6470
|
});
|
|
5638
6471
|
|
|
@@ -5642,6 +6475,8 @@ export class AgentFramework {
|
|
|
5642
6475
|
responder?: { id: string | number; respond: (result: unknown) => void; respondError: (code: number, message: string) => void },
|
|
5643
6476
|
) => {
|
|
5644
6477
|
if (this.inferenceRouter && responder) {
|
|
6478
|
+
const barrier = this.discordAwarenessBarrier;
|
|
6479
|
+
if (barrier) await barrier.promise;
|
|
5645
6480
|
await this.inferenceRouter.handleInferenceRequest(connection.id, params, {
|
|
5646
6481
|
respond: responder.respond,
|
|
5647
6482
|
respondError: responder.respondError,
|
|
@@ -5664,10 +6499,12 @@ export class AgentFramework {
|
|
|
5664
6499
|
});
|
|
5665
6500
|
|
|
5666
6501
|
// Handle incoming channel messages (Step 7)
|
|
5667
|
-
connection.on('channels-incoming', (
|
|
6502
|
+
connection.on('channels-incoming', async (
|
|
5668
6503
|
params: ChannelsIncomingParams,
|
|
5669
6504
|
responder?: { respond: (result: unknown) => void },
|
|
5670
6505
|
) => {
|
|
6506
|
+
const barrier = this.discordAwarenessBarrier;
|
|
6507
|
+
if (barrier) await barrier.promise;
|
|
5671
6508
|
this.channelRegistry?.handleIncoming(connection.id, params, responder as never);
|
|
5672
6509
|
});
|
|
5673
6510
|
|
|
@@ -5678,6 +6515,8 @@ export class AgentFramework {
|
|
|
5678
6515
|
) => {
|
|
5679
6516
|
if (!responder) return;
|
|
5680
6517
|
try {
|
|
6518
|
+
const barrier = this.discordAwarenessBarrier;
|
|
6519
|
+
if (barrier) await barrier.promise;
|
|
5681
6520
|
const result = await this.handleHostCommand(connection.id, params ?? {});
|
|
5682
6521
|
responder.respond(result);
|
|
5683
6522
|
} catch (error) {
|
|
@@ -5688,7 +6527,19 @@ export class AgentFramework {
|
|
|
5688
6527
|
|
|
5689
6528
|
// Handle dynamic tool list changes (notifications/tools/list_changed)
|
|
5690
6529
|
connection.on('tools-list-changed', () => {
|
|
6530
|
+
// Pause before starting any async refresh. This listener runs
|
|
6531
|
+
// synchronously in the transport line callback, so a following inbound
|
|
6532
|
+
// data event cannot pass before the new barrier exists.
|
|
6533
|
+
const awarenessBarrier = this.installMcplDataPlaneGate();
|
|
6534
|
+
this.releaseMcplDataPlaneGate(awarenessBarrier);
|
|
5691
6535
|
this.handleToolsListChanged(connection.id);
|
|
6536
|
+
void awarenessBarrier.promise.then(() => {
|
|
6537
|
+
this.completeMcplDataPlaneGate(awarenessBarrier);
|
|
6538
|
+
}).catch((error) => this.failMcplDataPlaneGate(
|
|
6539
|
+
awarenessBarrier,
|
|
6540
|
+
'tools-list reconciliation',
|
|
6541
|
+
error,
|
|
6542
|
+
));
|
|
5692
6543
|
});
|
|
5693
6544
|
|
|
5694
6545
|
// Re-establish full server registration on reconnect. The 'close' handler
|
|
@@ -5699,6 +6550,10 @@ export class AgentFramework {
|
|
|
5699
6550
|
// preserved across the transient close are resumed by idempotent
|
|
5700
6551
|
// registration. Then refresh tools (server may have different tools).
|
|
5701
6552
|
connection.on('reconnect', (info?: { attempts?: number }) => {
|
|
6553
|
+
// Install the barrier synchronously so any inbound event emitted after
|
|
6554
|
+
// reconnect observes it before doing work that could wake an agent. The
|
|
6555
|
+
// connection paused its data plane before wiring the fresh transport.
|
|
6556
|
+
const awarenessBarrier = this.installMcplDataPlaneGate();
|
|
5702
6557
|
try {
|
|
5703
6558
|
const config = this.mcplServerConfigs.get(connection.id);
|
|
5704
6559
|
if (config) {
|
|
@@ -5710,15 +6565,26 @@ export class AgentFramework {
|
|
|
5710
6565
|
error instanceof Error ? error.message : error,
|
|
5711
6566
|
);
|
|
5712
6567
|
}
|
|
6568
|
+
this.releaseMcplDataPlaneGate(awarenessBarrier);
|
|
5713
6569
|
this.handleToolsListChanged(connection.id);
|
|
5714
|
-
|
|
5715
|
-
|
|
5716
|
-
|
|
5717
|
-
|
|
5718
|
-
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
6570
|
+
void awarenessBarrier.promise.then(() => {
|
|
6571
|
+
if (
|
|
6572
|
+
awarenessBarrier.requiresBarrier
|
|
6573
|
+
&& !this.completeMcplDataPlaneGate(awarenessBarrier)
|
|
6574
|
+
) return;
|
|
6575
|
+
this.emitTrace({
|
|
6576
|
+
type: 'mcpl:server-reconnected',
|
|
6577
|
+
serverId: connection.id,
|
|
6578
|
+
attempts: info?.attempts ?? 0,
|
|
6579
|
+
});
|
|
6580
|
+
// Mirror the module:removed emitted on 'close' so module-lifecycle
|
|
6581
|
+
// consumers see the server come back, not just vanish.
|
|
6582
|
+
this.emitTrace({ type: 'module:added', moduleName: `mcpl:${connection.id}` });
|
|
6583
|
+
}).catch((error) => this.failMcplDataPlaneGate(
|
|
6584
|
+
awarenessBarrier,
|
|
6585
|
+
'reconnect reconciliation',
|
|
6586
|
+
error,
|
|
6587
|
+
));
|
|
5722
6588
|
});
|
|
5723
6589
|
|
|
5724
6590
|
// Surface connect/reconnect failures. Before these traces existed the
|
|
@@ -5760,6 +6626,7 @@ export class AgentFramework {
|
|
|
5760
6626
|
// divergence greppable instead of a silent drift into stale state.
|
|
5761
6627
|
connection.on('orphaned-response', (info: {
|
|
5762
6628
|
id: string | number;
|
|
6629
|
+
method?: string;
|
|
5763
6630
|
hadState: boolean;
|
|
5764
6631
|
hadCheckpoint: boolean;
|
|
5765
6632
|
}) => {
|
|
@@ -5767,6 +6634,7 @@ export class AgentFramework {
|
|
|
5767
6634
|
type: 'mcpl:orphaned-response',
|
|
5768
6635
|
serverId: connection.id,
|
|
5769
6636
|
responseId: info.id,
|
|
6637
|
+
method: info.method,
|
|
5770
6638
|
hadState: info.hadState,
|
|
5771
6639
|
hadCheckpoint: info.hadCheckpoint,
|
|
5772
6640
|
});
|
|
@@ -6228,6 +7096,153 @@ export class AgentFramework {
|
|
|
6228
7096
|
});
|
|
6229
7097
|
}
|
|
6230
7098
|
|
|
7099
|
+
/**
|
|
7100
|
+
* Handle the synthesized `save_recent_image` tool: locate the requested
|
|
7101
|
+
* image blocks in the calling agent's context (blobs re-inlined, counted
|
|
7102
|
+
* back from the most recent) and write their bytes to a workspace mount
|
|
7103
|
+
* via WorkspaceModule.writeBinary.
|
|
7104
|
+
*/
|
|
7105
|
+
private dispatchSaveImageToolCall(agentName: string, call: ToolCall): void {
|
|
7106
|
+
this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
|
|
7107
|
+
const finish = (result: ToolResult): void => {
|
|
7108
|
+
this.emitTrace({
|
|
7109
|
+
type: result.isError ? 'tool:failed' : 'tool:completed',
|
|
7110
|
+
module: 'workspace',
|
|
7111
|
+
tool: call.name,
|
|
7112
|
+
callId: call.id,
|
|
7113
|
+
durationMs: 0,
|
|
7114
|
+
...(result.isError ? { error: result.error } : {}),
|
|
7115
|
+
});
|
|
7116
|
+
this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace', result });
|
|
7117
|
+
};
|
|
7118
|
+
void (async (): Promise<void> => {
|
|
7119
|
+
try {
|
|
7120
|
+
const workspace = this.getWorkspaceModule();
|
|
7121
|
+
if (!workspace) throw new Error('save_recent_image requires a workspace module');
|
|
7122
|
+
const agent = this.agents.get(agentName);
|
|
7123
|
+
if (!agent) throw new Error(`Unknown agent: ${agentName}`);
|
|
7124
|
+
const input = (call.input ?? {}) as { path?: unknown; index?: unknown; count?: unknown };
|
|
7125
|
+
if (typeof input.path !== 'string' || input.path.length === 0) {
|
|
7126
|
+
throw new Error('save_recent_image: `path` (mount-prefixed) is required');
|
|
7127
|
+
}
|
|
7128
|
+
const index = input.index === undefined ? 0 : Number(input.index);
|
|
7129
|
+
if (!Number.isInteger(index) || index < 0) {
|
|
7130
|
+
throw new Error('save_recent_image: `index` must be a non-negative integer');
|
|
7131
|
+
}
|
|
7132
|
+
const count = input.count === undefined ? 1 : Number(input.count);
|
|
7133
|
+
const MAX_COUNT = 20;
|
|
7134
|
+
if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) {
|
|
7135
|
+
throw new Error(`save_recent_image: \`count\` must be an integer in 1..${MAX_COUNT}`);
|
|
7136
|
+
}
|
|
7137
|
+
const lastWanted = index + count - 1;
|
|
7138
|
+
|
|
7139
|
+
// Walk the message store tail-first in bounded windows, re-inlining
|
|
7140
|
+
// blob media, until we've collected the requested range. Scanning is
|
|
7141
|
+
// capped so a pathological index can't drag the whole store through
|
|
7142
|
+
// blob resolution.
|
|
7143
|
+
const MAX_SCAN = 500;
|
|
7144
|
+
const WINDOW = 25;
|
|
7145
|
+
const cm = agent.getContextManager();
|
|
7146
|
+
const total = cm.getMessageCount();
|
|
7147
|
+
let seen = 0;
|
|
7148
|
+
let scanned = 0;
|
|
7149
|
+
const found: Array<{
|
|
7150
|
+
rangeOffset: number;
|
|
7151
|
+
data: string;
|
|
7152
|
+
mediaType: string;
|
|
7153
|
+
messagesBack: number;
|
|
7154
|
+
}> = [];
|
|
7155
|
+
for (let end = total; end > 0 && scanned < MAX_SCAN && found.length < count; end -= WINDOW) {
|
|
7156
|
+
const start = Math.max(0, end - WINDOW);
|
|
7157
|
+
const { messages } = cm.getMessageWindow(start, end - start, { resolveBlobs: true });
|
|
7158
|
+
scanned += end - start;
|
|
7159
|
+
for (let i = messages.length - 1; i >= 0 && found.length < count; i--) {
|
|
7160
|
+
const content = messages[i]?.content;
|
|
7161
|
+
if (!Array.isArray(content)) continue;
|
|
7162
|
+
for (let b = content.length - 1; b >= 0 && found.length < count; b--) {
|
|
7163
|
+
const block = content[b] as {
|
|
7164
|
+
type?: string;
|
|
7165
|
+
source?: { type?: string; data?: string; mediaType?: string };
|
|
7166
|
+
};
|
|
7167
|
+
if (block?.type !== 'image') continue;
|
|
7168
|
+
if (seen >= index && seen <= lastWanted) {
|
|
7169
|
+
if (block.source?.type !== 'base64' || typeof block.source.data !== 'string') {
|
|
7170
|
+
throw new Error(
|
|
7171
|
+
`save_recent_image: image at index ${seen} is not stored inline (base64) — cannot save it`,
|
|
7172
|
+
);
|
|
7173
|
+
}
|
|
7174
|
+
found.push({
|
|
7175
|
+
rangeOffset: seen - index,
|
|
7176
|
+
data: block.source.data,
|
|
7177
|
+
mediaType: block.source.mediaType ?? 'image/png',
|
|
7178
|
+
messagesBack: total - (start + i),
|
|
7179
|
+
});
|
|
7180
|
+
}
|
|
7181
|
+
seen++;
|
|
7182
|
+
}
|
|
7183
|
+
}
|
|
7184
|
+
}
|
|
7185
|
+
if (found.length === 0) {
|
|
7186
|
+
throw new Error(
|
|
7187
|
+
seen === 0
|
|
7188
|
+
? `save_recent_image: no images found in the most recent ${Math.min(scanned, MAX_SCAN)} messages`
|
|
7189
|
+
: `save_recent_image: only ${seen} image(s) found in the most recent ${Math.min(scanned, MAX_SCAN)} messages (asked for index ${index})`,
|
|
7190
|
+
);
|
|
7191
|
+
}
|
|
7192
|
+
|
|
7193
|
+
// Single image → path as given. Range → numeric suffix before the
|
|
7194
|
+
// extension ("cat.png" → "cat-0.png"), 0 = newest of the range.
|
|
7195
|
+
const pathFor = (rangeOffset: number): string => {
|
|
7196
|
+
if (count === 1) return input.path as string;
|
|
7197
|
+
const p = input.path as string;
|
|
7198
|
+
const dot = p.lastIndexOf('.');
|
|
7199
|
+
const slash = p.lastIndexOf('/');
|
|
7200
|
+
return dot > slash
|
|
7201
|
+
? `${p.slice(0, dot)}-${rangeOffset}${p.slice(dot)}`
|
|
7202
|
+
: `${p}-${rangeOffset}`;
|
|
7203
|
+
};
|
|
7204
|
+
const savedFiles: Array<Record<string, unknown>> = [];
|
|
7205
|
+
for (const image of found) {
|
|
7206
|
+
const destination = pathFor(image.rangeOffset);
|
|
7207
|
+
const bytes = Buffer.from(image.data, 'base64');
|
|
7208
|
+
const result = await workspace.writeBinary(destination, bytes, image.mediaType);
|
|
7209
|
+
if (!result.success) {
|
|
7210
|
+
finish({
|
|
7211
|
+
success: false,
|
|
7212
|
+
error:
|
|
7213
|
+
`save_recent_image: failed writing "${destination}": ${result.error}` +
|
|
7214
|
+
(savedFiles.length > 0
|
|
7215
|
+
? ` (already saved: ${savedFiles.map((f) => f.path).join(', ')})`
|
|
7216
|
+
: ''),
|
|
7217
|
+
isError: true,
|
|
7218
|
+
});
|
|
7219
|
+
return;
|
|
7220
|
+
}
|
|
7221
|
+
savedFiles.push({
|
|
7222
|
+
...(result.data as Record<string, unknown>),
|
|
7223
|
+
imageIndex: index + image.rangeOffset,
|
|
7224
|
+
messagesBack: image.messagesBack,
|
|
7225
|
+
});
|
|
7226
|
+
}
|
|
7227
|
+
finish({
|
|
7228
|
+
success: true,
|
|
7229
|
+
data: {
|
|
7230
|
+
saved: savedFiles,
|
|
7231
|
+
...(found.length < count
|
|
7232
|
+
? { note: `only ${found.length} of ${count} requested images exist in the scanned window` }
|
|
7233
|
+
: {}),
|
|
7234
|
+
},
|
|
7235
|
+
});
|
|
7236
|
+
} catch (error) {
|
|
7237
|
+
finish({
|
|
7238
|
+
success: false,
|
|
7239
|
+
error: error instanceof Error ? error.message : String(error),
|
|
7240
|
+
isError: true,
|
|
7241
|
+
});
|
|
7242
|
+
}
|
|
7243
|
+
})();
|
|
7244
|
+
}
|
|
7245
|
+
|
|
6231
7246
|
private dispatchAgentSettingsToolCall(agentName: string, call: ToolCall): void {
|
|
6232
7247
|
this.emitTrace({
|
|
6233
7248
|
type: 'tool:started',
|
|
@@ -6243,11 +7258,22 @@ export class AgentFramework {
|
|
|
6243
7258
|
context_budget_tokens?: unknown;
|
|
6244
7259
|
tail_tokens?: unknown;
|
|
6245
7260
|
transition_pace_tokens?: unknown;
|
|
7261
|
+
same_round_think_text_policy?: unknown;
|
|
6246
7262
|
settings?: unknown;
|
|
7263
|
+
} & Record<string, unknown>;
|
|
7264
|
+
const extensions = this.collectAgentSettingsExtensions();
|
|
7265
|
+
/** Extension values merged flat alongside the core snapshot. */
|
|
7266
|
+
const extGet = (): Record<string, unknown> => {
|
|
7267
|
+
const out: Record<string, unknown> = {};
|
|
7268
|
+
for (const ext of extensions.values()) Object.assign(out, ext.get(agentName));
|
|
7269
|
+
return out;
|
|
6247
7270
|
};
|
|
6248
7271
|
switch (input.action) {
|
|
6249
7272
|
case 'get':
|
|
6250
|
-
result = {
|
|
7273
|
+
result = {
|
|
7274
|
+
success: true,
|
|
7275
|
+
data: { ...this.getAgentRuntimeSettings(agentName), ...extGet() },
|
|
7276
|
+
};
|
|
6251
7277
|
break;
|
|
6252
7278
|
case 'cancel':
|
|
6253
7279
|
result = { success: true, data: this.cancelAgentRuntimeSettingsTransition(agentName) };
|
|
@@ -6261,26 +7287,101 @@ export class AgentFramework {
|
|
|
6261
7287
|
if (input.transition_pace_tokens !== undefined) {
|
|
6262
7288
|
patch.transitionPaceTokens = Number(input.transition_pace_tokens);
|
|
6263
7289
|
}
|
|
6264
|
-
|
|
7290
|
+
if (input.same_round_think_text_policy !== undefined) {
|
|
7291
|
+
patch.sameRoundThinkTextPolicy = input.same_round_think_text_policy as AgentRuntimeSettingsPatch['sameRoundThinkTextPolicy'];
|
|
7292
|
+
}
|
|
7293
|
+
// Route extension-owned keys to their modules; apply the core patch
|
|
7294
|
+
// only when it touches core keys (an extension-only update must not
|
|
7295
|
+
// disturb a converging budget transition).
|
|
7296
|
+
const extResults: Record<string, unknown> = {};
|
|
7297
|
+
for (const ext of extensions.values()) {
|
|
7298
|
+
const slice: Record<string, unknown> = {};
|
|
7299
|
+
for (const key of ext.keys) {
|
|
7300
|
+
if (input[key] !== undefined) slice[key] = input[key];
|
|
7301
|
+
}
|
|
7302
|
+
if (Object.keys(slice).length > 0) {
|
|
7303
|
+
Object.assign(extResults, ext.update(agentName, slice));
|
|
7304
|
+
}
|
|
7305
|
+
}
|
|
7306
|
+
const coreTouched = Object.keys(patch).length > 0;
|
|
7307
|
+
const core = coreTouched
|
|
7308
|
+
? this.updateAgentRuntimeSettings(agentName, patch)
|
|
7309
|
+
: this.getAgentRuntimeSettings(agentName);
|
|
7310
|
+
result = {
|
|
7311
|
+
success: true,
|
|
7312
|
+
data: {
|
|
7313
|
+
...core,
|
|
7314
|
+
...extGet(),
|
|
7315
|
+
...extResults,
|
|
7316
|
+
...(patch.sameRoundThinkTextPolicy !== undefined
|
|
7317
|
+
? {
|
|
7318
|
+
sameRoundThinkTextPolicyUpdateNote:
|
|
7319
|
+
'Stored now; applies to provider think routing and description beginning with the next inference.',
|
|
7320
|
+
}
|
|
7321
|
+
: {}),
|
|
7322
|
+
},
|
|
7323
|
+
};
|
|
6265
7324
|
break;
|
|
6266
7325
|
}
|
|
6267
7326
|
case 'reset': {
|
|
6268
7327
|
let keys: Array<keyof AgentRuntimeSettingsPatch> | undefined;
|
|
7328
|
+
const extResetKeys = new Map<AgentSettingsExtension, string[]>();
|
|
7329
|
+
const resetsAllSettings = input.settings === undefined;
|
|
6269
7330
|
if (input.settings !== undefined) {
|
|
6270
7331
|
if (!Array.isArray(input.settings)) throw new Error('reset `settings` must be an array');
|
|
6271
7332
|
const names: Record<string, keyof AgentRuntimeSettingsPatch> = {
|
|
6272
7333
|
context_budget_tokens: 'contextBudgetTokens',
|
|
6273
7334
|
tail_tokens: 'tailTokens',
|
|
6274
7335
|
transition_pace_tokens: 'transitionPaceTokens',
|
|
7336
|
+
same_round_think_text_policy: 'sameRoundThinkTextPolicy',
|
|
6275
7337
|
};
|
|
6276
|
-
keys =
|
|
6277
|
-
|
|
6278
|
-
|
|
7338
|
+
keys = [];
|
|
7339
|
+
for (const name of input.settings) {
|
|
7340
|
+
if (typeof name === 'string' && names[name]) {
|
|
7341
|
+
keys.push(names[name]);
|
|
7342
|
+
continue;
|
|
6279
7343
|
}
|
|
6280
|
-
|
|
6281
|
-
|
|
7344
|
+
const owner = [...extensions.values()].find(
|
|
7345
|
+
(ext) => typeof name === 'string' && ext.keys.includes(name),
|
|
7346
|
+
);
|
|
7347
|
+
if (!owner) throw new Error(`Unknown reset setting: ${String(name)}`);
|
|
7348
|
+
const list = extResetKeys.get(owner) ?? [];
|
|
7349
|
+
list.push(name as string);
|
|
7350
|
+
extResetKeys.set(owner, list);
|
|
7351
|
+
}
|
|
7352
|
+
if (keys.length === 0) keys = undefined;
|
|
7353
|
+
}
|
|
7354
|
+
const touchedSameRoundThinkTextPolicy =
|
|
7355
|
+
resetsAllSettings || keys?.includes('sameRoundThinkTextPolicy') === true;
|
|
7356
|
+
const extResults: Record<string, unknown> = {};
|
|
7357
|
+
if (input.settings === undefined) {
|
|
7358
|
+
// Reset-all covers extensions too.
|
|
7359
|
+
for (const ext of extensions.values()) {
|
|
7360
|
+
if (ext.reset) Object.assign(extResults, ext.reset(agentName));
|
|
7361
|
+
}
|
|
7362
|
+
} else {
|
|
7363
|
+
for (const [ext, list] of extResetKeys) {
|
|
7364
|
+
if (ext.reset) Object.assign(extResults, ext.reset(agentName, list));
|
|
7365
|
+
}
|
|
6282
7366
|
}
|
|
6283
|
-
|
|
7367
|
+
const coreTouched = input.settings === undefined || keys !== undefined;
|
|
7368
|
+
const core = coreTouched
|
|
7369
|
+
? this.resetAgentRuntimeSettings(agentName, keys)
|
|
7370
|
+
: this.getAgentRuntimeSettings(agentName);
|
|
7371
|
+
result = {
|
|
7372
|
+
success: true,
|
|
7373
|
+
data: {
|
|
7374
|
+
...core,
|
|
7375
|
+
...extGet(),
|
|
7376
|
+
...extResults,
|
|
7377
|
+
...(touchedSameRoundThinkTextPolicy
|
|
7378
|
+
? {
|
|
7379
|
+
sameRoundThinkTextPolicyUpdateNote:
|
|
7380
|
+
'Reset now; restored provider think routing and description apply beginning with the next inference.',
|
|
7381
|
+
}
|
|
7382
|
+
: {}),
|
|
7383
|
+
},
|
|
7384
|
+
};
|
|
6284
7385
|
break;
|
|
6285
7386
|
}
|
|
6286
7387
|
default:
|