@animalabs/agent-framework 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/agent.d.ts +2 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +3 -0
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +76 -4
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +933 -151
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +62 -2
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +56 -2
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/index.d.ts +1 -1
- package/dist/src/gate/index.d.ts.map +1 -1
- package/dist/src/gate/index.js +1 -1
- package/dist/src/gate/index.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +84 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +160 -4
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +5 -1
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/tool-image-ledger.d.ts +175 -0
- package/dist/src/tool-image-ledger.d.ts.map +1 -0
- package/dist/src/tool-image-ledger.js +257 -0
- package/dist/src/tool-image-ledger.js.map +1 -0
- package/dist/src/tool-result-history.d.ts +15 -2
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +16 -9
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/tool-wrapper-prose-guard.d.ts +8 -0
- package/dist/src/tool-wrapper-prose-guard.d.ts.map +1 -0
- package/dist/src/tool-wrapper-prose-guard.js +37 -0
- package/dist/src/tool-wrapper-prose-guard.js.map +1 -0
- package/dist/src/tune-out/coordinator.d.ts +144 -0
- package/dist/src/tune-out/coordinator.d.ts.map +1 -0
- package/dist/src/tune-out/coordinator.js +444 -0
- package/dist/src/tune-out/coordinator.js.map +1 -0
- package/dist/src/tune-out/tools.d.ts +43 -0
- package/dist/src/tune-out/tools.d.ts.map +1 -0
- package/dist/src/tune-out/tools.js +93 -0
- package/dist/src/tune-out/tools.js.map +1 -0
- package/dist/src/types/agent.d.ts +17 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +6 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/test/channel-incoming-targeting.test.d.ts +2 -0
- package/dist/test/channel-incoming-targeting.test.d.ts.map +1 -0
- package/dist/test/channel-incoming-targeting.test.js +81 -0
- package/dist/test/channel-incoming-targeting.test.js.map +1 -0
- package/dist/test/event-gate.test.js +84 -1
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/per-agent-delivery.test.d.ts +2 -0
- package/dist/test/per-agent-delivery.test.d.ts.map +1 -0
- package/dist/test/per-agent-delivery.test.js +108 -0
- package/dist/test/per-agent-delivery.test.js.map +1 -0
- package/dist/test/puppet-tool-call.test.js +1 -0
- package/dist/test/puppet-tool-call.test.js.map +1 -1
- package/dist/test/save-recent-image.test.d.ts +26 -0
- package/dist/test/save-recent-image.test.d.ts.map +1 -0
- package/dist/test/save-recent-image.test.js +707 -0
- package/dist/test/save-recent-image.test.js.map +1 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts +2 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts.map +1 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.js +485 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +1 -0
- package/dist/test/tool-wrapper-prose-guard.test.d.ts +2 -0
- package/dist/test/tool-wrapper-prose-guard.test.d.ts.map +1 -0
- package/dist/test/tool-wrapper-prose-guard.test.js +34 -0
- package/dist/test/tool-wrapper-prose-guard.test.js.map +1 -0
- package/dist/test/tune-out-e2e.test.d.ts +2 -0
- package/dist/test/tune-out-e2e.test.d.ts.map +1 -0
- package/dist/test/tune-out-e2e.test.js +142 -0
- package/dist/test/tune-out-e2e.test.js.map +1 -0
- package/dist/test/tune-out-gate-composition.test.d.ts +2 -0
- package/dist/test/tune-out-gate-composition.test.d.ts.map +1 -0
- package/dist/test/tune-out-gate-composition.test.js +221 -0
- package/dist/test/tune-out-gate-composition.test.js.map +1 -0
- package/dist/test/tune-out-lifecycle.test.d.ts +2 -0
- package/dist/test/tune-out-lifecycle.test.d.ts.map +1 -0
- package/dist/test/tune-out-lifecycle.test.js +121 -0
- package/dist/test/tune-out-lifecycle.test.js.map +1 -0
- package/dist/test/turn-trigger-provenance.test.js +17 -0
- package/dist/test/turn-trigger-provenance.test.js.map +1 -1
- package/dist/test/workspace-read-image.test.js +8 -0
- package/dist/test/workspace-read-image.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +3 -0
- package/src/framework.ts +1051 -154
- package/src/gate/event-gate.ts +99 -6
- package/src/gate/index.ts +1 -1
- package/src/index.ts +1 -1
- package/src/mcpl/channel-registry.ts +235 -7
- package/src/modules/workspace/index.ts +5 -1
- package/src/tool-image-ledger.ts +334 -0
- package/src/tool-result-history.ts +30 -9
- package/src/tool-wrapper-prose-guard.ts +38 -0
- package/src/tune-out/coordinator.ts +548 -0
- package/src/tune-out/tools.ts +129 -0
- package/src/types/agent.ts +18 -0
- package/src/types/framework.ts +7 -0
package/src/framework.ts
CHANGED
|
@@ -5,8 +5,10 @@ import { appendFileSync, mkdirSync } from 'node:fs';
|
|
|
5
5
|
import { JsStore } from '@animalabs/chronicle';
|
|
6
6
|
import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane';
|
|
7
7
|
import { MembraneError } from '@animalabs/membrane';
|
|
8
|
-
import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager';
|
|
8
|
+
import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy } from '@animalabs/context-manager';
|
|
9
9
|
import type { CacheWireReceipt } from './kv-unified-wire.js';
|
|
10
|
+
import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES, type SubconsciousConfig } from './tune-out/tools.js';
|
|
11
|
+
import { TuneOutCoordinator, TUNE_OUT_DEFAULTS } from './tune-out/coordinator.js';
|
|
10
12
|
import type {
|
|
11
13
|
MessageId,
|
|
12
14
|
MessageMetadata,
|
|
@@ -67,6 +69,7 @@ import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
|
|
|
67
69
|
import { PushHandler, type McplPushEvent } from './mcpl/push-handler.js';
|
|
68
70
|
import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
|
|
69
71
|
import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
|
|
72
|
+
import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js';
|
|
70
73
|
import { InferenceRouter } from './mcpl/inference-router.js';
|
|
71
74
|
import { ChannelRegistry, type ChannelToolOrigin } from './mcpl/channel-registry.js';
|
|
72
75
|
import { ConversationRouter } from './mcpl/conversation-router.js';
|
|
@@ -77,6 +80,15 @@ import {
|
|
|
77
80
|
truncateForHistory,
|
|
78
81
|
DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS,
|
|
79
82
|
} from './tool-result-history.js';
|
|
83
|
+
import {
|
|
84
|
+
TOOL_IMAGE_REF_RE,
|
|
85
|
+
ToolImageLedger,
|
|
86
|
+
formatPreservedImageSlots,
|
|
87
|
+
parseImagePlaceholders,
|
|
88
|
+
sha256Hex,
|
|
89
|
+
splitPreservingImageSlots,
|
|
90
|
+
type ParsedImagePlaceholder,
|
|
91
|
+
} from './tool-image-ledger.js';
|
|
80
92
|
import { randomUUID } from 'node:crypto';
|
|
81
93
|
import { PyRunner, buildInjectedTools } from './code-execution/py-runner.js';
|
|
82
94
|
import {
|
|
@@ -788,6 +800,16 @@ export class AgentFramework {
|
|
|
788
800
|
private frameworkCancelledStreams: Map<string, 'turn_ended' | 'budget_restart'> = new Map();
|
|
789
801
|
/** Active runEphemeralToCompletion runs, keyed by agent name. */
|
|
790
802
|
private ephemeralRuns: Map<string, EphemeralRun> = new Map();
|
|
803
|
+
/** Ephemeral namespaces/names are single-generation for this framework
|
|
804
|
+
* lifetime. Reusing one while an old stream drains would let name-keyed
|
|
805
|
+
* liveness/settlement state cross generations. */
|
|
806
|
+
private usedEphemeralAgentNames: Set<string> = new Set();
|
|
807
|
+
/** One-shot generation tickets minted by createEphemeralAgent. */
|
|
808
|
+
private ephemeralCandidates: WeakMap<Agent, ContextManager> = new WeakMap();
|
|
809
|
+
/** Physical ephemeral frames deliberately disposed by their run watchdog/caller.
|
|
810
|
+
* The name is single-generation, so these frames still own their terminal
|
|
811
|
+
* typing/outgoing close even after runEphemeralToCompletion deregisters them. */
|
|
812
|
+
private disposedEphemeralFrames: WeakSet<Agent> = new WeakSet();
|
|
791
813
|
/** Per-agent count of consecutive exhausted inferences (reset on any success).
|
|
792
814
|
* Drives hard-down escalation — see noteInferenceExhausted. */
|
|
793
815
|
private consecutiveInferenceFailures: Map<string, number> = new Map();
|
|
@@ -841,7 +863,23 @@ export class AgentFramework {
|
|
|
841
863
|
|
|
842
864
|
// Messages deferred while an agent is waiting_for_tools (to preserve
|
|
843
865
|
// tool_use → tool_result adjacency required by the Anthropic API).
|
|
844
|
-
|
|
866
|
+
/** The subconscious resident's registry name (issue #77), or null. */
|
|
867
|
+
private subconsciousAgentName: string | null = null;
|
|
868
|
+
/** Its windowed strategy — the coordinator moves the anchor on tune-out entry. */
|
|
869
|
+
private subconsciousStrategy: WindowedPassthroughStrategy | null = null;
|
|
870
|
+
/** Its config (speak_in_channel gate, voice block provenance). */
|
|
871
|
+
private subconsciousConfig: SubconsciousConfig | null = null;
|
|
872
|
+
/** Tune-out coordinator (issue #77); non-null iff subconscious + channels. */
|
|
873
|
+
private tuneOutCoordinator: TuneOutCoordinator | null = null;
|
|
874
|
+
|
|
875
|
+
private deferredMessages: Array<{
|
|
876
|
+
participant: string;
|
|
877
|
+
content: ContentBlock[];
|
|
878
|
+
metadata?: MessageMetadata;
|
|
879
|
+
/** Delivery target (registry name). Absent = the primary agent —
|
|
880
|
+
* the historical behavior of every existing caller. */
|
|
881
|
+
forAgent?: string;
|
|
882
|
+
}> = [];
|
|
845
883
|
|
|
846
884
|
// Turn-alive markers (2026-07-31 Mythos phantom-skip incident): agentName →
|
|
847
885
|
// token of the turn currently in progress. Set at startAgentStream ENTRY —
|
|
@@ -873,6 +911,10 @@ export class AgentFramework {
|
|
|
873
911
|
getActiveTurnTrigger(agentName: string): InferenceRequest | undefined {
|
|
874
912
|
return this.activeTurnTriggers.get(agentName);
|
|
875
913
|
}
|
|
914
|
+
|
|
915
|
+
/** Structured calls executed in the current logical turn. Unlike the
|
|
916
|
+
* per-stream local this survives framework retries and budget restarts. */
|
|
917
|
+
private logicalTurnToolCalls: WeakMap<Agent, { turnToken: number; count: number }> = new WeakMap();
|
|
876
918
|
private nextTurnToken = 1;
|
|
877
919
|
|
|
878
920
|
// Undo/redo state
|
|
@@ -927,6 +969,11 @@ export class AgentFramework {
|
|
|
927
969
|
* framework state like the core runtime settings and restored at create
|
|
928
970
|
* (antra + Sol, 08-06); reset clears it back to the residence default. */
|
|
929
971
|
private toolResultInlineMaxCharsOverride: Map<string, number> = new Map();
|
|
972
|
+
/** Per-agent retention of tool-result images with provenance (issue
|
|
973
|
+
* #104). In-memory and bounded by design: history keeps only the
|
|
974
|
+
* placeholder; this is what lets `save_recent_image` reach the bytes the
|
|
975
|
+
* resident actually saw — or fail at that exact index when they're gone. */
|
|
976
|
+
private toolImageLedgers: Map<string, ToolImageLedger> = new Map();
|
|
930
977
|
/** Durable residence-configured inline cap from
|
|
931
978
|
* FrameworkConfig.toolResultInlineMaxChars; null → house default. */
|
|
932
979
|
private toolResultInlineMaxCharsConfig: number | null = null;
|
|
@@ -1131,6 +1178,13 @@ export class AgentFramework {
|
|
|
1131
1178
|
await framework.createAgent(agentConfig);
|
|
1132
1179
|
}
|
|
1133
1180
|
|
|
1181
|
+
// The subconscious resident (issue #77) registers after the residents so
|
|
1182
|
+
// it can never become primary; it is excluded from every broadcast
|
|
1183
|
+
// fan-out and woken only by the tune-out coordinator's explicit pushes.
|
|
1184
|
+
if (config.subconscious?.enabled) {
|
|
1185
|
+
await framework.createSubconsciousAgent(config.subconscious);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1134
1188
|
// Finish any branch-local suppression interrupted after Chronicle switched
|
|
1135
1189
|
// branches. This runs before modules, MCPL connections, or inbound traffic.
|
|
1136
1190
|
await framework.resumePreparedDiscordSuppressions();
|
|
@@ -1180,11 +1234,21 @@ export class AgentFramework {
|
|
|
1180
1234
|
initialConfig: config.gate.config,
|
|
1181
1235
|
privilegedUsersPath: config.gate.privilegedUsersPath,
|
|
1182
1236
|
emitTrace: (e) => framework.emitTrace(e as { type: TraceEvent['type']; [key: string]: unknown }),
|
|
1183
|
-
addMessage: (p, c, m) => framework.addMessage(p, c, m as MessageMetadata),
|
|
1184
|
-
requestInference: (agentName, reason, source) => {
|
|
1185
|
-
framework.pendingRequests.push({
|
|
1237
|
+
addMessage: (p, c, m, forAgent) => framework.addMessage(p, c, m as MessageMetadata, forAgent ? { forAgent } : undefined),
|
|
1238
|
+
requestInference: (agentName, reason, source, provenance) => {
|
|
1239
|
+
framework.pendingRequests.push({
|
|
1240
|
+
agentName, reason, source, timestamp: Date.now(),
|
|
1241
|
+
// gate-requested wakes carry where/who (EventGate wakeProvenance)
|
|
1242
|
+
// as TELEMETRY fields — the host's stamp reads them; the turn's
|
|
1243
|
+
// speech locus (channelId / addressed) is deliberately NOT set
|
|
1244
|
+
// here, so a batched wake routes exactly as it did before.
|
|
1245
|
+
...(provenance?.channelId ? { wakeChannelId: provenance.channelId } : {}),
|
|
1246
|
+
...(provenance?.counterparty ? { counterparty: provenance.counterparty } : {}),
|
|
1247
|
+
...(provenance?.at ? { wakeAt: provenance.at } : {}),
|
|
1248
|
+
});
|
|
1186
1249
|
},
|
|
1187
|
-
getAgentNames: () => [...framework.agents.keys()]
|
|
1250
|
+
getAgentNames: () => [...framework.agents.keys()].filter(
|
|
1251
|
+
(n) => n !== framework.subconsciousAgentName),
|
|
1188
1252
|
});
|
|
1189
1253
|
}
|
|
1190
1254
|
|
|
@@ -1247,6 +1311,40 @@ export class AgentFramework {
|
|
|
1247
1311
|
await framework.initializeMcpl(config.mcplServers, config.inferenceRouting);
|
|
1248
1312
|
}
|
|
1249
1313
|
|
|
1314
|
+
// Tune-out coordinator (issue #77): needs both the subconscious resident
|
|
1315
|
+
// and the MCPL channel subsystem; created after both exist.
|
|
1316
|
+
if (framework.subconsciousAgentName && framework.channelRegistry) {
|
|
1317
|
+
framework.tuneOutCoordinator = new TuneOutCoordinator(
|
|
1318
|
+
framework.channelRegistry,
|
|
1319
|
+
framework.mcplServerRegistry!,
|
|
1320
|
+
{
|
|
1321
|
+
addMessage: (participant, content, metadata, forAgent) =>
|
|
1322
|
+
framework.addMessage(participant, content, metadata as MessageMetadata,
|
|
1323
|
+
forAgent ? { forAgent } : undefined),
|
|
1324
|
+
requestInference: (agentName, reason, source) => {
|
|
1325
|
+
framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
|
|
1326
|
+
},
|
|
1327
|
+
subconsciousName: () => framework.subconsciousAgentName,
|
|
1328
|
+
primaryName: () => framework.primaryAgentName,
|
|
1329
|
+
getStoredMessages: () => {
|
|
1330
|
+
const primary = framework.primaryAgentName
|
|
1331
|
+
? framework.agents.get(framework.primaryAgentName) : undefined;
|
|
1332
|
+
return primary ? primary.getContextManager().getAllMessages() : [];
|
|
1333
|
+
},
|
|
1334
|
+
currentSequence: () => framework.store.currentSequence(),
|
|
1335
|
+
setSubconsciousAnchor: (sequence) =>
|
|
1336
|
+
framework.subconsciousStrategy?.setAnchor(sequence),
|
|
1337
|
+
isForkBound: (channelId) =>
|
|
1338
|
+
[...framework.conversationAgentHomes.values()].includes(channelId),
|
|
1339
|
+
isPrivilegedAuthor: (authorId) =>
|
|
1340
|
+
framework.eventGate?.isPrivilegedUser(authorId) ?? false,
|
|
1341
|
+
allowChannelSpeech: () => !!framework.subconsciousConfig?.allowChannelSpeech,
|
|
1342
|
+
emitTrace: (e) => framework.emitTrace(e as never),
|
|
1343
|
+
},
|
|
1344
|
+
);
|
|
1345
|
+
framework.tuneOutCoordinator.resumeActiveEpochs();
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1250
1348
|
// Diagnostics: `kill -USR2 <pid>` dumps live wake/inference state to stderr
|
|
1251
1349
|
// (journal) without a restart — for catching the wake-wedge on the running
|
|
1252
1350
|
// process. Shows the gate's `inferring` set + buffered-event count (the
|
|
@@ -1316,6 +1414,7 @@ export class AgentFramework {
|
|
|
1316
1414
|
this.running = false;
|
|
1317
1415
|
this.providerAdmissionClosed = true;
|
|
1318
1416
|
this.queue.close();
|
|
1417
|
+
this.tuneOutCoordinator?.stop();
|
|
1319
1418
|
|
|
1320
1419
|
// Kill running code_execution scripts before cancelling streams: a
|
|
1321
1420
|
// zombie script must not keep firing side-effectful tool calls into a
|
|
@@ -1335,6 +1434,9 @@ export class AgentFramework {
|
|
|
1335
1434
|
record.runner.dispose();
|
|
1336
1435
|
}
|
|
1337
1436
|
this.backgroundScripts.clear();
|
|
1437
|
+
// Retained tool images are per-process by design; a retained stopped
|
|
1438
|
+
// framework must not keep up to the whole ledger budget referenced.
|
|
1439
|
+
this.toolImageLedgers.clear();
|
|
1338
1440
|
|
|
1339
1441
|
// A stopped host must never hang behind its own cooldown.
|
|
1340
1442
|
this.cancelProviderAdmission();
|
|
@@ -1751,7 +1853,12 @@ export class AgentFramework {
|
|
|
1751
1853
|
*/
|
|
1752
1854
|
getAllTools(): import('./types/index.js').ToolDefinition[] {
|
|
1753
1855
|
const moduleTools = this.moduleRegistry.getAllTools();
|
|
1754
|
-
|
|
1856
|
+
// Copy: getChannelTools() returns the registry's shared definitions
|
|
1857
|
+
// array; pushing onto it would append another tune_out on every call.
|
|
1858
|
+
const channelTools = [...(this.channelRegistry?.getChannelTools() ?? [])];
|
|
1859
|
+
if (this.tuneOutCoordinator) {
|
|
1860
|
+
channelTools.push(AgentFramework.TUNE_OUT_TOOL);
|
|
1861
|
+
}
|
|
1755
1862
|
const gateTools = this.eventGate
|
|
1756
1863
|
? [
|
|
1757
1864
|
this.eventGate.getToolDefinition(),
|
|
@@ -2001,6 +2108,20 @@ export class AgentFramework {
|
|
|
2001
2108
|
agentName: string,
|
|
2002
2109
|
snapshot?: InferenceToolSnapshot,
|
|
2003
2110
|
): import('./types/index.js').ToolDefinition[] {
|
|
2111
|
+
// The subconscious gets its own small surface (plus think/skip_reply/
|
|
2112
|
+
// end_turn), not the residents' tool board: no channel lifecycle, no
|
|
2113
|
+
// gate rules, no workspace — it observes, judges, and reports.
|
|
2114
|
+
if (agentName === this.subconsciousAgentName) {
|
|
2115
|
+
const basics = this.getAllTools()
|
|
2116
|
+
.filter((t) => t.name === 'think' || t.name === 'skip_reply' || t.name === 'end_turn')
|
|
2117
|
+
.map((t) => t.name === 'think'
|
|
2118
|
+
? this.buildThinkTool(
|
|
2119
|
+
snapshot?.sameRoundThinkTextPolicy
|
|
2120
|
+
?? this.getAgentRuntimeSettings(agentName).sameRoundThinkTextPolicy,
|
|
2121
|
+
)
|
|
2122
|
+
: t);
|
|
2123
|
+
return [...SUBCONSCIOUS_TOOLS, ...basics];
|
|
2124
|
+
}
|
|
2004
2125
|
return this.getAllTools().map((tool) => {
|
|
2005
2126
|
if (tool.name === 'think') {
|
|
2006
2127
|
return this.buildThinkTool(
|
|
@@ -2421,25 +2542,38 @@ export class AgentFramework {
|
|
|
2421
2542
|
contextManager: ContextManager;
|
|
2422
2543
|
cleanup: () => void;
|
|
2423
2544
|
}> {
|
|
2424
|
-
|
|
2545
|
+
// Names are Chronicle namespaces and generation identities. Reserve before
|
|
2546
|
+
// opening the namespace so a second creation cannot append to an earlier
|
|
2547
|
+
// generation before runEphemeralToCompletion has a chance to reject it.
|
|
2548
|
+
if (this.usedEphemeralAgentNames.has(config.name) || this.agents.has(config.name)) {
|
|
2549
|
+
throw new Error(`Ephemeral agent name \"${config.name}\" is already registered or has been used in this framework`);
|
|
2550
|
+
}
|
|
2551
|
+
this.usedEphemeralAgentNames.add(config.name);
|
|
2552
|
+
try {
|
|
2553
|
+
const namespace = `subagent/${config.name}`;
|
|
2425
2554
|
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2555
|
+
const contextManager = await ContextManager.open({
|
|
2556
|
+
store: this.store,
|
|
2557
|
+
namespace,
|
|
2558
|
+
isolate: true,
|
|
2559
|
+
strategy: config.strategy ?? new PassthroughStrategy(),
|
|
2560
|
+
membrane: this.membrane,
|
|
2561
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
2562
|
+
});
|
|
2434
2563
|
|
|
2435
|
-
|
|
2564
|
+
const agent = new Agent(config, contextManager, this.membrane);
|
|
2565
|
+
this.ephemeralCandidates.set(agent, contextManager);
|
|
2436
2566
|
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2567
|
+
const cleanup = () => {
|
|
2568
|
+
// Don't close the store — it's shared. Just release the CM.
|
|
2569
|
+
// Data persists in the store under the namespace for investigation.
|
|
2570
|
+
};
|
|
2441
2571
|
|
|
2442
|
-
|
|
2572
|
+
return { agent, contextManager, cleanup };
|
|
2573
|
+
} catch (error) {
|
|
2574
|
+
this.usedEphemeralAgentNames.delete(config.name);
|
|
2575
|
+
throw error;
|
|
2576
|
+
}
|
|
2443
2577
|
}
|
|
2444
2578
|
|
|
2445
2579
|
private createDeferred<T>(): Deferred<T> {
|
|
@@ -2462,6 +2596,17 @@ export class AgentFramework {
|
|
|
2462
2596
|
run.lastActivity = Date.now();
|
|
2463
2597
|
}
|
|
2464
2598
|
|
|
2599
|
+
/** Record structured calls for the current logical turn. Ignore late stream
|
|
2600
|
+
* events after an ephemeral/conversation agent has been disposed. */
|
|
2601
|
+
private recordLogicalTurnToolCalls(agent: Agent, turnToken: number, count: number): void {
|
|
2602
|
+
if (count <= 0 || this.agents.get(agent.name) !== agent) return;
|
|
2603
|
+
const state = this.logicalTurnToolCalls.get(agent);
|
|
2604
|
+
// The Agent object is the generation: a late stream from a disposed
|
|
2605
|
+
// ephemeral/conversation fork cannot contaminate a same-name successor.
|
|
2606
|
+
if (!state || state.turnToken !== turnToken) return;
|
|
2607
|
+
this.logicalTurnToolCalls.set(agent, { turnToken, count: state.count + count });
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2465
2610
|
private recordEphemeralToolCalls(agentName: string, count: number): void {
|
|
2466
2611
|
const run = this.ephemeralRuns.get(agentName);
|
|
2467
2612
|
if (!run) return;
|
|
@@ -2505,7 +2650,18 @@ export class AgentFramework {
|
|
|
2505
2650
|
contextManager: ContextManager,
|
|
2506
2651
|
watchdogs?: { startupTimeoutMs?: number; idleTimeoutMs?: number; idlePollMs?: number },
|
|
2507
2652
|
): Promise<{ speech: string; toolCallsCount: number }> {
|
|
2508
|
-
//
|
|
2653
|
+
// Only a fresh object returned by createEphemeralAgent may enter this path.
|
|
2654
|
+
// Never overwrite a resident/conversation owner or a concurrent run: their
|
|
2655
|
+
// cleanup is name-keyed and could cancel/deregister the legitimate owner.
|
|
2656
|
+
if (this.ephemeralCandidates.get(agent) !== contextManager) {
|
|
2657
|
+
throw new Error(`Ephemeral agent "${agent.name}" has no fresh generation ticket from this framework`);
|
|
2658
|
+
}
|
|
2659
|
+
// Consume before any await/registration: the exact (Agent, ContextManager)
|
|
2660
|
+
// generation is one-shot even if startup later fails.
|
|
2661
|
+
this.ephemeralCandidates.delete(agent);
|
|
2662
|
+
if (this.agents.has(agent.name) || this.ephemeralRuns.has(agent.name)) {
|
|
2663
|
+
throw new Error(`Ephemeral agent "${agent.name}" is already registered or running`);
|
|
2664
|
+
}
|
|
2509
2665
|
this.agents.set(agent.name, agent);
|
|
2510
2666
|
const run: EphemeralRun = {
|
|
2511
2667
|
settle: this.createDeferred<AgentSettleResult>(),
|
|
@@ -2570,8 +2726,26 @@ export class AgentFramework {
|
|
|
2570
2726
|
} finally {
|
|
2571
2727
|
if (startupWatchdog) clearTimeout(startupWatchdog);
|
|
2572
2728
|
if (completionWatchdog) clearInterval(completionWatchdog);
|
|
2573
|
-
|
|
2574
|
-
this
|
|
2729
|
+
// Mark before cancellation: an adapter may synchronously settle its
|
|
2730
|
+
// iterator, and this physical frame still owns terminal typing/outgoing
|
|
2731
|
+
// closure even though the ephemeral name is about to be deregistered.
|
|
2732
|
+
this.disposedEphemeralFrames.add(agent);
|
|
2733
|
+
// Cancel before deregistration. Adapters may still yield a queued event;
|
|
2734
|
+
// driveStream's Agent-identity check above discards it before dispatch.
|
|
2735
|
+
agent.cancelStream();
|
|
2736
|
+
if (this.ephemeralRuns.get(agent.name) === run) this.ephemeralRuns.delete(agent.name);
|
|
2737
|
+
if (this.agents.get(agent.name) === agent) {
|
|
2738
|
+
// Exact-generation disposal owns these name-keyed entries. Remove them
|
|
2739
|
+
// before deregistration; the late driveStream finally will see the
|
|
2740
|
+
// generation mismatch and leave any future owner untouched.
|
|
2741
|
+
this.activeStreams.delete(agent.name);
|
|
2742
|
+
this.pendingAssistantBlocks.delete(agent.name);
|
|
2743
|
+
// The late stream finalizer intentionally refuses name-keyed cleanup once
|
|
2744
|
+
// deregistered, so disposal must release EventGate liveness itself.
|
|
2745
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
2746
|
+
this.agents.delete(agent.name);
|
|
2747
|
+
}
|
|
2748
|
+
this.toolImageLedgers.delete(agent.name);
|
|
2575
2749
|
// Spawn-and-dispose bookkeeping (main, d453165/fee96a7): without this,
|
|
2576
2750
|
// ephemeral agents leave checkpoint-tree keys and diagnostics map
|
|
2577
2751
|
// entries behind for the life of the store/session.
|
|
@@ -2583,6 +2757,7 @@ export class AgentFramework {
|
|
|
2583
2757
|
// of the map, and a late driveStream finally token-match no-ops.
|
|
2584
2758
|
this.activeTurnTokens.delete(agent.name);
|
|
2585
2759
|
this.activeTurnTriggers.delete(agent.name);
|
|
2760
|
+
this.logicalTurnToolCalls.delete(agent);
|
|
2586
2761
|
}
|
|
2587
2762
|
}
|
|
2588
2763
|
|
|
@@ -2597,6 +2772,48 @@ export class AgentFramework {
|
|
|
2597
2772
|
* Query inference logs.
|
|
2598
2773
|
* Returns entries with summary info (doesn't resolve blobs).
|
|
2599
2774
|
*/
|
|
2775
|
+
/** Tune-out (issue #77): divert a channel to your subconscious. Present
|
|
2776
|
+
* only when the subconscious resident is configured. */
|
|
2777
|
+
private static readonly TUNE_OUT_TOOL: import('./types/index.js').ToolDefinition = {
|
|
2778
|
+
name: 'tune_out',
|
|
2779
|
+
description:
|
|
2780
|
+
'Divert a channel to your subconscious for a while: its traffic stops ' +
|
|
2781
|
+
'entering your context and stops waking you. Your subconscious watches ' +
|
|
2782
|
+
'it instead — summarizing on a cadence in its own voice, judging ' +
|
|
2783
|
+
'whether mentions merit interrupting, and able to cancel. People who ' +
|
|
2784
|
+
'mention you get an automatic reaction so they know you saw nothing. ' +
|
|
2785
|
+
'Cancelling (mode "cancel") delivers the diverted backlog. The ' +
|
|
2786
|
+
'tune-out auto-cancels after maxWakes mention-bursts.',
|
|
2787
|
+
inputSchema: {
|
|
2788
|
+
type: 'object',
|
|
2789
|
+
properties: {
|
|
2790
|
+
channelId: { type: 'string' },
|
|
2791
|
+
mode: { type: 'string', enum: ['enter', 'cancel'], description: 'Default: enter.' },
|
|
2792
|
+
cadenceSeconds: {
|
|
2793
|
+
type: 'number',
|
|
2794
|
+
description: `Summary cadence in seconds (default ${TUNE_OUT_DEFAULTS.cadenceSeconds}; floors at 30).`,
|
|
2795
|
+
},
|
|
2796
|
+
backlogCap: {
|
|
2797
|
+
type: 'number',
|
|
2798
|
+
description:
|
|
2799
|
+
`Max raw messages delivered at cancel (default ${TUNE_OUT_DEFAULTS.backlogCap}). ` +
|
|
2800
|
+
'0 = deliver no raw backlog at all: your subconscious\'s report is the whole delivery.',
|
|
2801
|
+
},
|
|
2802
|
+
maxWakes: {
|
|
2803
|
+
type: 'number',
|
|
2804
|
+
description: `Wake budget before auto-cancel (default ${TUNE_OUT_DEFAULTS.maxWakes}; floors at 1).`,
|
|
2805
|
+
},
|
|
2806
|
+
durationSeconds: {
|
|
2807
|
+
type: 'number',
|
|
2808
|
+
description:
|
|
2809
|
+
'Optional bounded duration: the tune-out auto-cancels (backlog ' +
|
|
2810
|
+
'delivered) after this many seconds; floors at 60. Unset = until cancelled.',
|
|
2811
|
+
},
|
|
2812
|
+
},
|
|
2813
|
+
required: ['channelId'],
|
|
2814
|
+
},
|
|
2815
|
+
};
|
|
2816
|
+
|
|
2600
2817
|
/** Synthesized sleep/wake tool definitions (present when a gate is wired). */
|
|
2601
2818
|
private static readonly SLEEP_TOOLS: import('./types/index.js').ToolDefinition[] = [
|
|
2602
2819
|
{
|
|
@@ -2789,11 +3006,18 @@ export class AgentFramework {
|
|
|
2789
3006
|
name: 'save_recent_image',
|
|
2790
3007
|
description:
|
|
2791
3008
|
'Save one or more recent images from your own context to workspace files. ' +
|
|
2792
|
-
'Images are counted back from the most recent (index 0)
|
|
3009
|
+
'Images are counted back from the most recent (index 0), across BOTH ' +
|
|
3010
|
+
'attachments in messages and images returned by tools (their history ' +
|
|
3011
|
+
'placeholder shows a ref like `[image: image/png, ~691KB, ref img_k7x3q2_7]`; pass ' +
|
|
3012
|
+
'`ref` to save that exact image regardless of position). A single image is ' +
|
|
2793
3013
|
'written to `path` as given (e.g. "project/photos/cat.png"); when `count` > 1 ' +
|
|
2794
3014
|
'the range index..index+count-1 is saved with numeric suffixes ' +
|
|
2795
|
-
'("cat-0.png", "cat-1.png", …; 0 = the newest of the range).
|
|
2796
|
-
'
|
|
3015
|
+
'("cat-0.png", "cat-1.png", …; 0 = the newest of the range). If the image at ' +
|
|
3016
|
+
'the requested position is no longer retained (evicted, or from before a ' +
|
|
3017
|
+
'restart) the call fails and writes nothing — it never substitutes an older ' +
|
|
3018
|
+
'image. When other tool calls in the same batch are still running, the save ' +
|
|
3019
|
+
'waits for them so their images are counted. Receipts carry source tool call, MIME, byte size and SHA-256. Saved ' +
|
|
3020
|
+
'files are visible via workspace tools and the /files/ endpoint.',
|
|
2797
3021
|
inputSchema: {
|
|
2798
3022
|
type: 'object',
|
|
2799
3023
|
properties: {
|
|
@@ -2809,6 +3033,12 @@ export class AgentFramework {
|
|
|
2809
3033
|
type: 'number',
|
|
2810
3034
|
description: 'How many images to save, starting at `index` and going further back. Default 1.',
|
|
2811
3035
|
},
|
|
3036
|
+
ref: {
|
|
3037
|
+
type: 'string',
|
|
3038
|
+
description:
|
|
3039
|
+
'Save a specific tool-result image by its history ref (e.g. "img_k7x3q2_7", as shown in its placeholder in your context) instead of by position. ' +
|
|
3040
|
+
'Mutually exclusive with `index`/`count`.',
|
|
3041
|
+
},
|
|
2812
3042
|
},
|
|
2813
3043
|
required: ['path'],
|
|
2814
3044
|
},
|
|
@@ -4040,6 +4270,15 @@ export class AgentFramework {
|
|
|
4040
4270
|
// streaming agent keeps the original Membrane.
|
|
4041
4271
|
membrane: this.auxiliaryMembraneFor(config.name),
|
|
4042
4272
|
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
4273
|
+
// Tune-out (issue #77): messages stamped at ingestion as diverted
|
|
4274
|
+
// (metadata.tuneOut = { epochId }) never enter a resident's compiled
|
|
4275
|
+
// view — not at emission, not via chunking into the memory pyramid.
|
|
4276
|
+
// The stamp is permanent: after cancel, the backlog arrives as one
|
|
4277
|
+
// appended <tuned-out-backlog> dump instead of retro-inserting into
|
|
4278
|
+
// the timeline (KV-prefix-stable delivery). The stored originals
|
|
4279
|
+
// remain in the shared slot for the subconscious's merged view,
|
|
4280
|
+
// fetch_history, and audit.
|
|
4281
|
+
viewFilter: (message) => !(message.metadata as { tuneOut?: unknown } | undefined)?.tuneOut,
|
|
4043
4282
|
});
|
|
4044
4283
|
|
|
4045
4284
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
@@ -4060,6 +4299,71 @@ export class AgentFramework {
|
|
|
4060
4299
|
return agent;
|
|
4061
4300
|
}
|
|
4062
4301
|
|
|
4302
|
+
/**
|
|
4303
|
+
* Create and register the subconscious resident (issue #77): a persistent
|
|
4304
|
+
* side-agent with an isolated slot (`subconscious/<primary>`) whose
|
|
4305
|
+
* strategy view merges the residents' shared timeline with its own —
|
|
4306
|
+
* including the tuned-out-stamped messages the residents' viewFilter
|
|
4307
|
+
* excludes; the backlog is precisely what it exists to see. Windowed
|
|
4308
|
+
* passthrough, no memory pyramid: its durable output is what it delivers
|
|
4309
|
+
* into the resident's window, which accumulates there.
|
|
4310
|
+
*/
|
|
4311
|
+
private async createSubconsciousAgent(cfg: SubconsciousConfig): Promise<Agent> {
|
|
4312
|
+
const primaryName = this.primaryAgentName;
|
|
4313
|
+
const primaryConfig = primaryName ? this.agentConfigs.get(primaryName) : undefined;
|
|
4314
|
+
if (!primaryName || !primaryConfig) {
|
|
4315
|
+
throw new Error('subconscious requires a primary agent to attend to');
|
|
4316
|
+
}
|
|
4317
|
+
const name = cfg.name ?? 'Subconscious';
|
|
4318
|
+
if (this.agents.has(name)) {
|
|
4319
|
+
throw new Error(`subconscious name "${name}" collides with a registered agent`);
|
|
4320
|
+
}
|
|
4321
|
+
|
|
4322
|
+
const strategy = new WindowedPassthroughStrategy({
|
|
4323
|
+
reAnchorFraction: cfg.reAnchorFraction,
|
|
4324
|
+
// Mirror the principal's per-message truncation ceiling so both see
|
|
4325
|
+
// oversized tool results / attachments under the same policy.
|
|
4326
|
+
maxMessageTokens: primaryConfig.strategy?.maxMessageTokens,
|
|
4327
|
+
});
|
|
4328
|
+
const contextManager = await ContextManager.open({
|
|
4329
|
+
store: this.store,
|
|
4330
|
+
namespace: `subconscious/${primaryName}`,
|
|
4331
|
+
isolate: true,
|
|
4332
|
+
// The residents' shared un-namespaced slot, merged read-only and
|
|
4333
|
+
// UNfiltered (no viewFilter here — stamped messages included).
|
|
4334
|
+
auxiliaryMessageViews: [{}],
|
|
4335
|
+
strategy,
|
|
4336
|
+
membrane: this.membrane,
|
|
4337
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
4338
|
+
});
|
|
4339
|
+
|
|
4340
|
+
// Inherit the principal's inference settings wholesale (fork-template
|
|
4341
|
+
// idiom): reasoning effort (thinking), provider params, token ceilings,
|
|
4342
|
+
// caching, refusal handling, timezone — a same-model side-process must
|
|
4343
|
+
// not silently run under different inference conditions than the
|
|
4344
|
+
// resident it serves. Only identity- and surface-defining fields are
|
|
4345
|
+
// overridden below.
|
|
4346
|
+
const agentConfig: AgentConfig = {
|
|
4347
|
+
...primaryConfig,
|
|
4348
|
+
name,
|
|
4349
|
+
model: cfg.model ?? primaryConfig.model,
|
|
4350
|
+
systemPrompt: cfg.systemPrompt,
|
|
4351
|
+
strategy: undefined, // its CM owns the windowed strategy instance
|
|
4352
|
+
// Prose is never auto-routed: a cadence-triggered turn carries no
|
|
4353
|
+
// locus, and bare prose must not fall through to the default channel.
|
|
4354
|
+
// Speech happens only via speak_in_channel (its own marked voice).
|
|
4355
|
+
proseRouting: 'explicit',
|
|
4356
|
+
allowedTools: [...SUBCONSCIOUS_TOOL_NAMES, 'think', 'skip_reply', 'end_turn'],
|
|
4357
|
+
};
|
|
4358
|
+
const agent = new Agent(agentConfig, contextManager, this.membrane);
|
|
4359
|
+
this.agents.set(name, agent);
|
|
4360
|
+
this.agentConfigs.set(name, agentConfig);
|
|
4361
|
+
this.subconsciousAgentName = name;
|
|
4362
|
+
this.subconsciousStrategy = strategy;
|
|
4363
|
+
this.subconsciousConfig = cfg;
|
|
4364
|
+
return agent;
|
|
4365
|
+
}
|
|
4366
|
+
|
|
4063
4367
|
private async runLoop(): Promise<void> {
|
|
4064
4368
|
while (this.running) {
|
|
4065
4369
|
try {
|
|
@@ -4172,7 +4476,7 @@ export class AgentFramework {
|
|
|
4172
4476
|
// byte-matched (the window stores what the membrane sends;
|
|
4173
4477
|
// divergence breaks the compile prefix).
|
|
4174
4478
|
const { blocks: toolResultContent, spilled } =
|
|
4175
|
-
await this.buildStoredToolResultContent(currentState.toolResults, maxChars);
|
|
4479
|
+
await this.buildStoredToolResultContent(agent.name, currentState.toolResults, maxChars);
|
|
4176
4480
|
agent.getContextManager().addMessage('user', toolResultContent);
|
|
4177
4481
|
|
|
4178
4482
|
// Flush any messages that were deferred while this turn was in
|
|
@@ -4198,12 +4502,11 @@ export class AgentFramework {
|
|
|
4198
4502
|
// queued, they flush at the target's own next boundary — with
|
|
4199
4503
|
// injection — or in driveStream's finally when its turn ends.
|
|
4200
4504
|
const midTurnInjections: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata }> = [];
|
|
4201
|
-
|
|
4202
|
-
const
|
|
4203
|
-
if (
|
|
4204
|
-
const deferred = this.deferredMessages.splice(0);
|
|
4505
|
+
{
|
|
4506
|
+
const deferred = this.drainDeferredFor(agent.name);
|
|
4507
|
+
if (deferred.length > 0) {
|
|
4205
4508
|
for (const msg of deferred) {
|
|
4206
|
-
|
|
4509
|
+
agent.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
|
|
4207
4510
|
// Injection guards: tool blocks would corrupt the tool-cycle
|
|
4208
4511
|
// structure the membrane enforces, and a message named as the
|
|
4209
4512
|
// agent itself would render as an ASSISTANT turn on the wire
|
|
@@ -4538,7 +4841,8 @@ export class AgentFramework {
|
|
|
4538
4841
|
// their own channel's messages, not by framework-wide events.
|
|
4539
4842
|
const targetAgents =
|
|
4540
4843
|
response.requestInference === true
|
|
4541
|
-
? Array.from(this.agents.keys()).filter(
|
|
4844
|
+
? Array.from(this.agents.keys()).filter(
|
|
4845
|
+
(n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName)
|
|
4542
4846
|
: response.requestInference;
|
|
4543
4847
|
|
|
4544
4848
|
for (const agentName of targetAgents) {
|
|
@@ -4575,6 +4879,7 @@ export class AgentFramework {
|
|
|
4575
4879
|
metadata?: Record<string, unknown>;
|
|
4576
4880
|
tags?: string[];
|
|
4577
4881
|
triggerInference?: boolean;
|
|
4882
|
+
targetAgents?: string[];
|
|
4578
4883
|
}): Promise<void> {
|
|
4579
4884
|
const metadata: Record<string, unknown> = {
|
|
4580
4885
|
...event.metadata,
|
|
@@ -4621,12 +4926,38 @@ export class AgentFramework {
|
|
|
4621
4926
|
}
|
|
4622
4927
|
}
|
|
4623
4928
|
|
|
4929
|
+
// Tune-out divert (issue #77): stamped, stored, no resident wake. The
|
|
4930
|
+
// coordinator has already handled wake bookkeeping (coalesced
|
|
4931
|
+
// subconscious invocation, durable count, suppression ack) before we
|
|
4932
|
+
// stamp; the subconscious reads the message through its merged view.
|
|
4933
|
+
const divert = this.tuneOutCoordinator?.onIncoming(
|
|
4934
|
+
event.serverId,
|
|
4935
|
+
event.channelId,
|
|
4936
|
+
event.messageId,
|
|
4937
|
+
event.tags,
|
|
4938
|
+
event.author?.id,
|
|
4939
|
+
// The resident's gate verdict rides the event; it preconditions
|
|
4940
|
+
// subconscious wakes (gate composes, it is not replaced).
|
|
4941
|
+
event.triggerInference !== false,
|
|
4942
|
+
) ?? null;
|
|
4943
|
+
if (divert) {
|
|
4944
|
+
metadata.tuneOut = { epochId: divert.epochId };
|
|
4945
|
+
}
|
|
4946
|
+
|
|
4624
4947
|
const id = this.addMessage('user', incomingContent, metadata);
|
|
4625
4948
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
|
|
4626
4949
|
|
|
4627
|
-
if (event.triggerInference) {
|
|
4950
|
+
if (event.triggerInference && !divert) {
|
|
4628
4951
|
const addressed = isAddressedMessage(event.tags, event.metadata);
|
|
4629
|
-
|
|
4952
|
+
// Honor explicit targeting, mirroring the push-event path: an event
|
|
4953
|
+
// that names its agents wakes exactly those; an untargeted event
|
|
4954
|
+
// keeps the historical broadcast. (targetAgents was declared on
|
|
4955
|
+
// McplChannelIncomingEvent from the start but never honored here —
|
|
4956
|
+
// tune-out's wake routing is the first setter.)
|
|
4957
|
+
const targetAgents = event.targetAgents
|
|
4958
|
+
?? [...this.agents.keys()].filter((n) => n !== this.subconsciousAgentName);
|
|
4959
|
+
for (const agentName of targetAgents) {
|
|
4960
|
+
if (!this.agents.has(agentName)) continue;
|
|
4630
4961
|
this.pendingRequests.push({
|
|
4631
4962
|
agentName,
|
|
4632
4963
|
reason: 'mcpl:channel-incoming',
|
|
@@ -4757,41 +5088,50 @@ export class AgentFramework {
|
|
|
4757
5088
|
if (!template || !templateConfig) {
|
|
4758
5089
|
throw new Error(`conversation template agent "${router.templateAgent}" not found`);
|
|
4759
5090
|
}
|
|
5091
|
+
if (this.usedEphemeralAgentNames.has(name) || this.agents.has(name)) {
|
|
5092
|
+
throw new Error(`Conversation agent name "${name}" is already registered or reserved`);
|
|
5093
|
+
}
|
|
5094
|
+
this.usedEphemeralAgentNames.add(name);
|
|
5095
|
+
try {
|
|
4760
5096
|
|
|
4761
|
-
|
|
4762
|
-
|
|
4763
|
-
|
|
4764
|
-
|
|
4765
|
-
|
|
4766
|
-
|
|
4767
|
-
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
5097
|
+
const contextManager = await ContextManager.open({
|
|
5098
|
+
store: this.store,
|
|
5099
|
+
namespace: `conversations/${name}`,
|
|
5100
|
+
isolate: true,
|
|
5101
|
+
// Strategy instances are stateful — never share the template's.
|
|
5102
|
+
strategy: router.strategyFactory?.() ?? new PassthroughStrategy(),
|
|
5103
|
+
// Dynamic conversation forks retain the established provider policy in
|
|
5104
|
+
// this bounded first slice. Provider cooldown ownership belongs to the
|
|
5105
|
+
// persistent resident only; generation-unique forks must not leak gates.
|
|
5106
|
+
membrane: this.membrane,
|
|
5107
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
5108
|
+
});
|
|
4773
5109
|
|
|
4774
|
-
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
5110
|
+
// Seed with the template's compiled context, renaming the template
|
|
5111
|
+
// participant so the fork reads its inheritance as its own history.
|
|
5112
|
+
// Guard: only seed a genuinely fresh namespace. Generation counters are
|
|
5113
|
+
// persisted precisely so names aren't reused, but if this namespace has
|
|
5114
|
+
// history anyway (counter state lost, crash between spawn and persist),
|
|
5115
|
+
// seeding again would stack another template copy on top of it.
|
|
5116
|
+
const { messages: existing } = await contextManager.compile();
|
|
5117
|
+
if (existing.length === 0) {
|
|
5118
|
+
const { messages: compiled } = await template.getContextManager().compile();
|
|
5119
|
+
for (const msg of compiled) {
|
|
5120
|
+
const participant = msg.participant === template.name ? name : msg.participant;
|
|
5121
|
+
contextManager.addMessage(participant, msg.content);
|
|
5122
|
+
}
|
|
4786
5123
|
}
|
|
4787
|
-
}
|
|
4788
5124
|
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
5125
|
+
const config: AgentConfig = { ...templateConfig, name, strategy: undefined };
|
|
5126
|
+
const agent = new Agent(config, contextManager, this.membrane);
|
|
5127
|
+
this.agents.set(name, agent);
|
|
5128
|
+
this.agentConfigs.set(name, config);
|
|
5129
|
+
this.conversationAgentHomes.set(name, channelId);
|
|
5130
|
+
return agent;
|
|
5131
|
+
} catch (error) {
|
|
5132
|
+
this.usedEphemeralAgentNames.delete(name);
|
|
5133
|
+
throw error;
|
|
5134
|
+
}
|
|
4795
5135
|
}
|
|
4796
5136
|
|
|
4797
5137
|
/** Persist the router's generation counters (see hydration in create()). */
|
|
@@ -4815,10 +5155,13 @@ export class AgentFramework {
|
|
|
4815
5155
|
private disposeConversationAgent(agentName: string): void {
|
|
4816
5156
|
this.closingConversationAgents.delete(agentName);
|
|
4817
5157
|
const channelId = this.conversationAgentHomes.get(agentName);
|
|
5158
|
+
const agent = this.agents.get(agentName);
|
|
4818
5159
|
this.agents.delete(agentName);
|
|
5160
|
+
this.toolImageLedgers.delete(agentName);
|
|
4819
5161
|
this.agentConfigs.delete(agentName);
|
|
4820
5162
|
this.conversationAgentHomes.delete(agentName);
|
|
4821
5163
|
this.evictTurnCheckpoints(agentName);
|
|
5164
|
+
if (agent) this.logicalTurnToolCalls.delete(agent);
|
|
4822
5165
|
this.emitTrace({
|
|
4823
5166
|
type: 'mcpl:conversation-disposed',
|
|
4824
5167
|
agentName,
|
|
@@ -5054,7 +5397,8 @@ export class AgentFramework {
|
|
|
5054
5397
|
if (event.triggerInference) {
|
|
5055
5398
|
// Default broadcast excludes conversation forks (channel-driven).
|
|
5056
5399
|
const targetAgents = event.targetAgents
|
|
5057
|
-
?? [...this.agents.keys()].filter(
|
|
5400
|
+
?? [...this.agents.keys()].filter(
|
|
5401
|
+
(n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
|
|
5058
5402
|
for (const agentName of targetAgents) {
|
|
5059
5403
|
this.pendingRequests.push({
|
|
5060
5404
|
agentName,
|
|
@@ -5255,6 +5599,9 @@ export class AgentFramework {
|
|
|
5255
5599
|
`[inference-dropped] agent=${agentName} reason=policy-skip ` +
|
|
5256
5600
|
`requests=${requests.length} triggers=${requests.map((r) => r.reason).join(',')}`,
|
|
5257
5601
|
);
|
|
5602
|
+
// A context-budget restart inherits the predecessor's EventGate liveness.
|
|
5603
|
+
// If policy drops the queued successor, no driveStream remains to release it.
|
|
5604
|
+
if (budgetRestart) this.eventGate?.onInferenceEnded(agentName);
|
|
5258
5605
|
const gate = this.providerGates.get(agentName);
|
|
5259
5606
|
if (gate?.primaryPending && this.providerAccelerationRecoveries.has(agentName)) {
|
|
5260
5607
|
gate.primaryPending = false;
|
|
@@ -5294,6 +5641,17 @@ export class AgentFramework {
|
|
|
5294
5641
|
if (r.addressed) addressedReq = r;
|
|
5295
5642
|
}
|
|
5296
5643
|
const channelReq = addressedReq ?? ambientReq;
|
|
5644
|
+
// Telemetry provenance (who/where woke the agent): from the routing
|
|
5645
|
+
// winner when it carries one; else from the most recent request that
|
|
5646
|
+
// does (gate wakes name a counterparty without setting a locus),
|
|
5647
|
+
// ordered by the event's own time (wakeAt) rather than flush order.
|
|
5648
|
+
let provReq: InferenceRequest | undefined = channelReq?.counterparty || channelReq?.wakeChannelId ? channelReq : undefined;
|
|
5649
|
+
if (!provReq) {
|
|
5650
|
+
for (const r of requests) {
|
|
5651
|
+
if (!r.counterparty && !r.wakeChannelId) continue;
|
|
5652
|
+
if (!provReq || (r.wakeAt ?? r.timestamp) >= (provReq.wakeAt ?? provReq.timestamp)) provReq = r;
|
|
5653
|
+
}
|
|
5654
|
+
}
|
|
5297
5655
|
await this.startAgentStream(agent, {
|
|
5298
5656
|
...trigger,
|
|
5299
5657
|
channelId: channelReq?.channelId,
|
|
@@ -5302,7 +5660,9 @@ export class AgentFramework {
|
|
|
5302
5660
|
// the channel for routing but names no author — the restart is its
|
|
5303
5661
|
// own cause, and borrowing another request's author would be false
|
|
5304
5662
|
// provenance.
|
|
5305
|
-
counterparty: budgetRestart ? undefined :
|
|
5663
|
+
counterparty: budgetRestart ? undefined : provReq?.counterparty,
|
|
5664
|
+
wakeChannelId: budgetRestart ? undefined : provReq?.wakeChannelId,
|
|
5665
|
+
wakeAt: budgetRestart ? undefined : provReq?.wakeAt,
|
|
5306
5666
|
});
|
|
5307
5667
|
}
|
|
5308
5668
|
}
|
|
@@ -5846,9 +6206,8 @@ export class AgentFramework {
|
|
|
5846
6206
|
trigger?.reason !== 'context_budget_restart' &&
|
|
5847
6207
|
this.deferredMessages.length > 0
|
|
5848
6208
|
) {
|
|
5849
|
-
|
|
5850
|
-
|
|
5851
|
-
const deferred = this.deferredMessages.splice(0);
|
|
6209
|
+
{
|
|
6210
|
+
const deferred = this.drainDeferredFor(agent.name);
|
|
5852
6211
|
for (const msg of deferred) {
|
|
5853
6212
|
// Per-message try/catch (mirrors announceLocusIfChanged): one
|
|
5854
6213
|
// poison message — or a transient store-write failure — must not
|
|
@@ -5906,8 +6265,21 @@ export class AgentFramework {
|
|
|
5906
6265
|
// BEFORE this turn compiles, so the agent always knows where its voice
|
|
5907
6266
|
// goes (announce-on-change only — no per-turn chatter, append-only for
|
|
5908
6267
|
// KV stability).
|
|
6268
|
+
if (trigger?.reason === 'context_budget_restart') {
|
|
6269
|
+
const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
6270
|
+
this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
|
|
6271
|
+
}
|
|
6272
|
+
|
|
5909
6273
|
if (trigger?.reason !== 'context_budget_restart') {
|
|
5910
6274
|
if (attempt === 0) this.maybePrimeProseMode(agent);
|
|
6275
|
+
const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
6276
|
+
if (attempt === 0) {
|
|
6277
|
+
// A true new turn gets a fresh generation and count.
|
|
6278
|
+
this.logicalTurnToolCalls.set(agent, { turnToken, count: 0 });
|
|
6279
|
+
} else {
|
|
6280
|
+
// A framework retry is a new physical stream in the same logical turn.
|
|
6281
|
+
this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
|
|
6282
|
+
}
|
|
5911
6283
|
// Fresh turn: forget the previous turn's explicit-send engagements and
|
|
5912
6284
|
// prose deliveries — both are strictly turn-scoped (a restart continues
|
|
5913
6285
|
// the same logical turn and keeps them).
|
|
@@ -5941,7 +6313,11 @@ export class AgentFramework {
|
|
|
5941
6313
|
// context-budget restart, which skips the re-pin but re-emits this).
|
|
5942
6314
|
channelId: this.turnLocusPins.get(agent.name),
|
|
5943
6315
|
});
|
|
5944
|
-
|
|
6316
|
+
// A budget restart continues the same logical inference window. The
|
|
6317
|
+
// predecessor keeps EventGate liveness until its successor terminates.
|
|
6318
|
+
if (trigger?.reason !== 'context_budget_restart') {
|
|
6319
|
+
this.eventGate?.onInferenceStarted(agent.name);
|
|
6320
|
+
}
|
|
5945
6321
|
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
|
|
5946
6322
|
|
|
5947
6323
|
// Typing indicator, started at TURN START. It says "attending", which is
|
|
@@ -6004,12 +6380,38 @@ export class AgentFramework {
|
|
|
6004
6380
|
}
|
|
6005
6381
|
}
|
|
6006
6382
|
|
|
6383
|
+
// An ephemeral watchdog may dispose this Agent while hooks/compile await.
|
|
6384
|
+
// Do not create a provider stream after its generation lost ownership.
|
|
6385
|
+
if (this.agents.get(agent.name) !== agent) {
|
|
6386
|
+
this.channelRegistry?.stopTyping();
|
|
6387
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
6388
|
+
return false;
|
|
6389
|
+
}
|
|
6390
|
+
|
|
6391
|
+
// The subconscious's standing dispositions ride a fixed system-position
|
|
6392
|
+
// injection (antra, #tuneout-talk): never repeated in its timeline, and
|
|
6393
|
+
// — since it has no long-term memory — structurally unable to wash out
|
|
6394
|
+
// of its window. Bytes change only when a disposition changes.
|
|
6395
|
+
if (agent.name === this.subconsciousAgentName && this.tuneOutCoordinator) {
|
|
6396
|
+
const dispositionInjection = this.tuneOutCoordinator.getDispositionsInjection();
|
|
6397
|
+
if (dispositionInjection) {
|
|
6398
|
+
injections = injections ? [...injections, dispositionInjection] : [dispositionInjection];
|
|
6399
|
+
}
|
|
6400
|
+
}
|
|
6401
|
+
|
|
6007
6402
|
const {
|
|
6008
6403
|
stream,
|
|
6009
6404
|
request: compiledRequest,
|
|
6010
6405
|
takeKvSubmission,
|
|
6011
6406
|
drainKvSubmissionIds,
|
|
6012
6407
|
} = await agent.startStreamWithInjections(tools, injections);
|
|
6408
|
+
if (this.agents.get(agent.name) !== agent) {
|
|
6409
|
+
stream.cancel();
|
|
6410
|
+
agent.cancelStream();
|
|
6411
|
+
this.channelRegistry?.stopTyping();
|
|
6412
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
6413
|
+
return false;
|
|
6414
|
+
}
|
|
6013
6415
|
|
|
6014
6416
|
const handle = this.driveStream(
|
|
6015
6417
|
agent,
|
|
@@ -6021,6 +6423,7 @@ export class AgentFramework {
|
|
|
6021
6423
|
ownsProviderGate,
|
|
6022
6424
|
takeKvSubmission,
|
|
6023
6425
|
drainKvSubmissionIds,
|
|
6426
|
+
new Set(tools.map((tool) => tool.name)),
|
|
6024
6427
|
);
|
|
6025
6428
|
this.activeStreams.set(agent.name, handle);
|
|
6026
6429
|
// Handoff: driveStream captured the token in its synchronous prefix;
|
|
@@ -6105,6 +6508,7 @@ export class AgentFramework {
|
|
|
6105
6508
|
ownsProviderGate = false,
|
|
6106
6509
|
takeKvSubmission?: () => { submissionId: string; wireReceipt: CacheWireReceipt } | undefined,
|
|
6107
6510
|
drainKvSubmissionIds?: () => string[],
|
|
6511
|
+
knownToolNames: ReadonlySet<string> = new Set(),
|
|
6108
6512
|
): Promise<void> {
|
|
6109
6513
|
const startTime = Date.now();
|
|
6110
6514
|
const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -6124,6 +6528,7 @@ export class AgentFramework {
|
|
|
6124
6528
|
// means no successor turn can have replaced it while we compiled.
|
|
6125
6529
|
const myTurnToken = this.activeTurnTokens.get(agent.name);
|
|
6126
6530
|
let hadToolCalls = false;
|
|
6531
|
+
let generationLost = false;
|
|
6127
6532
|
|
|
6128
6533
|
// ---- Present-while-acting turn state ---------------------------------
|
|
6129
6534
|
// Output locus for the WHOLE logical turn: frozen in startAgentStream
|
|
@@ -6207,13 +6612,20 @@ export class AgentFramework {
|
|
|
6207
6612
|
// failed, a framework-cancelled stream sets aborted. Best-effort
|
|
6208
6613
|
// notifications — consumers dedupe by inferenceId and keep a timeout.
|
|
6209
6614
|
let lifecyclePhase: 'completed' | 'aborted' | 'failed' = 'completed';
|
|
6615
|
+
let preserveEventGateForSuccessor = false;
|
|
6210
6616
|
this.hookOrchestrator?.emitLifecycle({
|
|
6211
6617
|
inferenceId: outgoingInferenceId,
|
|
6212
6618
|
conversationId: agent.name,
|
|
6213
6619
|
turnIndex: 0,
|
|
6214
6620
|
phase: 'started',
|
|
6215
6621
|
});
|
|
6216
|
-
|
|
6622
|
+
// The wrapper guard needs the complete response before it can distinguish
|
|
6623
|
+
// an exact invocation-shaped body from ordinary XML/prose. Buffer guarded
|
|
6624
|
+
// turns until completion: otherwise speculative outgoing chunks could
|
|
6625
|
+
// expose the wrapper before the fail-closed classifier runs.
|
|
6626
|
+
const proseStream = this.channelRegistry &&
|
|
6627
|
+
agent.proseRouting !== 'disabled' &&
|
|
6628
|
+
!agent.toolWrapperProseGuard
|
|
6217
6629
|
? new ProseStreamRouter({
|
|
6218
6630
|
mode: agent.proseRouting === 'explicit' ? 'explicit' : agent.proseRouting === 'hybrid' ? 'hybrid' : 'locus',
|
|
6219
6631
|
initialTarget: typingChannel,
|
|
@@ -6243,6 +6655,14 @@ export class AgentFramework {
|
|
|
6243
6655
|
|
|
6244
6656
|
try {
|
|
6245
6657
|
for await (const event of stream) {
|
|
6658
|
+
// Ignore every late event from an Agent generation that no longer owns
|
|
6659
|
+
// this name (ephemeral watchdog/disposal, conversation replacement).
|
|
6660
|
+
if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
|
|
6661
|
+
generationLost = true;
|
|
6662
|
+
lifecyclePhase = 'aborted';
|
|
6663
|
+
stream.cancel();
|
|
6664
|
+
break;
|
|
6665
|
+
}
|
|
6246
6666
|
this.touchEphemeralRun(agent.name, true);
|
|
6247
6667
|
switch (event.type) {
|
|
6248
6668
|
case 'tokens':
|
|
@@ -6310,6 +6730,7 @@ export class AgentFramework {
|
|
|
6310
6730
|
case 'tool-calls': {
|
|
6311
6731
|
adoptInjectedRound();
|
|
6312
6732
|
hadToolCalls = true;
|
|
6733
|
+
this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length);
|
|
6313
6734
|
this.recordEphemeralToolCalls(agent.name, event.calls.length);
|
|
6314
6735
|
this.emitTrace({
|
|
6315
6736
|
type: 'inference:tool_calls_yielded',
|
|
@@ -6492,6 +6913,7 @@ export class AgentFramework {
|
|
|
6492
6913
|
const readyState = agent.state as AgentState;
|
|
6493
6914
|
if (readyState.status === 'ready') {
|
|
6494
6915
|
const { blocks: toolResultContent } = await this.buildStoredToolResultContent(
|
|
6916
|
+
agent.name,
|
|
6495
6917
|
readyState.toolResults,
|
|
6496
6918
|
this.resolveToolResultInlineCap(agent).cap,
|
|
6497
6919
|
);
|
|
@@ -6515,7 +6937,30 @@ export class AgentFramework {
|
|
|
6515
6937
|
const terminalContent = lastToolIdx >= 0
|
|
6516
6938
|
? response.content.slice(lastToolIdx + 1)
|
|
6517
6939
|
: response.content;
|
|
6518
|
-
|
|
6940
|
+
// This is a whole-response boundary, not a trailing-prose
|
|
6941
|
+
// classifier. If the turn executed any genuine structured tool
|
|
6942
|
+
// call, preserve its later prose exactly as ordinary history.
|
|
6943
|
+
const logicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
6944
|
+
const logicalTurnHadToolCalls = logicalToolState?.turnToken === myTurnToken && (logicalToolState?.count ?? 0) > 0;
|
|
6945
|
+
const guardedWrapperTool = agent.toolWrapperProseGuard && !logicalTurnHadToolCalls
|
|
6946
|
+
? detectKnownToolWrapperProse(response.content, knownToolNames)
|
|
6947
|
+
: null;
|
|
6948
|
+
if (guardedWrapperTool) {
|
|
6949
|
+
// AF #133: containment only. Never execute wrapper text, never
|
|
6950
|
+
// publish it, and never retain it as the freshest assistant
|
|
6951
|
+
// self-seed. Exact raw output remains in the provider ledger.
|
|
6952
|
+
agent.getContextManager().addMessage('user', [{
|
|
6953
|
+
type: 'text',
|
|
6954
|
+
text: '[tool-boundary] No tool was called.',
|
|
6955
|
+
}], {
|
|
6956
|
+
system: true,
|
|
6957
|
+
kind: 'tool-wrapper-prose-contained',
|
|
6958
|
+
toolName: guardedWrapperTool,
|
|
6959
|
+
});
|
|
6960
|
+
console.error(
|
|
6961
|
+
`[tool-boundary] ${agent.name}: contained whole-response prose wrapper for registered tool ${guardedWrapperTool}; no tool called`,
|
|
6962
|
+
);
|
|
6963
|
+
} else if (lastToolIdx >= 0) {
|
|
6519
6964
|
if (terminalContent.length > 0) {
|
|
6520
6965
|
agent.addAssistantResponse(terminalContent);
|
|
6521
6966
|
}
|
|
@@ -6541,7 +6986,9 @@ export class AgentFramework {
|
|
|
6541
6986
|
// text is speech.
|
|
6542
6987
|
const isTextBlock = (block: ContentBlock): block is ContentBlock & { type: 'text' } =>
|
|
6543
6988
|
block.type === 'text';
|
|
6544
|
-
const allText =
|
|
6989
|
+
const allText = guardedWrapperTool
|
|
6990
|
+
? []
|
|
6991
|
+
: response.content.filter(isTextBlock);
|
|
6545
6992
|
|
|
6546
6993
|
const speechContent = hadToolCalls ? [] : allText;
|
|
6547
6994
|
const thoughts = hadToolCalls ? allText : [];
|
|
@@ -6556,19 +7003,30 @@ export class AgentFramework {
|
|
|
6556
7003
|
}
|
|
6557
7004
|
: undefined;
|
|
6558
7005
|
|
|
7006
|
+
// Async completion work may overlap a replacement stream. Recheck
|
|
7007
|
+
// physical ownership before any terminal state/settlement side effect.
|
|
7008
|
+
if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
|
|
7009
|
+
generationLost = true;
|
|
7010
|
+
lifecyclePhase = 'aborted';
|
|
7011
|
+
stream.cancel();
|
|
7012
|
+
return;
|
|
7013
|
+
}
|
|
7014
|
+
|
|
6559
7015
|
// Reset agent state before emitting inference:completed. Traces are
|
|
6560
7016
|
// observability-only, but external synchronous listeners should
|
|
6561
7017
|
// still see the terminal state at the terminal trace boundary.
|
|
6562
7018
|
// Speech dispatch happens after but doesn't depend on the status
|
|
6563
7019
|
// field.
|
|
6564
7020
|
agent.reset();
|
|
6565
|
-
this.eventGate?.onInferenceEnded(agent.name);
|
|
7021
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
|
|
6566
7022
|
this.settleAgent(agent.name, {
|
|
6567
7023
|
stopReason: 'completed',
|
|
6568
|
-
speech:
|
|
6569
|
-
|
|
6570
|
-
|
|
6571
|
-
|
|
7024
|
+
speech: guardedWrapperTool
|
|
7025
|
+
? ''
|
|
7026
|
+
: terminalContent
|
|
7027
|
+
.filter((block: ContentBlock): block is ContentBlock & { type: 'text' } => block.type === 'text')
|
|
7028
|
+
.map((block) => block.text)
|
|
7029
|
+
.join('\n'),
|
|
6572
7030
|
});
|
|
6573
7031
|
|
|
6574
7032
|
this.emitTrace({
|
|
@@ -6942,7 +7400,7 @@ export class AgentFramework {
|
|
|
6942
7400
|
|
|
6943
7401
|
if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
|
|
6944
7402
|
lifecyclePhase = 'failed';
|
|
6945
|
-
this.eventGate?.onInferenceEnded(agent.name);
|
|
7403
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
|
|
6946
7404
|
break;
|
|
6947
7405
|
}
|
|
6948
7406
|
|
|
@@ -6966,7 +7424,7 @@ export class AgentFramework {
|
|
|
6966
7424
|
// itself) means retrying the same context can never succeed.
|
|
6967
7425
|
...this.classifyInferenceError(err),
|
|
6968
7426
|
});
|
|
6969
|
-
this.eventGate?.onInferenceEnded(agent.name);
|
|
7427
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
|
|
6970
7428
|
if (action.emit) {
|
|
6971
7429
|
this.pushEvent(action.emit);
|
|
6972
7430
|
}
|
|
@@ -6988,7 +7446,13 @@ export class AgentFramework {
|
|
|
6988
7446
|
if (cancelKind !== undefined) {
|
|
6989
7447
|
this.frameworkCancelledStreams.delete(cancelKey);
|
|
6990
7448
|
lifecyclePhase = 'aborted'; // §10.5 — terminal emitted in finally
|
|
6991
|
-
|
|
7449
|
+
// A budget restart continues the same EventGate window. Do not
|
|
7450
|
+
// end it here or in finally; the replacement owns final release.
|
|
7451
|
+
if (cancelKind === 'budget_restart') {
|
|
7452
|
+
preserveEventGateForSuccessor = true;
|
|
7453
|
+
} else if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) {
|
|
7454
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
7455
|
+
}
|
|
6992
7456
|
// endTurn IS a logical turn end — earlier rounds may have
|
|
6993
7457
|
// live-routed prose (narrate → skip_reply is a real shape),
|
|
6994
7458
|
// so settle the delivery chain and drop the receipt. A
|
|
@@ -7035,7 +7499,7 @@ export class AgentFramework {
|
|
|
7035
7499
|
request: compiledRequest ?? { note: 'streaming request aborted' },
|
|
7036
7500
|
durationMs,
|
|
7037
7501
|
});
|
|
7038
|
-
this.eventGate?.onInferenceEnded(agent.name);
|
|
7502
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
|
|
7039
7503
|
}
|
|
7040
7504
|
break;
|
|
7041
7505
|
}
|
|
@@ -7128,6 +7592,14 @@ export class AgentFramework {
|
|
|
7128
7592
|
}
|
|
7129
7593
|
}
|
|
7130
7594
|
} catch (error) {
|
|
7595
|
+
// A superseded physical stream may throw after its replacement starts.
|
|
7596
|
+
// It has no authority to settle, reset, gate-release, or publish failure.
|
|
7597
|
+
if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
|
|
7598
|
+
generationLost = true;
|
|
7599
|
+
lifecyclePhase = 'aborted';
|
|
7600
|
+
stream.cancel();
|
|
7601
|
+
return;
|
|
7602
|
+
}
|
|
7131
7603
|
// Stream itself threw. Organization acceleration is deferred from a
|
|
7132
7604
|
// fresh compile; every other throw keeps the ordinary exhausted path.
|
|
7133
7605
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
@@ -7138,7 +7610,7 @@ export class AgentFramework {
|
|
|
7138
7610
|
error: `Provider acceleration cooldown: ${err.message}`,
|
|
7139
7611
|
request: compiledRequest ?? { note: 'streaming request rate-limited' }, durationMs });
|
|
7140
7612
|
this.abortAgentScript(agent.name, 'provider acceleration cooldown');
|
|
7141
|
-
lifecyclePhase = 'failed'; agent.reset(); this.eventGate?.onInferenceEnded(agent.name);
|
|
7613
|
+
lifecyclePhase = 'failed'; agent.reset(); if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
|
|
7142
7614
|
return;
|
|
7143
7615
|
}
|
|
7144
7616
|
this.emitTrace({
|
|
@@ -7173,7 +7645,7 @@ export class AgentFramework {
|
|
|
7173
7645
|
this.abortAgentScript(agent.name, 'stream threw');
|
|
7174
7646
|
lifecyclePhase = 'failed'; // §10.5 — terminal emitted in finally
|
|
7175
7647
|
agent.reset();
|
|
7176
|
-
this.eventGate?.onInferenceEnded(agent.name);
|
|
7648
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
|
|
7177
7649
|
} finally {
|
|
7178
7650
|
const unsettledKvSubmissions = drainKvSubmissionIds?.() ?? [];
|
|
7179
7651
|
if (unsettledKvSubmissions.length > 0) {
|
|
@@ -7186,6 +7658,22 @@ export class AgentFramework {
|
|
|
7186
7658
|
}
|
|
7187
7659
|
} catch { /* receipt cleanup must not mask inference teardown */ }
|
|
7188
7660
|
}
|
|
7661
|
+
// A framework retry/budget restart can overlap physical streams on the
|
|
7662
|
+
// same Agent object. Name + Agent identity is not enough: only the
|
|
7663
|
+
// current stream generation may mutate name-keyed teardown state.
|
|
7664
|
+
const ownsPhysicalStream =
|
|
7665
|
+
this.agents.get(agent.name) === agent && agent.streamId === myStreamId;
|
|
7666
|
+
// Ephemeral completion resolves its caller before this finally runs, so
|
|
7667
|
+
// runEphemeralToCompletion may already have deregistered the name. The
|
|
7668
|
+
// terminal frame still owns its typing/outgoing completion unless it was
|
|
7669
|
+
// genuinely superseded or handed EventGate liveness to a budget restart.
|
|
7670
|
+
const disposedEphemeralWithoutSuccessor =
|
|
7671
|
+
generationLost &&
|
|
7672
|
+
this.disposedEphemeralFrames.has(agent) &&
|
|
7673
|
+
!this.agents.has(agent.name);
|
|
7674
|
+
const frameReachedTerminal =
|
|
7675
|
+
(!generationLost || disposedEphemeralWithoutSuccessor) &&
|
|
7676
|
+
!preserveEventGateForSuccessor;
|
|
7189
7677
|
// §10.5: exactly one terminal per `started`, on every exit path the
|
|
7190
7678
|
// host controls. Which one was decided by the path taken (default
|
|
7191
7679
|
// completed; catch → failed; framework-cancel → aborted). A host
|
|
@@ -7204,30 +7692,36 @@ export class AgentFramework {
|
|
|
7204
7692
|
// all incoming events → the agent silently stops waking on messages
|
|
7205
7693
|
// (typing still stops, compression still runs — matching the observed
|
|
7206
7694
|
// wedge). onInferenceEnded is idempotent, so a redundant call is safe.
|
|
7207
|
-
|
|
7208
|
-
|
|
7695
|
+
if (ownsPhysicalStream && !preserveEventGateForSuccessor) {
|
|
7696
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
7697
|
+
}
|
|
7698
|
+
if (!generationLost && ownsPhysicalStream) {
|
|
7699
|
+
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
|
|
7700
|
+
}
|
|
7209
7701
|
if (ownsProviderGate) this.releasePrimaryProviderGate(agent.name);
|
|
7210
7702
|
|
|
7211
7703
|
// Stop the typing indicator on every exit path (complete, error,
|
|
7212
7704
|
// exhausted, abort) so it never sticks after the turn ends.
|
|
7213
|
-
this.channelRegistry?.stopTyping();
|
|
7705
|
+
if (frameReachedTerminal) this.channelRegistry?.stopTyping();
|
|
7214
7706
|
|
|
7215
7707
|
// Spec 14.3: flush any held line-start text, then close each streamed
|
|
7216
7708
|
// channel with its final moderated content — the consumer's signal to
|
|
7217
7709
|
// finalize (end the TTS utterance, settle the rendered message).
|
|
7218
|
-
if (proseStream) {
|
|
7710
|
+
if (frameReachedTerminal && proseStream) {
|
|
7219
7711
|
emitOutgoing(proseStream.finish());
|
|
7220
7712
|
for (const [channelId, text] of proseStream.byChannel()) {
|
|
7221
7713
|
this.channelRegistry!.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text);
|
|
7222
7714
|
}
|
|
7223
7715
|
}
|
|
7224
7716
|
this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
|
|
7225
|
-
|
|
7226
|
-
|
|
7717
|
+
if (ownsPhysicalStream) {
|
|
7718
|
+
this.activeStreams.delete(agent.name);
|
|
7719
|
+
this.pendingAssistantBlocks.delete(agent.name);
|
|
7720
|
+
}
|
|
7227
7721
|
|
|
7228
7722
|
// A conversation fork whose TTL closure turn just finished is done for
|
|
7229
7723
|
// good — dispose it so the agent map doesn't grow monotonically.
|
|
7230
|
-
if (this.closingConversationAgents.has(agent.name)) {
|
|
7724
|
+
if (ownsPhysicalStream && this.closingConversationAgents.has(agent.name)) {
|
|
7231
7725
|
this.disposeConversationAgent(agent.name);
|
|
7232
7726
|
}
|
|
7233
7727
|
|
|
@@ -7243,11 +7737,15 @@ export class AgentFramework {
|
|
|
7243
7737
|
this.activeTurnTriggers.delete(agent.name);
|
|
7244
7738
|
}
|
|
7245
7739
|
|
|
7246
|
-
// Flush any deferred messages (e.g. if stream failed while tools were
|
|
7247
|
-
|
|
7248
|
-
|
|
7740
|
+
// Flush any deferred messages (e.g. if stream failed while tools were
|
|
7741
|
+
// pending). Only THIS agent's messages: other targets' entries wait
|
|
7742
|
+
// for their own boundaries (re-adding via addMessage re-defers if the
|
|
7743
|
+
// target has meanwhile started a turn).
|
|
7744
|
+
if (frameReachedTerminal && this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
|
|
7745
|
+
const deferred = this.drainDeferredFor(agent.name);
|
|
7249
7746
|
for (const msg of deferred) {
|
|
7250
|
-
this.addMessage(msg.participant, msg.content, msg.metadata
|
|
7747
|
+
this.addMessage(msg.participant, msg.content, msg.metadata,
|
|
7748
|
+
msg.forAgent ? { forAgent: msg.forAgent } : undefined);
|
|
7251
7749
|
}
|
|
7252
7750
|
}
|
|
7253
7751
|
}
|
|
@@ -7350,6 +7848,7 @@ export class AgentFramework {
|
|
|
7350
7848
|
// never between tool_use and its tool_result. The two addMessage calls
|
|
7351
7849
|
// below are synchronous and adjacent — nothing can interleave.
|
|
7352
7850
|
const { blocks } = await this.buildStoredToolResultContent(
|
|
7851
|
+
agentName,
|
|
7353
7852
|
[{ id: toolUseId, name: toolName, input, result, durationMs }],
|
|
7354
7853
|
this.resolveToolResultInlineCap(agent).cap,
|
|
7355
7854
|
);
|
|
@@ -7856,6 +8355,7 @@ export class AgentFramework {
|
|
|
7856
8355
|
* live wire copy can reuse the identical strings.
|
|
7857
8356
|
*/
|
|
7858
8357
|
private async buildStoredToolResultContent(
|
|
8358
|
+
agentName: string,
|
|
7859
8359
|
toolResults: CompletedToolCall[],
|
|
7860
8360
|
maxChars: number | undefined,
|
|
7861
8361
|
): Promise<{
|
|
@@ -7864,10 +8364,22 @@ export class AgentFramework {
|
|
|
7864
8364
|
}> {
|
|
7865
8365
|
const spilled = new Map<string, { text: string; filePath: string | null }>();
|
|
7866
8366
|
const dateLabel = new Date().toISOString().slice(0, 10);
|
|
8367
|
+
const ledger = this.toolImageLedgerFor(agentName);
|
|
7867
8368
|
for (const tc of toolResults) {
|
|
8369
|
+
// Retain every image the result carries BEFORE the placeholder is
|
|
8370
|
+
// written (issue #104): the placeholder embeds the ref, so the image
|
|
8371
|
+
// stays reachable by provenance after the bytes leave the wire.
|
|
7868
8372
|
const raw = tc.result.isError
|
|
7869
8373
|
? tc.result.error ?? 'Unknown error'
|
|
7870
|
-
: toolResultDataToHistoryString(tc.result.data, undefined
|
|
8374
|
+
: toolResultDataToHistoryString(tc.result.data, undefined, {
|
|
8375
|
+
imageRef: (blockIndex, image) => ledger.retain({
|
|
8376
|
+
toolCallId: tc.id,
|
|
8377
|
+
toolName: tc.name,
|
|
8378
|
+
blockIndex,
|
|
8379
|
+
data: image.data,
|
|
8380
|
+
mediaType: image.mimeType,
|
|
8381
|
+
}).ref,
|
|
8382
|
+
});
|
|
7871
8383
|
spilled.set(tc.id, await this.spillOrTruncate(raw, maxChars, `${dateLabel}-${tc.id}`));
|
|
7872
8384
|
}
|
|
7873
8385
|
const blocks: ContentBlock[] = toolResults.map(tc => ({
|
|
@@ -7888,6 +8400,13 @@ export class AgentFramework {
|
|
|
7888
8400
|
label: string,
|
|
7889
8401
|
): Promise<{ text: string; filePath: string | null }> {
|
|
7890
8402
|
if (!cap || content.length <= cap) return { text: content, filePath: null };
|
|
8403
|
+
// The stored text is the only place save_recent_image discovers tool
|
|
8404
|
+
// images, and the live wire delivers every image regardless of text
|
|
8405
|
+
// length — so an image slot dropped by the cut is an image the resident
|
|
8406
|
+
// saw that the index would silently slide past (issue #104). Keep the
|
|
8407
|
+
// slots at or past the cut, re-appended after the notice.
|
|
8408
|
+
const { head, tail } = splitPreservingImageSlots(content, cap);
|
|
8409
|
+
const kept = formatPreservedImageSlots(tail);
|
|
7891
8410
|
|
|
7892
8411
|
const workspace = this.getWorkspaceModule();
|
|
7893
8412
|
const mountName = workspace ? this.firstWritableMountName(workspace) : null;
|
|
@@ -7899,10 +8418,11 @@ export class AgentFramework {
|
|
|
7899
8418
|
const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain');
|
|
7900
8419
|
if (result.success) {
|
|
7901
8420
|
return {
|
|
7902
|
-
text:
|
|
7903
|
-
+ `\n\n[truncated — showing ${
|
|
8421
|
+
text: head
|
|
8422
|
+
+ `\n\n[truncated — showing ${head.length} of ${content.length} chars; full content: workspace file ${path}. `
|
|
7904
8423
|
+ 'Read/grep it with your file tools, or raise the inline cap via '
|
|
7905
|
-
+ 'agent_settings update tool_result_inline_max_chars.]'
|
|
8424
|
+
+ 'agent_settings update tool_result_inline_max_chars.]'
|
|
8425
|
+
+ kept,
|
|
7906
8426
|
filePath: path,
|
|
7907
8427
|
};
|
|
7908
8428
|
}
|
|
@@ -7925,15 +8445,17 @@ export class AgentFramework {
|
|
|
7925
8445
|
`[spill] workspace write failed for ${path} (${content.length} chars): ${failure}`,
|
|
7926
8446
|
);
|
|
7927
8447
|
return {
|
|
7928
|
-
text:
|
|
7929
|
-
+ `\n\n[truncated — showing ${
|
|
7930
|
-
+ ` (${failure}); content over the cap was not retained]
|
|
8448
|
+
text: head
|
|
8449
|
+
+ `\n\n[truncated — showing ${head.length} of ${content.length} chars; spill to workspace file ${path} FAILED`
|
|
8450
|
+
+ ` (${failure}); content over the cap was not retained]`
|
|
8451
|
+
+ kept,
|
|
7931
8452
|
filePath: null,
|
|
7932
8453
|
};
|
|
7933
8454
|
}
|
|
7934
8455
|
return {
|
|
7935
|
-
text:
|
|
7936
|
-
+ '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace, full content not retained]'
|
|
8456
|
+
text: head
|
|
8457
|
+
+ '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace, full content not retained]'
|
|
8458
|
+
+ kept,
|
|
7937
8459
|
filePath: null,
|
|
7938
8460
|
};
|
|
7939
8461
|
}
|
|
@@ -8151,6 +8673,15 @@ export class AgentFramework {
|
|
|
8151
8673
|
return hasImage ? blocks : null;
|
|
8152
8674
|
}
|
|
8153
8675
|
|
|
8676
|
+
private toolImageLedgerFor(agentName: string): ToolImageLedger {
|
|
8677
|
+
let ledger = this.toolImageLedgers.get(agentName);
|
|
8678
|
+
if (!ledger) {
|
|
8679
|
+
ledger = new ToolImageLedger();
|
|
8680
|
+
this.toolImageLedgers.set(agentName, ledger);
|
|
8681
|
+
}
|
|
8682
|
+
return ledger;
|
|
8683
|
+
}
|
|
8684
|
+
|
|
8154
8685
|
/** Strategy-derived per-message bound (maxMessageTokens * 4 chars). */
|
|
8155
8686
|
private strategyDerivedToolResultChars(agent: Agent): number | undefined {
|
|
8156
8687
|
const strategy = agent.getContextManager().getStrategy();
|
|
@@ -8348,6 +8879,30 @@ export class AgentFramework {
|
|
|
8348
8879
|
return;
|
|
8349
8880
|
}
|
|
8350
8881
|
|
|
8882
|
+
// Route the tune-out tool (residents) and the subconscious's surface
|
|
8883
|
+
if (enrichedCall.name === 'tune_out' && this.tuneOutCoordinator) {
|
|
8884
|
+
this.dispatchTuneOutToolCall(agentName, enrichedCall);
|
|
8885
|
+
return;
|
|
8886
|
+
}
|
|
8887
|
+
if (
|
|
8888
|
+
this.tuneOutCoordinator &&
|
|
8889
|
+
agentName === this.subconsciousAgentName &&
|
|
8890
|
+
(SUBCONSCIOUS_TOOL_NAMES as readonly string[]).includes(enrichedCall.name)
|
|
8891
|
+
) {
|
|
8892
|
+
void this.tuneOutCoordinator
|
|
8893
|
+
.handleSubconsciousTool(enrichedCall.name, enrichedCall.input as Record<string, unknown>)
|
|
8894
|
+
.then((result) => {
|
|
8895
|
+
this.queue.push({
|
|
8896
|
+
type: 'tool-result',
|
|
8897
|
+
callId: enrichedCall.id,
|
|
8898
|
+
agentName,
|
|
8899
|
+
moduleName: 'tune-out',
|
|
8900
|
+
result,
|
|
8901
|
+
});
|
|
8902
|
+
});
|
|
8903
|
+
return;
|
|
8904
|
+
}
|
|
8905
|
+
|
|
8351
8906
|
// Route sleep / wake tools
|
|
8352
8907
|
if ((enrichedCall.name === 'sleep' || enrichedCall.name === 'wake') && this.eventGate) {
|
|
8353
8908
|
this.dispatchSleepToolCall(agentName, enrichedCall);
|
|
@@ -8492,13 +9047,32 @@ export class AgentFramework {
|
|
|
8492
9047
|
private addMessage(
|
|
8493
9048
|
participant: string,
|
|
8494
9049
|
content: ContentBlock[],
|
|
8495
|
-
metadata?: MessageMetadata
|
|
9050
|
+
metadata?: MessageMetadata,
|
|
9051
|
+
opts?: {
|
|
9052
|
+
/**
|
|
9053
|
+
* Deliver into this registry agent's window instead of the primary's,
|
|
9054
|
+
* with the deferral/turn-alive guard evaluated against THAT agent's
|
|
9055
|
+
* turn state. Default (absent) is byte-identical to the historical
|
|
9056
|
+
* primary-only behavior. Unknown names log and drop (loud, non-fatal:
|
|
9057
|
+
* this is reachable from timer callbacks that may outlive an agent).
|
|
9058
|
+
*/
|
|
9059
|
+
forAgent?: string;
|
|
9060
|
+
}
|
|
8496
9061
|
): MessageId {
|
|
8497
|
-
// Route to the
|
|
8498
|
-
const agent =
|
|
8499
|
-
? this.agents.get(
|
|
8500
|
-
: this.
|
|
9062
|
+
// Route to the named agent, else the primary (not ephemeral subagents).
|
|
9063
|
+
const agent = opts?.forAgent
|
|
9064
|
+
? this.agents.get(opts.forAgent)
|
|
9065
|
+
: this.primaryAgentName
|
|
9066
|
+
? this.agents.get(this.primaryAgentName)
|
|
9067
|
+
: this.agents.values().next().value;
|
|
8501
9068
|
if (!agent) {
|
|
9069
|
+
if (opts?.forAgent) {
|
|
9070
|
+
console.error(
|
|
9071
|
+
`[addMessage] delivery target "${opts.forAgent}" is not a registered agent — message dropped ` +
|
|
9072
|
+
`(participant=${participant})`,
|
|
9073
|
+
);
|
|
9074
|
+
return '' as MessageId;
|
|
9075
|
+
}
|
|
8502
9076
|
throw new Error('No agents configured');
|
|
8503
9077
|
}
|
|
8504
9078
|
|
|
@@ -8519,19 +9093,48 @@ export class AgentFramework {
|
|
|
8519
9093
|
// ALSO injected into the live stream — hear-while-acting), or in
|
|
8520
9094
|
// driveStream's finally when the turn ends.
|
|
8521
9095
|
const hasToolResult = content.some(b => b.type === 'tool_result');
|
|
9096
|
+
// Explicitly-targeted deliveries scope the tool-cycle check to the
|
|
9097
|
+
// target (pendingAssistantBlocks is keyed by agent); the default path
|
|
9098
|
+
// keeps the historical GLOBAL check byte-for-byte (conservative: any
|
|
9099
|
+
// agent's pending cycle defers primary-bound messages).
|
|
9100
|
+
const midToolCycle = opts?.forAgent
|
|
9101
|
+
? this.pendingAssistantBlocks.has(agent.name)
|
|
9102
|
+
: this.pendingAssistantBlocks.size > 0;
|
|
8522
9103
|
if (
|
|
8523
9104
|
!hasToolResult &&
|
|
8524
|
-
(
|
|
9105
|
+
(midToolCycle ||
|
|
8525
9106
|
this.activeTurnTokens.has(agent.name) ||
|
|
8526
9107
|
this.activeStreams.has(agent.name))
|
|
8527
9108
|
) {
|
|
8528
|
-
this.deferredMessages.push({ participant, content, metadata });
|
|
8529
|
-
return '' as MessageId; // Deferred — flushed at the next boundary
|
|
9109
|
+
this.deferredMessages.push({ participant, content, metadata, forAgent: opts?.forAgent });
|
|
9110
|
+
return '' as MessageId; // Deferred — flushed at the target's next boundary
|
|
8530
9111
|
}
|
|
8531
9112
|
|
|
8532
9113
|
return agent.getContextManager().addMessage(participant, content, metadata);
|
|
8533
9114
|
}
|
|
8534
9115
|
|
|
9116
|
+
/**
|
|
9117
|
+
* Splice out the deferred messages addressed to `agentName` (explicitly
|
|
9118
|
+
* via forAgent, or implicitly when it is the primary), leaving other
|
|
9119
|
+
* targets' messages queued for their own boundaries.
|
|
9120
|
+
*/
|
|
9121
|
+
private drainDeferredFor(agentName: string): Array<{
|
|
9122
|
+
participant: string;
|
|
9123
|
+
content: ContentBlock[];
|
|
9124
|
+
metadata?: MessageMetadata;
|
|
9125
|
+
forAgent?: string;
|
|
9126
|
+
}> {
|
|
9127
|
+
if (this.deferredMessages.length === 0) return [];
|
|
9128
|
+
const mine: typeof this.deferredMessages = [];
|
|
9129
|
+
const rest: typeof this.deferredMessages = [];
|
|
9130
|
+
for (const msg of this.deferredMessages) {
|
|
9131
|
+
const target = msg.forAgent ?? this.primaryAgentName;
|
|
9132
|
+
(target === agentName ? mine : rest).push(msg);
|
|
9133
|
+
}
|
|
9134
|
+
this.deferredMessages = rest;
|
|
9135
|
+
return mine;
|
|
9136
|
+
}
|
|
9137
|
+
|
|
8535
9138
|
private editMessage(id: MessageId, content: ContentBlock[]): void {
|
|
8536
9139
|
const agent = this.primaryAgentName
|
|
8537
9140
|
? this.agents.get(this.primaryAgentName)
|
|
@@ -10922,6 +11525,53 @@ export class AgentFramework {
|
|
|
10922
11525
|
* suppression window, optionally announces in the sticky channel, and ends
|
|
10923
11526
|
* the turn (the agent goes idle immediately). `wake` clears sleep.
|
|
10924
11527
|
*/
|
|
11528
|
+
/** tune_out tool (residents): enter or cancel a channel tune-out. */
|
|
11529
|
+
private dispatchTuneOutToolCall(agentName: string, call: ToolCall): void {
|
|
11530
|
+
const coordinator = this.tuneOutCoordinator!;
|
|
11531
|
+
const input = (call.input ?? {}) as {
|
|
11532
|
+
channelId?: string; mode?: string;
|
|
11533
|
+
cadenceSeconds?: number; backlogCap?: number; maxWakes?: number;
|
|
11534
|
+
durationSeconds?: number;
|
|
11535
|
+
};
|
|
11536
|
+
const channelId = String(input.channelId ?? '');
|
|
11537
|
+
const entry = this.channelRegistry?.listChannelsRaw()
|
|
11538
|
+
.find((e) => e.descriptor.id === channelId);
|
|
11539
|
+
let result: { success: boolean; data?: unknown; error?: string; isError?: boolean };
|
|
11540
|
+
if (!entry) {
|
|
11541
|
+
result = { success: false, error: `unknown channel: ${channelId}`, isError: false };
|
|
11542
|
+
} else if (input.mode === 'cancel') {
|
|
11543
|
+
const r = coordinator.cancel(entry.serverId, channelId, 'agent-tool', 'agent request');
|
|
11544
|
+
result = r.ok
|
|
11545
|
+
? { success: true, data: { cancelled: channelId } }
|
|
11546
|
+
: { success: false, error: r.error, isError: false };
|
|
11547
|
+
} else {
|
|
11548
|
+
const r = coordinator.enter(entry.serverId, channelId, {
|
|
11549
|
+
cadenceSeconds: input.cadenceSeconds,
|
|
11550
|
+
backlogCap: input.backlogCap,
|
|
11551
|
+
maxWakes: input.maxWakes,
|
|
11552
|
+
durationSeconds: input.durationSeconds,
|
|
11553
|
+
}, 'agent-tool');
|
|
11554
|
+
result = r.ok
|
|
11555
|
+
? {
|
|
11556
|
+
success: true,
|
|
11557
|
+
data: {
|
|
11558
|
+
tunedOut: channelId,
|
|
11559
|
+
cadenceSeconds: r.params.cadenceSeconds,
|
|
11560
|
+
backlogCap: r.params.backlogCap,
|
|
11561
|
+
maxWakes: r.params.maxWakes,
|
|
11562
|
+
},
|
|
11563
|
+
}
|
|
11564
|
+
: { success: false, error: r.error, isError: false };
|
|
11565
|
+
}
|
|
11566
|
+
this.queue.push({
|
|
11567
|
+
type: 'tool-result',
|
|
11568
|
+
callId: call.id,
|
|
11569
|
+
agentName,
|
|
11570
|
+
moduleName: 'tune-out',
|
|
11571
|
+
result,
|
|
11572
|
+
});
|
|
11573
|
+
}
|
|
11574
|
+
|
|
10925
11575
|
private dispatchSleepToolCall(agentName: string, call: ToolCall): void {
|
|
10926
11576
|
this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
|
|
10927
11577
|
const gate = this.eventGate!;
|
|
@@ -11128,10 +11778,53 @@ export class AgentFramework {
|
|
|
11128
11778
|
}
|
|
11129
11779
|
|
|
11130
11780
|
/**
|
|
11131
|
-
*
|
|
11132
|
-
*
|
|
11133
|
-
*
|
|
11134
|
-
*
|
|
11781
|
+
* `save_recent_image` barrier. Module tool calls in a batch are only
|
|
11782
|
+
* ENQUEUED by dispatch, while the save runs at once — so a [snap, save]
|
|
11783
|
+
* round would inventory before the snapshot exists and silently save the
|
|
11784
|
+
* newest OLDER image (issue #104, same-batch representation). Wait for
|
|
11785
|
+
* every sibling in the round to settle; if one never does, fail closed
|
|
11786
|
+
* rather than trust an index the batch may still change. Sibling saves
|
|
11787
|
+
* produce no images and are not waited on (two saves in one batch would
|
|
11788
|
+
* otherwise deadlock into a double timeout). Script-inner calls
|
|
11789
|
+
* (`pytc-…`) run sequentially inside their script host and skip the
|
|
11790
|
+
* barrier — the host call itself is the only pending sibling they'd see.
|
|
11791
|
+
*/
|
|
11792
|
+
private async awaitSiblingToolCalls(agent: Agent, call: ToolCall): Promise<void> {
|
|
11793
|
+
if (call.id.startsWith('pytc-')) return;
|
|
11794
|
+
const BARRIER_MS = 120_000;
|
|
11795
|
+
const POLL_MS = 20;
|
|
11796
|
+
const startedAt = Date.now();
|
|
11797
|
+
for (;;) {
|
|
11798
|
+
const state = agent.state;
|
|
11799
|
+
// Idle (puppet path) or the round was torn down: nothing to wait on.
|
|
11800
|
+
if (state.status !== 'waiting_for_tools') return;
|
|
11801
|
+
const open = [...state.pending.values()].filter((p) => p.id !== call.id && p.name !== 'save_recent_image');
|
|
11802
|
+
if (open.length === 0) return;
|
|
11803
|
+
if (Date.now() - startedAt > BARRIER_MS) {
|
|
11804
|
+
throw new Error(
|
|
11805
|
+
`save_recent_image: ${open.length} sibling tool call(s) in this batch still pending after ` +
|
|
11806
|
+
`${Math.round(BARRIER_MS / 1000)}s (${open.map((p) => `${p.name} (call ${p.id})`).join(', ')}) — their results ` +
|
|
11807
|
+
'may carry newer images than anything in your context, so no index can be trusted yet. Nothing written. ' +
|
|
11808
|
+
'Save again once they have finished.',
|
|
11809
|
+
);
|
|
11810
|
+
}
|
|
11811
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
11812
|
+
}
|
|
11813
|
+
}
|
|
11814
|
+
|
|
11815
|
+
/**
|
|
11816
|
+
* Handle the synthesized `save_recent_image` tool (issue #104): build ONE
|
|
11817
|
+
* ordered inventory of every image the calling agent has seen — attachment
|
|
11818
|
+
* image blocks (blobs re-inlined) AND tool-result images (their history
|
|
11819
|
+
* placeholders carry a ref, resolved through the agent's ToolImageLedger)
|
|
11820
|
+
* — counted back from the most recent, then write the requested bytes to
|
|
11821
|
+
* a workspace mount via WorkspaceModule.writeBinary.
|
|
11822
|
+
*
|
|
11823
|
+
* Fail-closed: when the image at a requested position cannot be produced
|
|
11824
|
+
* (bytes evicted, minted by an earlier process, or a placeholder written
|
|
11825
|
+
* before retention existed) the call fails AT THAT INDEX and writes
|
|
11826
|
+
* nothing. It never slides to an older image — a filename must not
|
|
11827
|
+
* authenticate bytes from another surface.
|
|
11135
11828
|
*/
|
|
11136
11829
|
private dispatchSaveImageToolCall(agentName: string, call: ToolCall): void {
|
|
11137
11830
|
this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
|
|
@@ -11146,26 +11839,219 @@ export class AgentFramework {
|
|
|
11146
11839
|
});
|
|
11147
11840
|
this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace', result });
|
|
11148
11841
|
};
|
|
11842
|
+
type Candidate =
|
|
11843
|
+
| { kind: 'attachment'; data: string | null; mediaType: string; messagesBack: number }
|
|
11844
|
+
| {
|
|
11845
|
+
kind: 'tool';
|
|
11846
|
+
ref: string | null;
|
|
11847
|
+
/** The tool_result block the placeholder was found in — NOT trusted
|
|
11848
|
+
* text; a resolved ref must belong to this very call. */
|
|
11849
|
+
toolCallId: string;
|
|
11850
|
+
toolName: string;
|
|
11851
|
+
mediaType: string;
|
|
11852
|
+
messagesBack: number;
|
|
11853
|
+
}
|
|
11854
|
+
| {
|
|
11855
|
+
kind: 'reference';
|
|
11856
|
+
refId: string;
|
|
11857
|
+
mediaType: string;
|
|
11858
|
+
toolCallId: string;
|
|
11859
|
+
toolName: string;
|
|
11860
|
+
messagesBack: number;
|
|
11861
|
+
};
|
|
11862
|
+
interface Resolved {
|
|
11863
|
+
rangeOffset: number;
|
|
11864
|
+
data: string;
|
|
11865
|
+
mediaType: string;
|
|
11866
|
+
receipt: Record<string, unknown>;
|
|
11867
|
+
}
|
|
11149
11868
|
void (async (): Promise<void> => {
|
|
11150
11869
|
try {
|
|
11151
11870
|
const workspace = this.getWorkspaceModule();
|
|
11152
11871
|
if (!workspace) throw new Error('save_recent_image requires a workspace module');
|
|
11153
11872
|
const agent = this.agents.get(agentName);
|
|
11154
11873
|
if (!agent) throw new Error(`Unknown agent: ${agentName}`);
|
|
11155
|
-
const input = (call.input ?? {}) as { path?: unknown; index?: unknown; count?: unknown };
|
|
11874
|
+
const input = (call.input ?? {}) as { path?: unknown; index?: unknown; count?: unknown; ref?: unknown };
|
|
11156
11875
|
if (typeof input.path !== 'string' || input.path.length === 0) {
|
|
11157
11876
|
throw new Error('save_recent_image: `path` (mount-prefixed) is required');
|
|
11158
11877
|
}
|
|
11159
|
-
const
|
|
11160
|
-
|
|
11161
|
-
|
|
11162
|
-
|
|
11163
|
-
const
|
|
11164
|
-
|
|
11165
|
-
|
|
11166
|
-
|
|
11878
|
+
const ledger = this.toolImageLedgerFor(agentName);
|
|
11879
|
+
|
|
11880
|
+
const describeTool = (c: { toolName: string; toolCallId: string }): string =>
|
|
11881
|
+
`${c.toolName} (call ${c.toolCallId})`;
|
|
11882
|
+
const unavailable = (status: 'evicted' | 'unknown'): string =>
|
|
11883
|
+
status === 'evicted'
|
|
11884
|
+
? 'its bytes were evicted from the bounded retention budget'
|
|
11885
|
+
: 'this process has no record of that ref (refs are per-process and a restart clears them; ' +
|
|
11886
|
+
'very old refs age out; a mistyped ref looks the same)';
|
|
11887
|
+
const resolve = (candidate: Candidate, position: number): Resolved => {
|
|
11888
|
+
const rangeOffset = 0; // filled by the caller
|
|
11889
|
+
if (candidate.kind === 'attachment') {
|
|
11890
|
+
if (candidate.data === null) {
|
|
11891
|
+
throw new Error(
|
|
11892
|
+
`save_recent_image: image at index ${position} is not stored inline (base64) — cannot save it. Nothing written.`,
|
|
11893
|
+
);
|
|
11894
|
+
}
|
|
11895
|
+
const bytes = Buffer.from(candidate.data, 'base64');
|
|
11896
|
+
return {
|
|
11897
|
+
rangeOffset,
|
|
11898
|
+
data: candidate.data,
|
|
11899
|
+
mediaType: candidate.mediaType,
|
|
11900
|
+
receipt: {
|
|
11901
|
+
source: 'attachment',
|
|
11902
|
+
mediaType: candidate.mediaType,
|
|
11903
|
+
byteSize: bytes.byteLength,
|
|
11904
|
+
sha256: sha256Hex(bytes),
|
|
11905
|
+
messagesBack: candidate.messagesBack,
|
|
11906
|
+
},
|
|
11907
|
+
};
|
|
11908
|
+
}
|
|
11909
|
+
if (candidate.kind === 'reference') {
|
|
11910
|
+
throw new Error(
|
|
11911
|
+
`save_recent_image: image at index ${position} is a reference (${candidate.refId}, ${candidate.mediaType}, ` +
|
|
11912
|
+
`from ${describeTool(candidate)}) — its bytes are not held here. Nothing written. ` +
|
|
11913
|
+
'Fetch it with fetch_reference and save the materialized file instead.',
|
|
11914
|
+
);
|
|
11915
|
+
}
|
|
11916
|
+
if (candidate.ref === null) {
|
|
11917
|
+
throw new Error(
|
|
11918
|
+
`save_recent_image: image at index ${position} is a tool-result image from ${describeTool(candidate)} ` +
|
|
11919
|
+
'recorded without a retention ref (written before tool-image retention existed, or inside a ' +
|
|
11920
|
+
'script run, where tool images are not retained) — its bytes are not available. Nothing written. ' +
|
|
11921
|
+
'Re-run the tool for a fresh image rather than saving an older one.',
|
|
11922
|
+
);
|
|
11923
|
+
}
|
|
11924
|
+
const lookup = ledger.lookup(candidate.ref);
|
|
11925
|
+
// The placeholder came out of stored TEXT, which a tool result can
|
|
11926
|
+
// quote from anywhere (a fetch_history of a channel where someone
|
|
11927
|
+
// pasted their save receipt). A genuine placeholder is written by
|
|
11928
|
+
// its own call's serialization, so the ref's provenance names that
|
|
11929
|
+
// call; a quoted or forged one cannot. Refuse the mismatch.
|
|
11930
|
+
if (lookup.status !== 'unknown' && lookup.image.toolCallId !== candidate.toolCallId) {
|
|
11931
|
+
throw new Error(
|
|
11932
|
+
`save_recent_image: image at index ${position} cites ${candidate.ref}, but that image belongs to ` +
|
|
11933
|
+
`${describeTool(lookup.image)}, not to ${describeTool(candidate)} where the placeholder appears — ` +
|
|
11934
|
+
'the placeholder is quoted or forged text, not this result\'s own image. Nothing written.',
|
|
11935
|
+
);
|
|
11936
|
+
}
|
|
11937
|
+
if (lookup.status !== 'retained') {
|
|
11938
|
+
throw new Error(
|
|
11939
|
+
`save_recent_image: image at index ${position} (${candidate.ref}, from ${describeTool(candidate)}) ` +
|
|
11940
|
+
`is no longer retained — ${unavailable(lookup.status)}. Nothing written. ` +
|
|
11941
|
+
'Re-run the tool for a fresh image rather than saving an older one.',
|
|
11942
|
+
);
|
|
11943
|
+
}
|
|
11944
|
+
const image = lookup.image;
|
|
11945
|
+
return {
|
|
11946
|
+
rangeOffset,
|
|
11947
|
+
data: image.data,
|
|
11948
|
+
mediaType: image.mediaType,
|
|
11949
|
+
receipt: {
|
|
11950
|
+
source: 'tool-result',
|
|
11951
|
+
ref: image.ref,
|
|
11952
|
+
toolName: image.toolName,
|
|
11953
|
+
toolCallId: image.toolCallId,
|
|
11954
|
+
blockIndex: image.blockIndex,
|
|
11955
|
+
mediaType: image.mediaType,
|
|
11956
|
+
byteSize: image.byteSize,
|
|
11957
|
+
sha256: image.sha256,
|
|
11958
|
+
messagesBack: candidate.messagesBack,
|
|
11959
|
+
},
|
|
11960
|
+
};
|
|
11961
|
+
};
|
|
11962
|
+
|
|
11963
|
+
// Barrier: a save dispatched alongside other calls in one batch must
|
|
11964
|
+
// not inventory before their results exist — those are the newest
|
|
11965
|
+
// images. Waits (bounded) for every sibling to settle, or fails closed.
|
|
11966
|
+
await this.awaitSiblingToolCalls(agent, call);
|
|
11967
|
+
|
|
11968
|
+
const found: Resolved[] = [];
|
|
11969
|
+
let index = 0;
|
|
11970
|
+
let count = 1;
|
|
11971
|
+
/** Direct-ref mode: the one ref to find in the scannable context. */
|
|
11972
|
+
let wantedRef: string | null = null;
|
|
11973
|
+
if (input.ref !== undefined) {
|
|
11974
|
+
if (typeof input.ref !== 'string' || !TOOL_IMAGE_REF_RE.test(input.ref)) {
|
|
11975
|
+
throw new Error('save_recent_image: `ref` must look like "img_k7x3q2_7" (copy it from the image placeholder in your context)');
|
|
11976
|
+
}
|
|
11977
|
+
if (input.index !== undefined || input.count !== undefined) {
|
|
11978
|
+
throw new Error('save_recent_image: `ref` is mutually exclusive with `index`/`count`');
|
|
11979
|
+
}
|
|
11980
|
+
wantedRef = input.ref;
|
|
11981
|
+
} else {
|
|
11982
|
+
index = input.index === undefined ? 0 : Number(input.index);
|
|
11983
|
+
if (!Number.isInteger(index) || index < 0) {
|
|
11984
|
+
throw new Error('save_recent_image: `index` must be a non-negative integer');
|
|
11985
|
+
}
|
|
11986
|
+
count = input.count === undefined ? 1 : Number(input.count);
|
|
11987
|
+
const MAX_COUNT = 20;
|
|
11988
|
+
if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) {
|
|
11989
|
+
throw new Error(`save_recent_image: \`count\` must be an integer in 1..${MAX_COUNT}`);
|
|
11990
|
+
}
|
|
11167
11991
|
}
|
|
11168
11992
|
const lastWanted = index + count - 1;
|
|
11993
|
+
let seen = 0;
|
|
11994
|
+
const collecting = (): boolean => (wantedRef !== null ? found.length === 0 : found.length < count);
|
|
11995
|
+
// One walk serves both modes. By index: the requested range of the
|
|
11996
|
+
// ordered inventory. By ref: the slot whose placeholder cites that
|
|
11997
|
+
// ref — the ref is resolved THROUGH its occurrence in context, so
|
|
11998
|
+
// the same provenance cross-check applies (a ref that is not visible
|
|
11999
|
+
// in the scannable window — an undone branch, quoted text naming a
|
|
12000
|
+
// real ref — is not saveable by name either).
|
|
12001
|
+
const consider = (candidate: Candidate): void => {
|
|
12002
|
+
if (wantedRef !== null) {
|
|
12003
|
+
if (candidate.kind === 'tool' && candidate.ref === wantedRef && found.length === 0) {
|
|
12004
|
+
found.push(resolve(candidate, seen));
|
|
12005
|
+
}
|
|
12006
|
+
} else if (seen >= index && seen <= lastWanted) {
|
|
12007
|
+
const resolved = resolve(candidate, seen);
|
|
12008
|
+
resolved.rangeOffset = seen - index;
|
|
12009
|
+
found.push(resolved);
|
|
12010
|
+
}
|
|
12011
|
+
seen++;
|
|
12012
|
+
};
|
|
12013
|
+
const considerToolResultImages = (
|
|
12014
|
+
toolCallId: string,
|
|
12015
|
+
toolName: string,
|
|
12016
|
+
placeholders: ParsedImagePlaceholder[],
|
|
12017
|
+
messagesBack: number,
|
|
12018
|
+
): void => {
|
|
12019
|
+
for (let p = placeholders.length - 1; p >= 0 && collecting(); p--) {
|
|
12020
|
+
const ph = placeholders[p]!;
|
|
12021
|
+
if (ph.kind === 'inline') {
|
|
12022
|
+
consider({ kind: 'tool', ref: ph.ref, toolCallId, toolName, mediaType: ph.mediaType, messagesBack });
|
|
12023
|
+
continue;
|
|
12024
|
+
}
|
|
12025
|
+
// Reference stub: the registry's testimony (when this process
|
|
12026
|
+
// still has the record) says whether it was an image; the
|
|
12027
|
+
// sniffed stub text is the fallback. Non-image references
|
|
12028
|
+
// are not slots.
|
|
12029
|
+
const testimony = referenceRegistry.get(ph.refId)?.testimony.mimeType?.toLowerCase() ?? ph.mediaType;
|
|
12030
|
+
if (!testimony || !testimony.startsWith('image/')) continue;
|
|
12031
|
+
consider({ kind: 'reference', refId: ph.refId, mediaType: testimony, toolCallId, toolName, messagesBack });
|
|
12032
|
+
}
|
|
12033
|
+
};
|
|
12034
|
+
|
|
12035
|
+
// Same-batch results (e.g. a snapshot dispatched alongside the save)
|
|
12036
|
+
// are not in the store yet but ARE the newest images. Classify them
|
|
12037
|
+
// with the very serializer the commit path will run — contradiction
|
|
12038
|
+
// blocks withheld, uri-form blocks as reference stubs, unknown shapes
|
|
12039
|
+
// as no slot at all — so the inventory means the same thing before
|
|
12040
|
+
// and after commit. Retention is idempotent per block, so the
|
|
12041
|
+
// placeholder written at commit time carries the same ref.
|
|
12042
|
+
const state = agent.state;
|
|
12043
|
+
if (state.status === 'waiting_for_tools') {
|
|
12044
|
+
for (let i = state.completed.length - 1; i >= 0 && collecting(); i--) {
|
|
12045
|
+
const tc = state.completed[i]!;
|
|
12046
|
+
if (tc.result.isError) continue;
|
|
12047
|
+
const text = toolResultDataToHistoryString(tc.result.data, undefined, {
|
|
12048
|
+
imageRef: (blockIndex, image) => ledger.retain({
|
|
12049
|
+
toolCallId: tc.id, toolName: tc.name, blockIndex, data: image.data, mediaType: image.mimeType,
|
|
12050
|
+
}).ref,
|
|
12051
|
+
});
|
|
12052
|
+
considerToolResultImages(tc.id, tc.name, parseImagePlaceholders(text), 0);
|
|
12053
|
+
}
|
|
12054
|
+
}
|
|
11169
12055
|
|
|
11170
12056
|
// Walk the message store tail-first in bounded windows, re-inlining
|
|
11171
12057
|
// blob media, until we've collected the requested range. Scanning is
|
|
@@ -11175,45 +12061,56 @@ export class AgentFramework {
|
|
|
11175
12061
|
const WINDOW = 25;
|
|
11176
12062
|
const cm = agent.getContextManager();
|
|
11177
12063
|
const total = cm.getMessageCount();
|
|
11178
|
-
let seen = 0;
|
|
11179
12064
|
let scanned = 0;
|
|
11180
|
-
|
|
11181
|
-
rangeOffset: number;
|
|
11182
|
-
data: string;
|
|
11183
|
-
mediaType: string;
|
|
11184
|
-
messagesBack: number;
|
|
11185
|
-
}> = [];
|
|
11186
|
-
for (let end = total; end > 0 && scanned < MAX_SCAN && found.length < count; end -= WINDOW) {
|
|
12065
|
+
for (let end = total; end > 0 && scanned < MAX_SCAN && collecting(); end -= WINDOW) {
|
|
11187
12066
|
const start = Math.max(0, end - WINDOW);
|
|
11188
|
-
|
|
12067
|
+
// By ref, attachment bytes are never needed — skip blob resolution.
|
|
12068
|
+
const { messages } = cm.getMessageWindow(start, end - start, { resolveBlobs: wantedRef === null });
|
|
11189
12069
|
scanned += end - start;
|
|
11190
|
-
for (let i = messages.length - 1; i >= 0 &&
|
|
12070
|
+
for (let i = messages.length - 1; i >= 0 && collecting(); i--) {
|
|
11191
12071
|
const content = messages[i]?.content;
|
|
11192
12072
|
if (!Array.isArray(content)) continue;
|
|
11193
|
-
|
|
12073
|
+
const messagesBack = total - (start + i);
|
|
12074
|
+
for (let b = content.length - 1; b >= 0 && collecting(); b--) {
|
|
11194
12075
|
const block = content[b] as {
|
|
11195
12076
|
type?: string;
|
|
11196
12077
|
source?: { type?: string; data?: string; mediaType?: string };
|
|
12078
|
+
toolUseId?: string;
|
|
12079
|
+
toolName?: string;
|
|
12080
|
+
content?: unknown;
|
|
11197
12081
|
};
|
|
11198
|
-
if (block?.type
|
|
11199
|
-
|
|
11200
|
-
|
|
11201
|
-
|
|
11202
|
-
|
|
11203
|
-
|
|
11204
|
-
|
|
11205
|
-
found.push({
|
|
11206
|
-
rangeOffset: seen - index,
|
|
11207
|
-
data: block.source.data,
|
|
11208
|
-
mediaType: block.source.mediaType ?? 'image/png',
|
|
11209
|
-
messagesBack: total - (start + i),
|
|
12082
|
+
if (block?.type === 'image') {
|
|
12083
|
+
const inline = block.source?.type === 'base64' && typeof block.source.data === 'string';
|
|
12084
|
+
consider({
|
|
12085
|
+
kind: 'attachment',
|
|
12086
|
+
data: inline ? block.source!.data! : null,
|
|
12087
|
+
mediaType: block.source?.mediaType ?? 'image/png',
|
|
12088
|
+
messagesBack,
|
|
11210
12089
|
});
|
|
12090
|
+
} else if (block?.type === 'tool_result' && typeof block.content === 'string') {
|
|
12091
|
+
considerToolResultImages(
|
|
12092
|
+
block.toolUseId ?? 'unknown',
|
|
12093
|
+
block.toolName ?? 'unknown tool',
|
|
12094
|
+
parseImagePlaceholders(block.content),
|
|
12095
|
+
messagesBack,
|
|
12096
|
+
);
|
|
11211
12097
|
}
|
|
11212
|
-
seen++;
|
|
11213
12098
|
}
|
|
11214
12099
|
}
|
|
11215
12100
|
}
|
|
11216
12101
|
if (found.length === 0) {
|
|
12102
|
+
if (wantedRef !== null) {
|
|
12103
|
+
const lookup = ledger.lookup(wantedRef);
|
|
12104
|
+
throw new Error(
|
|
12105
|
+
lookup.status === 'unknown'
|
|
12106
|
+
? `save_recent_image: ref ${wantedRef} cannot be saved — ${unavailable('unknown')}. Nothing written.`
|
|
12107
|
+
: `save_recent_image: ref ${wantedRef} (from ${describeTool(lookup.image)}) does not appear in your recent ` +
|
|
12108
|
+
`context (scanned ${Math.min(scanned, MAX_SCAN)} messages) — a ref is saved through its placeholder, ` +
|
|
12109
|
+
'which must be visible to you' +
|
|
12110
|
+
(lookup.status === 'evicted' ? '; its bytes were also evicted from the bounded retention budget' : '') +
|
|
12111
|
+
'. Nothing written.',
|
|
12112
|
+
);
|
|
12113
|
+
}
|
|
11217
12114
|
throw new Error(
|
|
11218
12115
|
seen === 0
|
|
11219
12116
|
? `save_recent_image: no images found in the most recent ${Math.min(scanned, MAX_SCAN)} messages`
|
|
@@ -11252,7 +12149,7 @@ export class AgentFramework {
|
|
|
11252
12149
|
savedFiles.push({
|
|
11253
12150
|
...(result.data as Record<string, unknown>),
|
|
11254
12151
|
imageIndex: index + image.rangeOffset,
|
|
11255
|
-
|
|
12152
|
+
...image.receipt,
|
|
11256
12153
|
});
|
|
11257
12154
|
}
|
|
11258
12155
|
finish({
|