@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/dist/src/framework.js
CHANGED
|
@@ -4,7 +4,9 @@ import { ReferenceFetcher, DEFAULT_FETCH_MAX_BYTES, EAGER_FETCH_TIMEOUT_MS } fro
|
|
|
4
4
|
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
5
5
|
import { JsStore } from '@animalabs/chronicle';
|
|
6
6
|
import { MembraneError } from '@animalabs/membrane';
|
|
7
|
-
import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager';
|
|
7
|
+
import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy } from '@animalabs/context-manager';
|
|
8
|
+
import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES } from './tune-out/tools.js';
|
|
9
|
+
import { TuneOutCoordinator, TUNE_OUT_DEFAULTS } from './tune-out/coordinator.js';
|
|
8
10
|
import { ProcessQueueImpl } from './queue.js';
|
|
9
11
|
import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK } from './refusal-reactions.js';
|
|
10
12
|
import { Agent } from './agent.js';
|
|
@@ -17,11 +19,13 @@ import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
|
|
|
17
19
|
import { PushHandler } from './mcpl/push-handler.js';
|
|
18
20
|
import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
|
|
19
21
|
import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
|
|
22
|
+
import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js';
|
|
20
23
|
import { InferenceRouter } from './mcpl/inference-router.js';
|
|
21
24
|
import { ChannelRegistry } from './mcpl/channel-registry.js';
|
|
22
25
|
import { ConversationRouter } from './mcpl/conversation-router.js';
|
|
23
26
|
import { safeSlice } from './safe-slice.js';
|
|
24
27
|
import { toolResultDataToHistoryString, truncateForHistory, DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, } from './tool-result-history.js';
|
|
28
|
+
import { TOOL_IMAGE_REF_RE, ToolImageLedger, formatPreservedImageSlots, parseImagePlaceholders, sha256Hex, splitPreservingImageSlots, } from './tool-image-ledger.js';
|
|
25
29
|
import { randomUUID } from 'node:crypto';
|
|
26
30
|
import { PyRunner, buildInjectedTools } from './code-execution/py-runner.js';
|
|
27
31
|
import { buildCodeExecutionToolDefinition, CODE_EXECUTION_TOOL_NAME, } from './code-execution/tool-definition.js';
|
|
@@ -522,6 +526,16 @@ export class AgentFramework {
|
|
|
522
526
|
frameworkCancelledStreams = new Map();
|
|
523
527
|
/** Active runEphemeralToCompletion runs, keyed by agent name. */
|
|
524
528
|
ephemeralRuns = new Map();
|
|
529
|
+
/** Ephemeral namespaces/names are single-generation for this framework
|
|
530
|
+
* lifetime. Reusing one while an old stream drains would let name-keyed
|
|
531
|
+
* liveness/settlement state cross generations. */
|
|
532
|
+
usedEphemeralAgentNames = new Set();
|
|
533
|
+
/** One-shot generation tickets minted by createEphemeralAgent. */
|
|
534
|
+
ephemeralCandidates = new WeakMap();
|
|
535
|
+
/** Physical ephemeral frames deliberately disposed by their run watchdog/caller.
|
|
536
|
+
* The name is single-generation, so these frames still own their terminal
|
|
537
|
+
* typing/outgoing close even after runEphemeralToCompletion deregisters them. */
|
|
538
|
+
disposedEphemeralFrames = new WeakSet();
|
|
525
539
|
/** Per-agent count of consecutive exhausted inferences (reset on any success).
|
|
526
540
|
* Drives hard-down escalation — see noteInferenceExhausted. */
|
|
527
541
|
consecutiveInferenceFailures = new Map();
|
|
@@ -569,6 +583,14 @@ export class AgentFramework {
|
|
|
569
583
|
primaryAgentName = null;
|
|
570
584
|
// Messages deferred while an agent is waiting_for_tools (to preserve
|
|
571
585
|
// tool_use → tool_result adjacency required by the Anthropic API).
|
|
586
|
+
/** The subconscious resident's registry name (issue #77), or null. */
|
|
587
|
+
subconsciousAgentName = null;
|
|
588
|
+
/** Its windowed strategy — the coordinator moves the anchor on tune-out entry. */
|
|
589
|
+
subconsciousStrategy = null;
|
|
590
|
+
/** Its config (speak_in_channel gate, voice block provenance). */
|
|
591
|
+
subconsciousConfig = null;
|
|
592
|
+
/** Tune-out coordinator (issue #77); non-null iff subconscious + channels. */
|
|
593
|
+
tuneOutCoordinator = null;
|
|
572
594
|
deferredMessages = [];
|
|
573
595
|
// Turn-alive markers (2026-07-31 Mythos phantom-skip incident): agentName →
|
|
574
596
|
// token of the turn currently in progress. Set at startAgentStream ENTRY —
|
|
@@ -599,6 +621,9 @@ export class AgentFramework {
|
|
|
599
621
|
getActiveTurnTrigger(agentName) {
|
|
600
622
|
return this.activeTurnTriggers.get(agentName);
|
|
601
623
|
}
|
|
624
|
+
/** Structured calls executed in the current logical turn. Unlike the
|
|
625
|
+
* per-stream local this survives framework retries and budget restarts. */
|
|
626
|
+
logicalTurnToolCalls = new WeakMap();
|
|
602
627
|
nextTurnToken = 1;
|
|
603
628
|
// Undo/redo state
|
|
604
629
|
turnCounters = new Map(); // agentName → next turnIndex
|
|
@@ -649,6 +674,11 @@ export class AgentFramework {
|
|
|
649
674
|
* framework state like the core runtime settings and restored at create
|
|
650
675
|
* (antra + Sol, 08-06); reset clears it back to the residence default. */
|
|
651
676
|
toolResultInlineMaxCharsOverride = new Map();
|
|
677
|
+
/** Per-agent retention of tool-result images with provenance (issue
|
|
678
|
+
* #104). In-memory and bounded by design: history keeps only the
|
|
679
|
+
* placeholder; this is what lets `save_recent_image` reach the bytes the
|
|
680
|
+
* resident actually saw — or fail at that exact index when they're gone. */
|
|
681
|
+
toolImageLedgers = new Map();
|
|
652
682
|
/** Durable residence-configured inline cap from
|
|
653
683
|
* FrameworkConfig.toolResultInlineMaxChars; null → house default. */
|
|
654
684
|
toolResultInlineMaxCharsConfig = null;
|
|
@@ -810,6 +840,12 @@ export class AgentFramework {
|
|
|
810
840
|
for (const agentConfig of config.agents) {
|
|
811
841
|
await framework.createAgent(agentConfig);
|
|
812
842
|
}
|
|
843
|
+
// The subconscious resident (issue #77) registers after the residents so
|
|
844
|
+
// it can never become primary; it is excluded from every broadcast
|
|
845
|
+
// fan-out and woken only by the tune-out coordinator's explicit pushes.
|
|
846
|
+
if (config.subconscious?.enabled) {
|
|
847
|
+
await framework.createSubconsciousAgent(config.subconscious);
|
|
848
|
+
}
|
|
813
849
|
// Finish any branch-local suppression interrupted after Chronicle switched
|
|
814
850
|
// branches. This runs before modules, MCPL connections, or inbound traffic.
|
|
815
851
|
await framework.resumePreparedDiscordSuppressions();
|
|
@@ -854,11 +890,20 @@ export class AgentFramework {
|
|
|
854
890
|
initialConfig: config.gate.config,
|
|
855
891
|
privilegedUsersPath: config.gate.privilegedUsersPath,
|
|
856
892
|
emitTrace: (e) => framework.emitTrace(e),
|
|
857
|
-
addMessage: (p, c, m) => framework.addMessage(p, c, m),
|
|
858
|
-
requestInference: (agentName, reason, source) => {
|
|
859
|
-
framework.pendingRequests.push({
|
|
893
|
+
addMessage: (p, c, m, forAgent) => framework.addMessage(p, c, m, forAgent ? { forAgent } : undefined),
|
|
894
|
+
requestInference: (agentName, reason, source, provenance) => {
|
|
895
|
+
framework.pendingRequests.push({
|
|
896
|
+
agentName, reason, source, timestamp: Date.now(),
|
|
897
|
+
// gate-requested wakes carry where/who (EventGate wakeProvenance)
|
|
898
|
+
// as TELEMETRY fields — the host's stamp reads them; the turn's
|
|
899
|
+
// speech locus (channelId / addressed) is deliberately NOT set
|
|
900
|
+
// here, so a batched wake routes exactly as it did before.
|
|
901
|
+
...(provenance?.channelId ? { wakeChannelId: provenance.channelId } : {}),
|
|
902
|
+
...(provenance?.counterparty ? { counterparty: provenance.counterparty } : {}),
|
|
903
|
+
...(provenance?.at ? { wakeAt: provenance.at } : {}),
|
|
904
|
+
});
|
|
860
905
|
},
|
|
861
|
-
getAgentNames: () => [...framework.agents.keys()],
|
|
906
|
+
getAgentNames: () => [...framework.agents.keys()].filter((n) => n !== framework.subconsciousAgentName),
|
|
862
907
|
});
|
|
863
908
|
}
|
|
864
909
|
// Liveness watchdog: fail hard if the main thread wedges (opt-in).
|
|
@@ -909,6 +954,30 @@ export class AgentFramework {
|
|
|
909
954
|
}
|
|
910
955
|
await framework.initializeMcpl(config.mcplServers, config.inferenceRouting);
|
|
911
956
|
}
|
|
957
|
+
// Tune-out coordinator (issue #77): needs both the subconscious resident
|
|
958
|
+
// and the MCPL channel subsystem; created after both exist.
|
|
959
|
+
if (framework.subconsciousAgentName && framework.channelRegistry) {
|
|
960
|
+
framework.tuneOutCoordinator = new TuneOutCoordinator(framework.channelRegistry, framework.mcplServerRegistry, {
|
|
961
|
+
addMessage: (participant, content, metadata, forAgent) => framework.addMessage(participant, content, metadata, forAgent ? { forAgent } : undefined),
|
|
962
|
+
requestInference: (agentName, reason, source) => {
|
|
963
|
+
framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
|
|
964
|
+
},
|
|
965
|
+
subconsciousName: () => framework.subconsciousAgentName,
|
|
966
|
+
primaryName: () => framework.primaryAgentName,
|
|
967
|
+
getStoredMessages: () => {
|
|
968
|
+
const primary = framework.primaryAgentName
|
|
969
|
+
? framework.agents.get(framework.primaryAgentName) : undefined;
|
|
970
|
+
return primary ? primary.getContextManager().getAllMessages() : [];
|
|
971
|
+
},
|
|
972
|
+
currentSequence: () => framework.store.currentSequence(),
|
|
973
|
+
setSubconsciousAnchor: (sequence) => framework.subconsciousStrategy?.setAnchor(sequence),
|
|
974
|
+
isForkBound: (channelId) => [...framework.conversationAgentHomes.values()].includes(channelId),
|
|
975
|
+
isPrivilegedAuthor: (authorId) => framework.eventGate?.isPrivilegedUser(authorId) ?? false,
|
|
976
|
+
allowChannelSpeech: () => !!framework.subconsciousConfig?.allowChannelSpeech,
|
|
977
|
+
emitTrace: (e) => framework.emitTrace(e),
|
|
978
|
+
});
|
|
979
|
+
framework.tuneOutCoordinator.resumeActiveEpochs();
|
|
980
|
+
}
|
|
912
981
|
// Diagnostics: `kill -USR2 <pid>` dumps live wake/inference state to stderr
|
|
913
982
|
// (journal) without a restart — for catching the wake-wedge on the running
|
|
914
983
|
// process. Shows the gate's `inferring` set + buffered-event count (the
|
|
@@ -973,6 +1042,7 @@ export class AgentFramework {
|
|
|
973
1042
|
this.running = false;
|
|
974
1043
|
this.providerAdmissionClosed = true;
|
|
975
1044
|
this.queue.close();
|
|
1045
|
+
this.tuneOutCoordinator?.stop();
|
|
976
1046
|
// Kill running code_execution scripts before cancelling streams: a
|
|
977
1047
|
// zombie script must not keep firing side-effectful tool calls into a
|
|
978
1048
|
// framework that is shutting down.
|
|
@@ -991,6 +1061,9 @@ export class AgentFramework {
|
|
|
991
1061
|
record.runner.dispose();
|
|
992
1062
|
}
|
|
993
1063
|
this.backgroundScripts.clear();
|
|
1064
|
+
// Retained tool images are per-process by design; a retained stopped
|
|
1065
|
+
// framework must not keep up to the whole ledger budget referenced.
|
|
1066
|
+
this.toolImageLedgers.clear();
|
|
994
1067
|
// A stopped host must never hang behind its own cooldown.
|
|
995
1068
|
this.cancelProviderAdmission();
|
|
996
1069
|
// Cancel all active streams
|
|
@@ -1404,7 +1477,12 @@ export class AgentFramework {
|
|
|
1404
1477
|
*/
|
|
1405
1478
|
getAllTools() {
|
|
1406
1479
|
const moduleTools = this.moduleRegistry.getAllTools();
|
|
1407
|
-
|
|
1480
|
+
// Copy: getChannelTools() returns the registry's shared definitions
|
|
1481
|
+
// array; pushing onto it would append another tune_out on every call.
|
|
1482
|
+
const channelTools = [...(this.channelRegistry?.getChannelTools() ?? [])];
|
|
1483
|
+
if (this.tuneOutCoordinator) {
|
|
1484
|
+
channelTools.push(AgentFramework.TUNE_OUT_TOOL);
|
|
1485
|
+
}
|
|
1408
1486
|
const gateTools = this.eventGate
|
|
1409
1487
|
? [
|
|
1410
1488
|
this.eventGate.getToolDefinition(),
|
|
@@ -1634,6 +1712,18 @@ export class AgentFramework {
|
|
|
1634
1712
|
};
|
|
1635
1713
|
}
|
|
1636
1714
|
getToolsForAgent(agentName, snapshot) {
|
|
1715
|
+
// The subconscious gets its own small surface (plus think/skip_reply/
|
|
1716
|
+
// end_turn), not the residents' tool board: no channel lifecycle, no
|
|
1717
|
+
// gate rules, no workspace — it observes, judges, and reports.
|
|
1718
|
+
if (agentName === this.subconsciousAgentName) {
|
|
1719
|
+
const basics = this.getAllTools()
|
|
1720
|
+
.filter((t) => t.name === 'think' || t.name === 'skip_reply' || t.name === 'end_turn')
|
|
1721
|
+
.map((t) => t.name === 'think'
|
|
1722
|
+
? this.buildThinkTool(snapshot?.sameRoundThinkTextPolicy
|
|
1723
|
+
?? this.getAgentRuntimeSettings(agentName).sameRoundThinkTextPolicy)
|
|
1724
|
+
: t);
|
|
1725
|
+
return [...SUBCONSCIOUS_TOOLS, ...basics];
|
|
1726
|
+
}
|
|
1637
1727
|
return this.getAllTools().map((tool) => {
|
|
1638
1728
|
if (tool.name === 'think') {
|
|
1639
1729
|
return this.buildThinkTool(snapshot?.sameRoundThinkTextPolicy
|
|
@@ -1989,21 +2079,35 @@ export class AgentFramework {
|
|
|
1989
2079
|
* Call cleanup() when done to release the ContextManager.
|
|
1990
2080
|
*/
|
|
1991
2081
|
async createEphemeralAgent(config) {
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2082
|
+
// Names are Chronicle namespaces and generation identities. Reserve before
|
|
2083
|
+
// opening the namespace so a second creation cannot append to an earlier
|
|
2084
|
+
// generation before runEphemeralToCompletion has a chance to reject it.
|
|
2085
|
+
if (this.usedEphemeralAgentNames.has(config.name) || this.agents.has(config.name)) {
|
|
2086
|
+
throw new Error(`Ephemeral agent name \"${config.name}\" is already registered or has been used in this framework`);
|
|
2087
|
+
}
|
|
2088
|
+
this.usedEphemeralAgentNames.add(config.name);
|
|
2089
|
+
try {
|
|
2090
|
+
const namespace = `subagent/${config.name}`;
|
|
2091
|
+
const contextManager = await ContextManager.open({
|
|
2092
|
+
store: this.store,
|
|
2093
|
+
namespace,
|
|
2094
|
+
isolate: true,
|
|
2095
|
+
strategy: config.strategy ?? new PassthroughStrategy(),
|
|
2096
|
+
membrane: this.membrane,
|
|
2097
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
2098
|
+
});
|
|
2099
|
+
const agent = new Agent(config, contextManager, this.membrane);
|
|
2100
|
+
this.ephemeralCandidates.set(agent, contextManager);
|
|
2101
|
+
const cleanup = () => {
|
|
2102
|
+
// Don't close the store — it's shared. Just release the CM.
|
|
2103
|
+
// Data persists in the store under the namespace for investigation.
|
|
2104
|
+
};
|
|
2105
|
+
return { agent, contextManager, cleanup };
|
|
2106
|
+
}
|
|
2107
|
+
catch (error) {
|
|
2108
|
+
this.usedEphemeralAgentNames.delete(config.name);
|
|
2109
|
+
throw error;
|
|
2110
|
+
}
|
|
2007
2111
|
}
|
|
2008
2112
|
createDeferred() {
|
|
2009
2113
|
let resolve;
|
|
@@ -2024,6 +2128,18 @@ export class AgentFramework {
|
|
|
2024
2128
|
}
|
|
2025
2129
|
run.lastActivity = Date.now();
|
|
2026
2130
|
}
|
|
2131
|
+
/** Record structured calls for the current logical turn. Ignore late stream
|
|
2132
|
+
* events after an ephemeral/conversation agent has been disposed. */
|
|
2133
|
+
recordLogicalTurnToolCalls(agent, turnToken, count) {
|
|
2134
|
+
if (count <= 0 || this.agents.get(agent.name) !== agent)
|
|
2135
|
+
return;
|
|
2136
|
+
const state = this.logicalTurnToolCalls.get(agent);
|
|
2137
|
+
// The Agent object is the generation: a late stream from a disposed
|
|
2138
|
+
// ephemeral/conversation fork cannot contaminate a same-name successor.
|
|
2139
|
+
if (!state || state.turnToken !== turnToken)
|
|
2140
|
+
return;
|
|
2141
|
+
this.logicalTurnToolCalls.set(agent, { turnToken, count: state.count + count });
|
|
2142
|
+
}
|
|
2027
2143
|
recordEphemeralToolCalls(agentName, count) {
|
|
2028
2144
|
const run = this.ephemeralRuns.get(agentName);
|
|
2029
2145
|
if (!run)
|
|
@@ -2060,7 +2176,18 @@ export class AgentFramework {
|
|
|
2060
2176
|
* The task message should already be in the context manager.
|
|
2061
2177
|
*/
|
|
2062
2178
|
async runEphemeralToCompletion(agent, contextManager, watchdogs) {
|
|
2063
|
-
//
|
|
2179
|
+
// Only a fresh object returned by createEphemeralAgent may enter this path.
|
|
2180
|
+
// Never overwrite a resident/conversation owner or a concurrent run: their
|
|
2181
|
+
// cleanup is name-keyed and could cancel/deregister the legitimate owner.
|
|
2182
|
+
if (this.ephemeralCandidates.get(agent) !== contextManager) {
|
|
2183
|
+
throw new Error(`Ephemeral agent "${agent.name}" has no fresh generation ticket from this framework`);
|
|
2184
|
+
}
|
|
2185
|
+
// Consume before any await/registration: the exact (Agent, ContextManager)
|
|
2186
|
+
// generation is one-shot even if startup later fails.
|
|
2187
|
+
this.ephemeralCandidates.delete(agent);
|
|
2188
|
+
if (this.agents.has(agent.name) || this.ephemeralRuns.has(agent.name)) {
|
|
2189
|
+
throw new Error(`Ephemeral agent "${agent.name}" is already registered or running`);
|
|
2190
|
+
}
|
|
2064
2191
|
this.agents.set(agent.name, agent);
|
|
2065
2192
|
const run = {
|
|
2066
2193
|
settle: this.createDeferred(),
|
|
@@ -2119,8 +2246,27 @@ export class AgentFramework {
|
|
|
2119
2246
|
clearTimeout(startupWatchdog);
|
|
2120
2247
|
if (completionWatchdog)
|
|
2121
2248
|
clearInterval(completionWatchdog);
|
|
2122
|
-
|
|
2123
|
-
this
|
|
2249
|
+
// Mark before cancellation: an adapter may synchronously settle its
|
|
2250
|
+
// iterator, and this physical frame still owns terminal typing/outgoing
|
|
2251
|
+
// closure even though the ephemeral name is about to be deregistered.
|
|
2252
|
+
this.disposedEphemeralFrames.add(agent);
|
|
2253
|
+
// Cancel before deregistration. Adapters may still yield a queued event;
|
|
2254
|
+
// driveStream's Agent-identity check above discards it before dispatch.
|
|
2255
|
+
agent.cancelStream();
|
|
2256
|
+
if (this.ephemeralRuns.get(agent.name) === run)
|
|
2257
|
+
this.ephemeralRuns.delete(agent.name);
|
|
2258
|
+
if (this.agents.get(agent.name) === agent) {
|
|
2259
|
+
// Exact-generation disposal owns these name-keyed entries. Remove them
|
|
2260
|
+
// before deregistration; the late driveStream finally will see the
|
|
2261
|
+
// generation mismatch and leave any future owner untouched.
|
|
2262
|
+
this.activeStreams.delete(agent.name);
|
|
2263
|
+
this.pendingAssistantBlocks.delete(agent.name);
|
|
2264
|
+
// The late stream finalizer intentionally refuses name-keyed cleanup once
|
|
2265
|
+
// deregistered, so disposal must release EventGate liveness itself.
|
|
2266
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
2267
|
+
this.agents.delete(agent.name);
|
|
2268
|
+
}
|
|
2269
|
+
this.toolImageLedgers.delete(agent.name);
|
|
2124
2270
|
// Spawn-and-dispose bookkeeping (main, d453165/fee96a7): without this,
|
|
2125
2271
|
// ephemeral agents leave checkpoint-tree keys and diagnostics map
|
|
2126
2272
|
// entries behind for the life of the store/session.
|
|
@@ -2132,6 +2278,7 @@ export class AgentFramework {
|
|
|
2132
2278
|
// of the map, and a late driveStream finally token-match no-ops.
|
|
2133
2279
|
this.activeTurnTokens.delete(agent.name);
|
|
2134
2280
|
this.activeTurnTriggers.delete(agent.name);
|
|
2281
|
+
this.logicalTurnToolCalls.delete(agent);
|
|
2135
2282
|
}
|
|
2136
2283
|
}
|
|
2137
2284
|
/**
|
|
@@ -2144,6 +2291,44 @@ export class AgentFramework {
|
|
|
2144
2291
|
* Query inference logs.
|
|
2145
2292
|
* Returns entries with summary info (doesn't resolve blobs).
|
|
2146
2293
|
*/
|
|
2294
|
+
/** Tune-out (issue #77): divert a channel to your subconscious. Present
|
|
2295
|
+
* only when the subconscious resident is configured. */
|
|
2296
|
+
static TUNE_OUT_TOOL = {
|
|
2297
|
+
name: 'tune_out',
|
|
2298
|
+
description: 'Divert a channel to your subconscious for a while: its traffic stops ' +
|
|
2299
|
+
'entering your context and stops waking you. Your subconscious watches ' +
|
|
2300
|
+
'it instead — summarizing on a cadence in its own voice, judging ' +
|
|
2301
|
+
'whether mentions merit interrupting, and able to cancel. People who ' +
|
|
2302
|
+
'mention you get an automatic reaction so they know you saw nothing. ' +
|
|
2303
|
+
'Cancelling (mode "cancel") delivers the diverted backlog. The ' +
|
|
2304
|
+
'tune-out auto-cancels after maxWakes mention-bursts.',
|
|
2305
|
+
inputSchema: {
|
|
2306
|
+
type: 'object',
|
|
2307
|
+
properties: {
|
|
2308
|
+
channelId: { type: 'string' },
|
|
2309
|
+
mode: { type: 'string', enum: ['enter', 'cancel'], description: 'Default: enter.' },
|
|
2310
|
+
cadenceSeconds: {
|
|
2311
|
+
type: 'number',
|
|
2312
|
+
description: `Summary cadence in seconds (default ${TUNE_OUT_DEFAULTS.cadenceSeconds}; floors at 30).`,
|
|
2313
|
+
},
|
|
2314
|
+
backlogCap: {
|
|
2315
|
+
type: 'number',
|
|
2316
|
+
description: `Max raw messages delivered at cancel (default ${TUNE_OUT_DEFAULTS.backlogCap}). ` +
|
|
2317
|
+
'0 = deliver no raw backlog at all: your subconscious\'s report is the whole delivery.',
|
|
2318
|
+
},
|
|
2319
|
+
maxWakes: {
|
|
2320
|
+
type: 'number',
|
|
2321
|
+
description: `Wake budget before auto-cancel (default ${TUNE_OUT_DEFAULTS.maxWakes}; floors at 1).`,
|
|
2322
|
+
},
|
|
2323
|
+
durationSeconds: {
|
|
2324
|
+
type: 'number',
|
|
2325
|
+
description: 'Optional bounded duration: the tune-out auto-cancels (backlog ' +
|
|
2326
|
+
'delivered) after this many seconds; floors at 60. Unset = until cancelled.',
|
|
2327
|
+
},
|
|
2328
|
+
},
|
|
2329
|
+
required: ['channelId'],
|
|
2330
|
+
},
|
|
2331
|
+
};
|
|
2147
2332
|
/** Synthesized sleep/wake tool definitions (present when a gate is wired). */
|
|
2148
2333
|
static SLEEP_TOOLS = [
|
|
2149
2334
|
{
|
|
@@ -2324,11 +2509,18 @@ export class AgentFramework {
|
|
|
2324
2509
|
static SAVE_IMAGE_TOOL = {
|
|
2325
2510
|
name: 'save_recent_image',
|
|
2326
2511
|
description: 'Save one or more recent images from your own context to workspace files. ' +
|
|
2327
|
-
'Images are counted back from the most recent (index 0)
|
|
2512
|
+
'Images are counted back from the most recent (index 0), across BOTH ' +
|
|
2513
|
+
'attachments in messages and images returned by tools (their history ' +
|
|
2514
|
+
'placeholder shows a ref like `[image: image/png, ~691KB, ref img_k7x3q2_7]`; pass ' +
|
|
2515
|
+
'`ref` to save that exact image regardless of position). A single image is ' +
|
|
2328
2516
|
'written to `path` as given (e.g. "project/photos/cat.png"); when `count` > 1 ' +
|
|
2329
2517
|
'the range index..index+count-1 is saved with numeric suffixes ' +
|
|
2330
|
-
'("cat-0.png", "cat-1.png", …; 0 = the newest of the range).
|
|
2331
|
-
'
|
|
2518
|
+
'("cat-0.png", "cat-1.png", …; 0 = the newest of the range). If the image at ' +
|
|
2519
|
+
'the requested position is no longer retained (evicted, or from before a ' +
|
|
2520
|
+
'restart) the call fails and writes nothing — it never substitutes an older ' +
|
|
2521
|
+
'image. When other tool calls in the same batch are still running, the save ' +
|
|
2522
|
+
'waits for them so their images are counted. Receipts carry source tool call, MIME, byte size and SHA-256. Saved ' +
|
|
2523
|
+
'files are visible via workspace tools and the /files/ endpoint.',
|
|
2332
2524
|
inputSchema: {
|
|
2333
2525
|
type: 'object',
|
|
2334
2526
|
properties: {
|
|
@@ -2344,6 +2536,11 @@ export class AgentFramework {
|
|
|
2344
2536
|
type: 'number',
|
|
2345
2537
|
description: 'How many images to save, starting at `index` and going further back. Default 1.',
|
|
2346
2538
|
},
|
|
2539
|
+
ref: {
|
|
2540
|
+
type: 'string',
|
|
2541
|
+
description: '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. ' +
|
|
2542
|
+
'Mutually exclusive with `index`/`count`.',
|
|
2543
|
+
},
|
|
2347
2544
|
},
|
|
2348
2545
|
required: ['path'],
|
|
2349
2546
|
},
|
|
@@ -3395,6 +3592,15 @@ export class AgentFramework {
|
|
|
3395
3592
|
// streaming agent keeps the original Membrane.
|
|
3396
3593
|
membrane: this.auxiliaryMembraneFor(config.name),
|
|
3397
3594
|
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
3595
|
+
// Tune-out (issue #77): messages stamped at ingestion as diverted
|
|
3596
|
+
// (metadata.tuneOut = { epochId }) never enter a resident's compiled
|
|
3597
|
+
// view — not at emission, not via chunking into the memory pyramid.
|
|
3598
|
+
// The stamp is permanent: after cancel, the backlog arrives as one
|
|
3599
|
+
// appended <tuned-out-backlog> dump instead of retro-inserting into
|
|
3600
|
+
// the timeline (KV-prefix-stable delivery). The stored originals
|
|
3601
|
+
// remain in the shared slot for the subconscious's merged view,
|
|
3602
|
+
// fetch_history, and audit.
|
|
3603
|
+
viewFilter: (message) => !message.metadata?.tuneOut,
|
|
3398
3604
|
});
|
|
3399
3605
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
3400
3606
|
const restoredSettings = this.readAgentRuntimeSettings(config.name);
|
|
@@ -3409,6 +3615,68 @@ export class AgentFramework {
|
|
|
3409
3615
|
}
|
|
3410
3616
|
return agent;
|
|
3411
3617
|
}
|
|
3618
|
+
/**
|
|
3619
|
+
* Create and register the subconscious resident (issue #77): a persistent
|
|
3620
|
+
* side-agent with an isolated slot (`subconscious/<primary>`) whose
|
|
3621
|
+
* strategy view merges the residents' shared timeline with its own —
|
|
3622
|
+
* including the tuned-out-stamped messages the residents' viewFilter
|
|
3623
|
+
* excludes; the backlog is precisely what it exists to see. Windowed
|
|
3624
|
+
* passthrough, no memory pyramid: its durable output is what it delivers
|
|
3625
|
+
* into the resident's window, which accumulates there.
|
|
3626
|
+
*/
|
|
3627
|
+
async createSubconsciousAgent(cfg) {
|
|
3628
|
+
const primaryName = this.primaryAgentName;
|
|
3629
|
+
const primaryConfig = primaryName ? this.agentConfigs.get(primaryName) : undefined;
|
|
3630
|
+
if (!primaryName || !primaryConfig) {
|
|
3631
|
+
throw new Error('subconscious requires a primary agent to attend to');
|
|
3632
|
+
}
|
|
3633
|
+
const name = cfg.name ?? 'Subconscious';
|
|
3634
|
+
if (this.agents.has(name)) {
|
|
3635
|
+
throw new Error(`subconscious name "${name}" collides with a registered agent`);
|
|
3636
|
+
}
|
|
3637
|
+
const strategy = new WindowedPassthroughStrategy({
|
|
3638
|
+
reAnchorFraction: cfg.reAnchorFraction,
|
|
3639
|
+
// Mirror the principal's per-message truncation ceiling so both see
|
|
3640
|
+
// oversized tool results / attachments under the same policy.
|
|
3641
|
+
maxMessageTokens: primaryConfig.strategy?.maxMessageTokens,
|
|
3642
|
+
});
|
|
3643
|
+
const contextManager = await ContextManager.open({
|
|
3644
|
+
store: this.store,
|
|
3645
|
+
namespace: `subconscious/${primaryName}`,
|
|
3646
|
+
isolate: true,
|
|
3647
|
+
// The residents' shared un-namespaced slot, merged read-only and
|
|
3648
|
+
// UNfiltered (no viewFilter here — stamped messages included).
|
|
3649
|
+
auxiliaryMessageViews: [{}],
|
|
3650
|
+
strategy,
|
|
3651
|
+
membrane: this.membrane,
|
|
3652
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
3653
|
+
});
|
|
3654
|
+
// Inherit the principal's inference settings wholesale (fork-template
|
|
3655
|
+
// idiom): reasoning effort (thinking), provider params, token ceilings,
|
|
3656
|
+
// caching, refusal handling, timezone — a same-model side-process must
|
|
3657
|
+
// not silently run under different inference conditions than the
|
|
3658
|
+
// resident it serves. Only identity- and surface-defining fields are
|
|
3659
|
+
// overridden below.
|
|
3660
|
+
const agentConfig = {
|
|
3661
|
+
...primaryConfig,
|
|
3662
|
+
name,
|
|
3663
|
+
model: cfg.model ?? primaryConfig.model,
|
|
3664
|
+
systemPrompt: cfg.systemPrompt,
|
|
3665
|
+
strategy: undefined, // its CM owns the windowed strategy instance
|
|
3666
|
+
// Prose is never auto-routed: a cadence-triggered turn carries no
|
|
3667
|
+
// locus, and bare prose must not fall through to the default channel.
|
|
3668
|
+
// Speech happens only via speak_in_channel (its own marked voice).
|
|
3669
|
+
proseRouting: 'explicit',
|
|
3670
|
+
allowedTools: [...SUBCONSCIOUS_TOOL_NAMES, 'think', 'skip_reply', 'end_turn'],
|
|
3671
|
+
};
|
|
3672
|
+
const agent = new Agent(agentConfig, contextManager, this.membrane);
|
|
3673
|
+
this.agents.set(name, agent);
|
|
3674
|
+
this.agentConfigs.set(name, agentConfig);
|
|
3675
|
+
this.subconsciousAgentName = name;
|
|
3676
|
+
this.subconsciousStrategy = strategy;
|
|
3677
|
+
this.subconsciousConfig = cfg;
|
|
3678
|
+
return agent;
|
|
3679
|
+
}
|
|
3412
3680
|
async runLoop() {
|
|
3413
3681
|
while (this.running) {
|
|
3414
3682
|
try {
|
|
@@ -3508,7 +3776,7 @@ export class AgentFramework {
|
|
|
3508
3776
|
// history copy and the live wire copy below — the two must stay
|
|
3509
3777
|
// byte-matched (the window stores what the membrane sends;
|
|
3510
3778
|
// divergence breaks the compile prefix).
|
|
3511
|
-
const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(currentState.toolResults, maxChars);
|
|
3779
|
+
const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(agent.name, currentState.toolResults, maxChars);
|
|
3512
3780
|
agent.getContextManager().addMessage('user', toolResultContent);
|
|
3513
3781
|
// Flush any messages that were deferred while this turn was in
|
|
3514
3782
|
// flight. Route to the PRIMARY agent — deferred messages are
|
|
@@ -3533,12 +3801,11 @@ export class AgentFramework {
|
|
|
3533
3801
|
// queued, they flush at the target's own next boundary — with
|
|
3534
3802
|
// injection — or in driveStream's finally when its turn ends.
|
|
3535
3803
|
const midTurnInjections = [];
|
|
3536
|
-
|
|
3537
|
-
const
|
|
3538
|
-
if (
|
|
3539
|
-
const deferred = this.deferredMessages.splice(0);
|
|
3804
|
+
{
|
|
3805
|
+
const deferred = this.drainDeferredFor(agent.name);
|
|
3806
|
+
if (deferred.length > 0) {
|
|
3540
3807
|
for (const msg of deferred) {
|
|
3541
|
-
|
|
3808
|
+
agent.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
|
|
3542
3809
|
// Injection guards: tool blocks would corrupt the tool-cycle
|
|
3543
3810
|
// structure the membrane enforces, and a message named as the
|
|
3544
3811
|
// agent itself would render as an ASSISTANT turn on the wire
|
|
@@ -3835,7 +4102,7 @@ export class AgentFramework {
|
|
|
3835
4102
|
// Broadcast requests exclude conversation forks — they are driven by
|
|
3836
4103
|
// their own channel's messages, not by framework-wide events.
|
|
3837
4104
|
const targetAgents = response.requestInference === true
|
|
3838
|
-
? Array.from(this.agents.keys()).filter((n) => !this.conversationAgentHomes.has(n))
|
|
4105
|
+
? Array.from(this.agents.keys()).filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName)
|
|
3839
4106
|
: response.requestInference;
|
|
3840
4107
|
for (const agentName of targetAgents) {
|
|
3841
4108
|
const agent = this.agents.get(agentName);
|
|
@@ -3902,11 +4169,31 @@ export class AgentFramework {
|
|
|
3902
4169
|
Object.assign(metadata, invitation.metadataPatch);
|
|
3903
4170
|
}
|
|
3904
4171
|
}
|
|
4172
|
+
// Tune-out divert (issue #77): stamped, stored, no resident wake. The
|
|
4173
|
+
// coordinator has already handled wake bookkeeping (coalesced
|
|
4174
|
+
// subconscious invocation, durable count, suppression ack) before we
|
|
4175
|
+
// stamp; the subconscious reads the message through its merged view.
|
|
4176
|
+
const divert = this.tuneOutCoordinator?.onIncoming(event.serverId, event.channelId, event.messageId, event.tags, event.author?.id,
|
|
4177
|
+
// The resident's gate verdict rides the event; it preconditions
|
|
4178
|
+
// subconscious wakes (gate composes, it is not replaced).
|
|
4179
|
+
event.triggerInference !== false) ?? null;
|
|
4180
|
+
if (divert) {
|
|
4181
|
+
metadata.tuneOut = { epochId: divert.epochId };
|
|
4182
|
+
}
|
|
3905
4183
|
const id = this.addMessage('user', incomingContent, metadata);
|
|
3906
4184
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
|
|
3907
|
-
if (event.triggerInference) {
|
|
4185
|
+
if (event.triggerInference && !divert) {
|
|
3908
4186
|
const addressed = isAddressedMessage(event.tags, event.metadata);
|
|
3909
|
-
|
|
4187
|
+
// Honor explicit targeting, mirroring the push-event path: an event
|
|
4188
|
+
// that names its agents wakes exactly those; an untargeted event
|
|
4189
|
+
// keeps the historical broadcast. (targetAgents was declared on
|
|
4190
|
+
// McplChannelIncomingEvent from the start but never honored here —
|
|
4191
|
+
// tune-out's wake routing is the first setter.)
|
|
4192
|
+
const targetAgents = event.targetAgents
|
|
4193
|
+
?? [...this.agents.keys()].filter((n) => n !== this.subconsciousAgentName);
|
|
4194
|
+
for (const agentName of targetAgents) {
|
|
4195
|
+
if (!this.agents.has(agentName))
|
|
4196
|
+
continue;
|
|
3910
4197
|
this.pendingRequests.push({
|
|
3911
4198
|
agentName,
|
|
3912
4199
|
reason: 'mcpl:channel-incoming',
|
|
@@ -4019,38 +4306,48 @@ export class AgentFramework {
|
|
|
4019
4306
|
if (!template || !templateConfig) {
|
|
4020
4307
|
throw new Error(`conversation template agent "${router.templateAgent}" not found`);
|
|
4021
4308
|
}
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4309
|
+
if (this.usedEphemeralAgentNames.has(name) || this.agents.has(name)) {
|
|
4310
|
+
throw new Error(`Conversation agent name "${name}" is already registered or reserved`);
|
|
4311
|
+
}
|
|
4312
|
+
this.usedEphemeralAgentNames.add(name);
|
|
4313
|
+
try {
|
|
4314
|
+
const contextManager = await ContextManager.open({
|
|
4315
|
+
store: this.store,
|
|
4316
|
+
namespace: `conversations/${name}`,
|
|
4317
|
+
isolate: true,
|
|
4318
|
+
// Strategy instances are stateful — never share the template's.
|
|
4319
|
+
strategy: router.strategyFactory?.() ?? new PassthroughStrategy(),
|
|
4320
|
+
// Dynamic conversation forks retain the established provider policy in
|
|
4321
|
+
// this bounded first slice. Provider cooldown ownership belongs to the
|
|
4322
|
+
// persistent resident only; generation-unique forks must not leak gates.
|
|
4323
|
+
membrane: this.membrane,
|
|
4324
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
4325
|
+
});
|
|
4326
|
+
// Seed with the template's compiled context, renaming the template
|
|
4327
|
+
// participant so the fork reads its inheritance as its own history.
|
|
4328
|
+
// Guard: only seed a genuinely fresh namespace. Generation counters are
|
|
4329
|
+
// persisted precisely so names aren't reused, but if this namespace has
|
|
4330
|
+
// history anyway (counter state lost, crash between spawn and persist),
|
|
4331
|
+
// seeding again would stack another template copy on top of it.
|
|
4332
|
+
const { messages: existing } = await contextManager.compile();
|
|
4333
|
+
if (existing.length === 0) {
|
|
4334
|
+
const { messages: compiled } = await template.getContextManager().compile();
|
|
4335
|
+
for (const msg of compiled) {
|
|
4336
|
+
const participant = msg.participant === template.name ? name : msg.participant;
|
|
4337
|
+
contextManager.addMessage(participant, msg.content);
|
|
4338
|
+
}
|
|
4339
|
+
}
|
|
4340
|
+
const config = { ...templateConfig, name, strategy: undefined };
|
|
4341
|
+
const agent = new Agent(config, contextManager, this.membrane);
|
|
4342
|
+
this.agents.set(name, agent);
|
|
4343
|
+
this.agentConfigs.set(name, config);
|
|
4344
|
+
this.conversationAgentHomes.set(name, channelId);
|
|
4345
|
+
return agent;
|
|
4346
|
+
}
|
|
4347
|
+
catch (error) {
|
|
4348
|
+
this.usedEphemeralAgentNames.delete(name);
|
|
4349
|
+
throw error;
|
|
4350
|
+
}
|
|
4054
4351
|
}
|
|
4055
4352
|
/** Persist the router's generation counters (see hydration in create()). */
|
|
4056
4353
|
persistConversationRouterState() {
|
|
@@ -4074,10 +4371,14 @@ export class AgentFramework {
|
|
|
4074
4371
|
disposeConversationAgent(agentName) {
|
|
4075
4372
|
this.closingConversationAgents.delete(agentName);
|
|
4076
4373
|
const channelId = this.conversationAgentHomes.get(agentName);
|
|
4374
|
+
const agent = this.agents.get(agentName);
|
|
4077
4375
|
this.agents.delete(agentName);
|
|
4376
|
+
this.toolImageLedgers.delete(agentName);
|
|
4078
4377
|
this.agentConfigs.delete(agentName);
|
|
4079
4378
|
this.conversationAgentHomes.delete(agentName);
|
|
4080
4379
|
this.evictTurnCheckpoints(agentName);
|
|
4380
|
+
if (agent)
|
|
4381
|
+
this.logicalTurnToolCalls.delete(agent);
|
|
4081
4382
|
this.emitTrace({
|
|
4082
4383
|
type: 'mcpl:conversation-disposed',
|
|
4083
4384
|
agentName,
|
|
@@ -4277,7 +4578,7 @@ export class AgentFramework {
|
|
|
4277
4578
|
if (event.triggerInference) {
|
|
4278
4579
|
// Default broadcast excludes conversation forks (channel-driven).
|
|
4279
4580
|
const targetAgents = event.targetAgents
|
|
4280
|
-
?? [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n));
|
|
4581
|
+
?? [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
|
|
4281
4582
|
for (const agentName of targetAgents) {
|
|
4282
4583
|
this.pendingRequests.push({
|
|
4283
4584
|
agentName,
|
|
@@ -4459,6 +4760,10 @@ export class AgentFramework {
|
|
|
4459
4760
|
// exactly this class of silent drop. One stderr line per drop.
|
|
4460
4761
|
console.error(`[inference-dropped] agent=${agentName} reason=policy-skip ` +
|
|
4461
4762
|
`requests=${requests.length} triggers=${requests.map((r) => r.reason).join(',')}`);
|
|
4763
|
+
// A context-budget restart inherits the predecessor's EventGate liveness.
|
|
4764
|
+
// If policy drops the queued successor, no driveStream remains to release it.
|
|
4765
|
+
if (budgetRestart)
|
|
4766
|
+
this.eventGate?.onInferenceEnded(agentName);
|
|
4462
4767
|
const gate = this.providerGates.get(agentName);
|
|
4463
4768
|
if (gate?.primaryPending && this.providerAccelerationRecoveries.has(agentName)) {
|
|
4464
4769
|
gate.primaryPending = false;
|
|
@@ -4499,6 +4804,19 @@ export class AgentFramework {
|
|
|
4499
4804
|
addressedReq = r;
|
|
4500
4805
|
}
|
|
4501
4806
|
const channelReq = addressedReq ?? ambientReq;
|
|
4807
|
+
// Telemetry provenance (who/where woke the agent): from the routing
|
|
4808
|
+
// winner when it carries one; else from the most recent request that
|
|
4809
|
+
// does (gate wakes name a counterparty without setting a locus),
|
|
4810
|
+
// ordered by the event's own time (wakeAt) rather than flush order.
|
|
4811
|
+
let provReq = channelReq?.counterparty || channelReq?.wakeChannelId ? channelReq : undefined;
|
|
4812
|
+
if (!provReq) {
|
|
4813
|
+
for (const r of requests) {
|
|
4814
|
+
if (!r.counterparty && !r.wakeChannelId)
|
|
4815
|
+
continue;
|
|
4816
|
+
if (!provReq || (r.wakeAt ?? r.timestamp) >= (provReq.wakeAt ?? provReq.timestamp))
|
|
4817
|
+
provReq = r;
|
|
4818
|
+
}
|
|
4819
|
+
}
|
|
4502
4820
|
await this.startAgentStream(agent, {
|
|
4503
4821
|
...trigger,
|
|
4504
4822
|
channelId: channelReq?.channelId,
|
|
@@ -4507,7 +4825,9 @@ export class AgentFramework {
|
|
|
4507
4825
|
// the channel for routing but names no author — the restart is its
|
|
4508
4826
|
// own cause, and borrowing another request's author would be false
|
|
4509
4827
|
// provenance.
|
|
4510
|
-
counterparty: budgetRestart ? undefined :
|
|
4828
|
+
counterparty: budgetRestart ? undefined : provReq?.counterparty,
|
|
4829
|
+
wakeChannelId: budgetRestart ? undefined : provReq?.wakeChannelId,
|
|
4830
|
+
wakeAt: budgetRestart ? undefined : provReq?.wakeAt,
|
|
4511
4831
|
});
|
|
4512
4832
|
}
|
|
4513
4833
|
}
|
|
@@ -5012,9 +5332,8 @@ export class AgentFramework {
|
|
|
5012
5332
|
if (attempt === 0 &&
|
|
5013
5333
|
trigger?.reason !== 'context_budget_restart' &&
|
|
5014
5334
|
this.deferredMessages.length > 0) {
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
const deferred = this.deferredMessages.splice(0);
|
|
5335
|
+
{
|
|
5336
|
+
const deferred = this.drainDeferredFor(agent.name);
|
|
5018
5337
|
for (const msg of deferred) {
|
|
5019
5338
|
// Per-message try/catch (mirrors announceLocusIfChanged): one
|
|
5020
5339
|
// poison message — or a transient store-write failure — must not
|
|
@@ -5068,9 +5387,22 @@ export class AgentFramework {
|
|
|
5068
5387
|
// BEFORE this turn compiles, so the agent always knows where its voice
|
|
5069
5388
|
// goes (announce-on-change only — no per-turn chatter, append-only for
|
|
5070
5389
|
// KV stability).
|
|
5390
|
+
if (trigger?.reason === 'context_budget_restart') {
|
|
5391
|
+
const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
5392
|
+
this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
|
|
5393
|
+
}
|
|
5071
5394
|
if (trigger?.reason !== 'context_budget_restart') {
|
|
5072
5395
|
if (attempt === 0)
|
|
5073
5396
|
this.maybePrimeProseMode(agent);
|
|
5397
|
+
const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
5398
|
+
if (attempt === 0) {
|
|
5399
|
+
// A true new turn gets a fresh generation and count.
|
|
5400
|
+
this.logicalTurnToolCalls.set(agent, { turnToken, count: 0 });
|
|
5401
|
+
}
|
|
5402
|
+
else {
|
|
5403
|
+
// A framework retry is a new physical stream in the same logical turn.
|
|
5404
|
+
this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
|
|
5405
|
+
}
|
|
5074
5406
|
// Fresh turn: forget the previous turn's explicit-send engagements and
|
|
5075
5407
|
// prose deliveries — both are strictly turn-scoped (a restart continues
|
|
5076
5408
|
// the same logical turn and keeps them).
|
|
@@ -5108,7 +5440,11 @@ export class AgentFramework {
|
|
|
5108
5440
|
// context-budget restart, which skips the re-pin but re-emits this).
|
|
5109
5441
|
channelId: this.turnLocusPins.get(agent.name),
|
|
5110
5442
|
});
|
|
5111
|
-
|
|
5443
|
+
// A budget restart continues the same logical inference window. The
|
|
5444
|
+
// predecessor keeps EventGate liveness until its successor terminates.
|
|
5445
|
+
if (trigger?.reason !== 'context_budget_restart') {
|
|
5446
|
+
this.eventGate?.onInferenceStarted(agent.name);
|
|
5447
|
+
}
|
|
5112
5448
|
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
|
|
5113
5449
|
// Typing indicator, started at TURN START. It says "attending", which is
|
|
5114
5450
|
// true from the moment the turn is accepted — but it used to start inside
|
|
@@ -5162,8 +5498,32 @@ export class AgentFramework {
|
|
|
5162
5498
|
console.error('beforeInference hook error:', error);
|
|
5163
5499
|
}
|
|
5164
5500
|
}
|
|
5501
|
+
// An ephemeral watchdog may dispose this Agent while hooks/compile await.
|
|
5502
|
+
// Do not create a provider stream after its generation lost ownership.
|
|
5503
|
+
if (this.agents.get(agent.name) !== agent) {
|
|
5504
|
+
this.channelRegistry?.stopTyping();
|
|
5505
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
5506
|
+
return false;
|
|
5507
|
+
}
|
|
5508
|
+
// The subconscious's standing dispositions ride a fixed system-position
|
|
5509
|
+
// injection (antra, #tuneout-talk): never repeated in its timeline, and
|
|
5510
|
+
// — since it has no long-term memory — structurally unable to wash out
|
|
5511
|
+
// of its window. Bytes change only when a disposition changes.
|
|
5512
|
+
if (agent.name === this.subconsciousAgentName && this.tuneOutCoordinator) {
|
|
5513
|
+
const dispositionInjection = this.tuneOutCoordinator.getDispositionsInjection();
|
|
5514
|
+
if (dispositionInjection) {
|
|
5515
|
+
injections = injections ? [...injections, dispositionInjection] : [dispositionInjection];
|
|
5516
|
+
}
|
|
5517
|
+
}
|
|
5165
5518
|
const { stream, request: compiledRequest, takeKvSubmission, drainKvSubmissionIds, } = await agent.startStreamWithInjections(tools, injections);
|
|
5166
|
-
|
|
5519
|
+
if (this.agents.get(agent.name) !== agent) {
|
|
5520
|
+
stream.cancel();
|
|
5521
|
+
agent.cancelStream();
|
|
5522
|
+
this.channelRegistry?.stopTyping();
|
|
5523
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
5524
|
+
return false;
|
|
5525
|
+
}
|
|
5526
|
+
const handle = this.driveStream(agent, stream, requestSnapshot, trigger, attempt, compiledRequest, ownsProviderGate, takeKvSubmission, drainKvSubmissionIds, new Set(tools.map((tool) => tool.name)));
|
|
5167
5527
|
this.activeStreams.set(agent.name, handle);
|
|
5168
5528
|
// Handoff: driveStream captured the token in its synchronous prefix;
|
|
5169
5529
|
// its finally now owns clearing it. The caller's finally must not.
|
|
@@ -5236,7 +5596,7 @@ export class AgentFramework {
|
|
|
5236
5596
|
return false;
|
|
5237
5597
|
}
|
|
5238
5598
|
}
|
|
5239
|
-
async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest, ownsProviderGate = false, takeKvSubmission, drainKvSubmissionIds) {
|
|
5599
|
+
async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest, ownsProviderGate = false, takeKvSubmission, drainKvSubmissionIds, knownToolNames = new Set()) {
|
|
5240
5600
|
const startTime = Date.now();
|
|
5241
5601
|
const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
|
|
5242
5602
|
const myStreamId = agent.streamId;
|
|
@@ -5255,6 +5615,7 @@ export class AgentFramework {
|
|
|
5255
5615
|
// means no successor turn can have replaced it while we compiled.
|
|
5256
5616
|
const myTurnToken = this.activeTurnTokens.get(agent.name);
|
|
5257
5617
|
let hadToolCalls = false;
|
|
5618
|
+
let generationLost = false;
|
|
5258
5619
|
// ---- Present-while-acting turn state ---------------------------------
|
|
5259
5620
|
// Output locus for the WHOLE logical turn: frozen in startAgentStream
|
|
5260
5621
|
// (home → addressed trigger → global default) before this stream began,
|
|
@@ -5329,13 +5690,20 @@ export class AgentFramework {
|
|
|
5329
5690
|
// failed, a framework-cancelled stream sets aborted. Best-effort
|
|
5330
5691
|
// notifications — consumers dedupe by inferenceId and keep a timeout.
|
|
5331
5692
|
let lifecyclePhase = 'completed';
|
|
5693
|
+
let preserveEventGateForSuccessor = false;
|
|
5332
5694
|
this.hookOrchestrator?.emitLifecycle({
|
|
5333
5695
|
inferenceId: outgoingInferenceId,
|
|
5334
5696
|
conversationId: agent.name,
|
|
5335
5697
|
turnIndex: 0,
|
|
5336
5698
|
phase: 'started',
|
|
5337
5699
|
});
|
|
5338
|
-
|
|
5700
|
+
// The wrapper guard needs the complete response before it can distinguish
|
|
5701
|
+
// an exact invocation-shaped body from ordinary XML/prose. Buffer guarded
|
|
5702
|
+
// turns until completion: otherwise speculative outgoing chunks could
|
|
5703
|
+
// expose the wrapper before the fail-closed classifier runs.
|
|
5704
|
+
const proseStream = this.channelRegistry &&
|
|
5705
|
+
agent.proseRouting !== 'disabled' &&
|
|
5706
|
+
!agent.toolWrapperProseGuard
|
|
5339
5707
|
? new ProseStreamRouter({
|
|
5340
5708
|
mode: agent.proseRouting === 'explicit' ? 'explicit' : agent.proseRouting === 'hybrid' ? 'hybrid' : 'locus',
|
|
5341
5709
|
initialTarget: typingChannel,
|
|
@@ -5361,6 +5729,14 @@ export class AgentFramework {
|
|
|
5361
5729
|
};
|
|
5362
5730
|
try {
|
|
5363
5731
|
for await (const event of stream) {
|
|
5732
|
+
// Ignore every late event from an Agent generation that no longer owns
|
|
5733
|
+
// this name (ephemeral watchdog/disposal, conversation replacement).
|
|
5734
|
+
if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
|
|
5735
|
+
generationLost = true;
|
|
5736
|
+
lifecyclePhase = 'aborted';
|
|
5737
|
+
stream.cancel();
|
|
5738
|
+
break;
|
|
5739
|
+
}
|
|
5364
5740
|
this.touchEphemeralRun(agent.name, true);
|
|
5365
5741
|
switch (event.type) {
|
|
5366
5742
|
case 'tokens':
|
|
@@ -5423,6 +5799,7 @@ export class AgentFramework {
|
|
|
5423
5799
|
case 'tool-calls': {
|
|
5424
5800
|
adoptInjectedRound();
|
|
5425
5801
|
hadToolCalls = true;
|
|
5802
|
+
this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length);
|
|
5426
5803
|
this.recordEphemeralToolCalls(agent.name, event.calls.length);
|
|
5427
5804
|
this.emitTrace({
|
|
5428
5805
|
type: 'inference:tool_calls_yielded',
|
|
@@ -5588,7 +5965,7 @@ export class AgentFramework {
|
|
|
5588
5965
|
// one door a giant blob still walks through).
|
|
5589
5966
|
const readyState = agent.state;
|
|
5590
5967
|
if (readyState.status === 'ready') {
|
|
5591
|
-
const { blocks: toolResultContent } = await this.buildStoredToolResultContent(readyState.toolResults, this.resolveToolResultInlineCap(agent).cap);
|
|
5968
|
+
const { blocks: toolResultContent } = await this.buildStoredToolResultContent(agent.name, readyState.toolResults, this.resolveToolResultInlineCap(agent).cap);
|
|
5592
5969
|
agent.getContextManager().addMessage('user', toolResultContent);
|
|
5593
5970
|
}
|
|
5594
5971
|
}
|
|
@@ -5604,7 +5981,29 @@ export class AgentFramework {
|
|
|
5604
5981
|
const terminalContent = lastToolIdx >= 0
|
|
5605
5982
|
? response.content.slice(lastToolIdx + 1)
|
|
5606
5983
|
: response.content;
|
|
5607
|
-
|
|
5984
|
+
// This is a whole-response boundary, not a trailing-prose
|
|
5985
|
+
// classifier. If the turn executed any genuine structured tool
|
|
5986
|
+
// call, preserve its later prose exactly as ordinary history.
|
|
5987
|
+
const logicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
5988
|
+
const logicalTurnHadToolCalls = logicalToolState?.turnToken === myTurnToken && (logicalToolState?.count ?? 0) > 0;
|
|
5989
|
+
const guardedWrapperTool = agent.toolWrapperProseGuard && !logicalTurnHadToolCalls
|
|
5990
|
+
? detectKnownToolWrapperProse(response.content, knownToolNames)
|
|
5991
|
+
: null;
|
|
5992
|
+
if (guardedWrapperTool) {
|
|
5993
|
+
// AF #133: containment only. Never execute wrapper text, never
|
|
5994
|
+
// publish it, and never retain it as the freshest assistant
|
|
5995
|
+
// self-seed. Exact raw output remains in the provider ledger.
|
|
5996
|
+
agent.getContextManager().addMessage('user', [{
|
|
5997
|
+
type: 'text',
|
|
5998
|
+
text: '[tool-boundary] No tool was called.',
|
|
5999
|
+
}], {
|
|
6000
|
+
system: true,
|
|
6001
|
+
kind: 'tool-wrapper-prose-contained',
|
|
6002
|
+
toolName: guardedWrapperTool,
|
|
6003
|
+
});
|
|
6004
|
+
console.error(`[tool-boundary] ${agent.name}: contained whole-response prose wrapper for registered tool ${guardedWrapperTool}; no tool called`);
|
|
6005
|
+
}
|
|
6006
|
+
else if (lastToolIdx >= 0) {
|
|
5608
6007
|
if (terminalContent.length > 0) {
|
|
5609
6008
|
agent.addAssistantResponse(terminalContent);
|
|
5610
6009
|
}
|
|
@@ -5627,7 +6026,9 @@ export class AgentFramework {
|
|
|
5627
6026
|
// convention remains "text is thoughts"; on a text-only turn all
|
|
5628
6027
|
// text is speech.
|
|
5629
6028
|
const isTextBlock = (block) => block.type === 'text';
|
|
5630
|
-
const allText =
|
|
6029
|
+
const allText = guardedWrapperTool
|
|
6030
|
+
? []
|
|
6031
|
+
: response.content.filter(isTextBlock);
|
|
5631
6032
|
const speechContent = hadToolCalls ? [] : allText;
|
|
5632
6033
|
const thoughts = hadToolCalls ? allText : [];
|
|
5633
6034
|
const du = response.details?.usage;
|
|
@@ -5639,19 +6040,30 @@ export class AgentFramework {
|
|
|
5639
6040
|
cacheRead: du.cacheReadTokens,
|
|
5640
6041
|
}
|
|
5641
6042
|
: undefined;
|
|
6043
|
+
// Async completion work may overlap a replacement stream. Recheck
|
|
6044
|
+
// physical ownership before any terminal state/settlement side effect.
|
|
6045
|
+
if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
|
|
6046
|
+
generationLost = true;
|
|
6047
|
+
lifecyclePhase = 'aborted';
|
|
6048
|
+
stream.cancel();
|
|
6049
|
+
return;
|
|
6050
|
+
}
|
|
5642
6051
|
// Reset agent state before emitting inference:completed. Traces are
|
|
5643
6052
|
// observability-only, but external synchronous listeners should
|
|
5644
6053
|
// still see the terminal state at the terminal trace boundary.
|
|
5645
6054
|
// Speech dispatch happens after but doesn't depend on the status
|
|
5646
6055
|
// field.
|
|
5647
6056
|
agent.reset();
|
|
5648
|
-
this.
|
|
6057
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
|
|
6058
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
5649
6059
|
this.settleAgent(agent.name, {
|
|
5650
6060
|
stopReason: 'completed',
|
|
5651
|
-
speech:
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
6061
|
+
speech: guardedWrapperTool
|
|
6062
|
+
? ''
|
|
6063
|
+
: terminalContent
|
|
6064
|
+
.filter((block) => block.type === 'text')
|
|
6065
|
+
.map((block) => block.text)
|
|
6066
|
+
.join('\n'),
|
|
5655
6067
|
});
|
|
5656
6068
|
this.emitTrace({
|
|
5657
6069
|
type: 'inference:completed',
|
|
@@ -5992,7 +6404,8 @@ export class AgentFramework {
|
|
|
5992
6404
|
agent.reset();
|
|
5993
6405
|
if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
|
|
5994
6406
|
lifecyclePhase = 'failed';
|
|
5995
|
-
this.
|
|
6407
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
|
|
6408
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
5996
6409
|
break;
|
|
5997
6410
|
}
|
|
5998
6411
|
const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
|
|
@@ -6016,7 +6429,8 @@ export class AgentFramework {
|
|
|
6016
6429
|
// itself) means retrying the same context can never succeed.
|
|
6017
6430
|
...this.classifyInferenceError(err),
|
|
6018
6431
|
});
|
|
6019
|
-
this.
|
|
6432
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
|
|
6433
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
6020
6434
|
if (action.emit) {
|
|
6021
6435
|
this.pushEvent(action.emit);
|
|
6022
6436
|
}
|
|
@@ -6037,7 +6451,14 @@ export class AgentFramework {
|
|
|
6037
6451
|
if (cancelKind !== undefined) {
|
|
6038
6452
|
this.frameworkCancelledStreams.delete(cancelKey);
|
|
6039
6453
|
lifecyclePhase = 'aborted'; // §10.5 — terminal emitted in finally
|
|
6040
|
-
|
|
6454
|
+
// A budget restart continues the same EventGate window. Do not
|
|
6455
|
+
// end it here or in finally; the replacement owns final release.
|
|
6456
|
+
if (cancelKind === 'budget_restart') {
|
|
6457
|
+
preserveEventGateForSuccessor = true;
|
|
6458
|
+
}
|
|
6459
|
+
else if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) {
|
|
6460
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
6461
|
+
}
|
|
6041
6462
|
// endTurn IS a logical turn end — earlier rounds may have
|
|
6042
6463
|
// live-routed prose (narrate → skip_reply is a real shape),
|
|
6043
6464
|
// so settle the delivery chain and drop the receipt. A
|
|
@@ -6084,7 +6505,8 @@ export class AgentFramework {
|
|
|
6084
6505
|
request: compiledRequest ?? { note: 'streaming request aborted' },
|
|
6085
6506
|
durationMs,
|
|
6086
6507
|
});
|
|
6087
|
-
this.
|
|
6508
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
|
|
6509
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
6088
6510
|
}
|
|
6089
6511
|
break;
|
|
6090
6512
|
}
|
|
@@ -6160,6 +6582,14 @@ export class AgentFramework {
|
|
|
6160
6582
|
}
|
|
6161
6583
|
}
|
|
6162
6584
|
catch (error) {
|
|
6585
|
+
// A superseded physical stream may throw after its replacement starts.
|
|
6586
|
+
// It has no authority to settle, reset, gate-release, or publish failure.
|
|
6587
|
+
if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
|
|
6588
|
+
generationLost = true;
|
|
6589
|
+
lifecyclePhase = 'aborted';
|
|
6590
|
+
stream.cancel();
|
|
6591
|
+
return;
|
|
6592
|
+
}
|
|
6163
6593
|
// Stream itself threw. Organization acceleration is deferred from a
|
|
6164
6594
|
// fresh compile; every other throw keeps the ordinary exhausted path.
|
|
6165
6595
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
@@ -6172,7 +6602,8 @@ export class AgentFramework {
|
|
|
6172
6602
|
this.abortAgentScript(agent.name, 'provider acceleration cooldown');
|
|
6173
6603
|
lifecyclePhase = 'failed';
|
|
6174
6604
|
agent.reset();
|
|
6175
|
-
this.
|
|
6605
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
|
|
6606
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
6176
6607
|
return;
|
|
6177
6608
|
}
|
|
6178
6609
|
this.emitTrace({
|
|
@@ -6207,7 +6638,8 @@ export class AgentFramework {
|
|
|
6207
6638
|
this.abortAgentScript(agent.name, 'stream threw');
|
|
6208
6639
|
lifecyclePhase = 'failed'; // §10.5 — terminal emitted in finally
|
|
6209
6640
|
agent.reset();
|
|
6210
|
-
this.
|
|
6641
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
|
|
6642
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
6211
6643
|
}
|
|
6212
6644
|
finally {
|
|
6213
6645
|
const unsettledKvSubmissions = drainKvSubmissionIds?.() ?? [];
|
|
@@ -6220,6 +6652,19 @@ export class AgentFramework {
|
|
|
6220
6652
|
}
|
|
6221
6653
|
catch { /* receipt cleanup must not mask inference teardown */ }
|
|
6222
6654
|
}
|
|
6655
|
+
// A framework retry/budget restart can overlap physical streams on the
|
|
6656
|
+
// same Agent object. Name + Agent identity is not enough: only the
|
|
6657
|
+
// current stream generation may mutate name-keyed teardown state.
|
|
6658
|
+
const ownsPhysicalStream = this.agents.get(agent.name) === agent && agent.streamId === myStreamId;
|
|
6659
|
+
// Ephemeral completion resolves its caller before this finally runs, so
|
|
6660
|
+
// runEphemeralToCompletion may already have deregistered the name. The
|
|
6661
|
+
// terminal frame still owns its typing/outgoing completion unless it was
|
|
6662
|
+
// genuinely superseded or handed EventGate liveness to a budget restart.
|
|
6663
|
+
const disposedEphemeralWithoutSuccessor = generationLost &&
|
|
6664
|
+
this.disposedEphemeralFrames.has(agent) &&
|
|
6665
|
+
!this.agents.has(agent.name);
|
|
6666
|
+
const frameReachedTerminal = (!generationLost || disposedEphemeralWithoutSuccessor) &&
|
|
6667
|
+
!preserveEventGateForSuccessor;
|
|
6223
6668
|
// §10.5: exactly one terminal per `started`, on every exit path the
|
|
6224
6669
|
// host controls. Which one was decided by the path taken (default
|
|
6225
6670
|
// completed; catch → failed; framework-cancel → aborted). A host
|
|
@@ -6238,28 +6683,35 @@ export class AgentFramework {
|
|
|
6238
6683
|
// all incoming events → the agent silently stops waking on messages
|
|
6239
6684
|
// (typing still stops, compression still runs — matching the observed
|
|
6240
6685
|
// wedge). onInferenceEnded is idempotent, so a redundant call is safe.
|
|
6241
|
-
|
|
6242
|
-
|
|
6686
|
+
if (ownsPhysicalStream && !preserveEventGateForSuccessor) {
|
|
6687
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
6688
|
+
}
|
|
6689
|
+
if (!generationLost && ownsPhysicalStream) {
|
|
6690
|
+
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
|
|
6691
|
+
}
|
|
6243
6692
|
if (ownsProviderGate)
|
|
6244
6693
|
this.releasePrimaryProviderGate(agent.name);
|
|
6245
6694
|
// Stop the typing indicator on every exit path (complete, error,
|
|
6246
6695
|
// exhausted, abort) so it never sticks after the turn ends.
|
|
6247
|
-
|
|
6696
|
+
if (frameReachedTerminal)
|
|
6697
|
+
this.channelRegistry?.stopTyping();
|
|
6248
6698
|
// Spec 14.3: flush any held line-start text, then close each streamed
|
|
6249
6699
|
// channel with its final moderated content — the consumer's signal to
|
|
6250
6700
|
// finalize (end the TTS utterance, settle the rendered message).
|
|
6251
|
-
if (proseStream) {
|
|
6701
|
+
if (frameReachedTerminal && proseStream) {
|
|
6252
6702
|
emitOutgoing(proseStream.finish());
|
|
6253
6703
|
for (const [channelId, text] of proseStream.byChannel()) {
|
|
6254
6704
|
this.channelRegistry.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text);
|
|
6255
6705
|
}
|
|
6256
6706
|
}
|
|
6257
6707
|
this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
|
|
6258
|
-
|
|
6259
|
-
|
|
6708
|
+
if (ownsPhysicalStream) {
|
|
6709
|
+
this.activeStreams.delete(agent.name);
|
|
6710
|
+
this.pendingAssistantBlocks.delete(agent.name);
|
|
6711
|
+
}
|
|
6260
6712
|
// A conversation fork whose TTL closure turn just finished is done for
|
|
6261
6713
|
// good — dispose it so the agent map doesn't grow monotonically.
|
|
6262
|
-
if (this.closingConversationAgents.has(agent.name)) {
|
|
6714
|
+
if (ownsPhysicalStream && this.closingConversationAgents.has(agent.name)) {
|
|
6263
6715
|
this.disposeConversationAgent(agent.name);
|
|
6264
6716
|
}
|
|
6265
6717
|
// The turn is torn down — release the turn-alive marker BEFORE the
|
|
@@ -6273,11 +6725,14 @@ export class AgentFramework {
|
|
|
6273
6725
|
this.activeTurnTokens.delete(agent.name);
|
|
6274
6726
|
this.activeTurnTriggers.delete(agent.name);
|
|
6275
6727
|
}
|
|
6276
|
-
// Flush any deferred messages (e.g. if stream failed while tools were
|
|
6277
|
-
|
|
6278
|
-
|
|
6728
|
+
// Flush any deferred messages (e.g. if stream failed while tools were
|
|
6729
|
+
// pending). Only THIS agent's messages: other targets' entries wait
|
|
6730
|
+
// for their own boundaries (re-adding via addMessage re-defers if the
|
|
6731
|
+
// target has meanwhile started a turn).
|
|
6732
|
+
if (frameReachedTerminal && this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
|
|
6733
|
+
const deferred = this.drainDeferredFor(agent.name);
|
|
6279
6734
|
for (const msg of deferred) {
|
|
6280
|
-
this.addMessage(msg.participant, msg.content, msg.metadata);
|
|
6735
|
+
this.addMessage(msg.participant, msg.content, msg.metadata, msg.forAgent ? { forAgent: msg.forAgent } : undefined);
|
|
6281
6736
|
}
|
|
6282
6737
|
}
|
|
6283
6738
|
}
|
|
@@ -6368,7 +6823,7 @@ export class AgentFramework {
|
|
|
6368
6823
|
// and a message arriving during that await must land before the pair,
|
|
6369
6824
|
// never between tool_use and its tool_result. The two addMessage calls
|
|
6370
6825
|
// below are synchronous and adjacent — nothing can interleave.
|
|
6371
|
-
const { blocks } = await this.buildStoredToolResultContent([{ id: toolUseId, name: toolName, input, result, durationMs }], this.resolveToolResultInlineCap(agent).cap);
|
|
6826
|
+
const { blocks } = await this.buildStoredToolResultContent(agentName, [{ id: toolUseId, name: toolName, input, result, durationMs }], this.resolveToolResultInlineCap(agent).cap);
|
|
6372
6827
|
const cm = agent.getContextManager();
|
|
6373
6828
|
cm.addMessage(agentName, [
|
|
6374
6829
|
{ type: 'tool_use', id: toolUseId, name: toolName, input },
|
|
@@ -6816,13 +7271,25 @@ export class AgentFramework {
|
|
|
6816
7271
|
* #89). Returns the stored blocks plus the per-call spill outcomes so the
|
|
6817
7272
|
* live wire copy can reuse the identical strings.
|
|
6818
7273
|
*/
|
|
6819
|
-
async buildStoredToolResultContent(toolResults, maxChars) {
|
|
7274
|
+
async buildStoredToolResultContent(agentName, toolResults, maxChars) {
|
|
6820
7275
|
const spilled = new Map();
|
|
6821
7276
|
const dateLabel = new Date().toISOString().slice(0, 10);
|
|
7277
|
+
const ledger = this.toolImageLedgerFor(agentName);
|
|
6822
7278
|
for (const tc of toolResults) {
|
|
7279
|
+
// Retain every image the result carries BEFORE the placeholder is
|
|
7280
|
+
// written (issue #104): the placeholder embeds the ref, so the image
|
|
7281
|
+
// stays reachable by provenance after the bytes leave the wire.
|
|
6823
7282
|
const raw = tc.result.isError
|
|
6824
7283
|
? tc.result.error ?? 'Unknown error'
|
|
6825
|
-
: toolResultDataToHistoryString(tc.result.data, undefined
|
|
7284
|
+
: toolResultDataToHistoryString(tc.result.data, undefined, {
|
|
7285
|
+
imageRef: (blockIndex, image) => ledger.retain({
|
|
7286
|
+
toolCallId: tc.id,
|
|
7287
|
+
toolName: tc.name,
|
|
7288
|
+
blockIndex,
|
|
7289
|
+
data: image.data,
|
|
7290
|
+
mediaType: image.mimeType,
|
|
7291
|
+
}).ref,
|
|
7292
|
+
});
|
|
6826
7293
|
spilled.set(tc.id, await this.spillOrTruncate(raw, maxChars, `${dateLabel}-${tc.id}`));
|
|
6827
7294
|
}
|
|
6828
7295
|
const blocks = toolResults.map(tc => ({
|
|
@@ -6839,6 +7306,13 @@ export class AgentFramework {
|
|
|
6839
7306
|
async spillOrTruncate(content, cap, label) {
|
|
6840
7307
|
if (!cap || content.length <= cap)
|
|
6841
7308
|
return { text: content, filePath: null };
|
|
7309
|
+
// The stored text is the only place save_recent_image discovers tool
|
|
7310
|
+
// images, and the live wire delivers every image regardless of text
|
|
7311
|
+
// length — so an image slot dropped by the cut is an image the resident
|
|
7312
|
+
// saw that the index would silently slide past (issue #104). Keep the
|
|
7313
|
+
// slots at or past the cut, re-appended after the notice.
|
|
7314
|
+
const { head, tail } = splitPreservingImageSlots(content, cap);
|
|
7315
|
+
const kept = formatPreservedImageSlots(tail);
|
|
6842
7316
|
const workspace = this.getWorkspaceModule();
|
|
6843
7317
|
const mountName = workspace ? this.firstWritableMountName(workspace) : null;
|
|
6844
7318
|
if (workspace && mountName) {
|
|
@@ -6849,10 +7323,11 @@ export class AgentFramework {
|
|
|
6849
7323
|
const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain');
|
|
6850
7324
|
if (result.success) {
|
|
6851
7325
|
return {
|
|
6852
|
-
text:
|
|
6853
|
-
+ `\n\n[truncated — showing ${
|
|
7326
|
+
text: head
|
|
7327
|
+
+ `\n\n[truncated — showing ${head.length} of ${content.length} chars; full content: workspace file ${path}. `
|
|
6854
7328
|
+ 'Read/grep it with your file tools, or raise the inline cap via '
|
|
6855
|
-
+ 'agent_settings update tool_result_inline_max_chars.]'
|
|
7329
|
+
+ 'agent_settings update tool_result_inline_max_chars.]'
|
|
7330
|
+
+ kept,
|
|
6856
7331
|
filePath: path,
|
|
6857
7332
|
};
|
|
6858
7333
|
}
|
|
@@ -6874,15 +7349,17 @@ export class AgentFramework {
|
|
|
6874
7349
|
});
|
|
6875
7350
|
console.error(`[spill] workspace write failed for ${path} (${content.length} chars): ${failure}`);
|
|
6876
7351
|
return {
|
|
6877
|
-
text:
|
|
6878
|
-
+ `\n\n[truncated — showing ${
|
|
6879
|
-
+ ` (${failure}); content over the cap was not retained]
|
|
7352
|
+
text: head
|
|
7353
|
+
+ `\n\n[truncated — showing ${head.length} of ${content.length} chars; spill to workspace file ${path} FAILED`
|
|
7354
|
+
+ ` (${failure}); content over the cap was not retained]`
|
|
7355
|
+
+ kept,
|
|
6880
7356
|
filePath: null,
|
|
6881
7357
|
};
|
|
6882
7358
|
}
|
|
6883
7359
|
return {
|
|
6884
|
-
text:
|
|
6885
|
-
+ '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace, full content not retained]'
|
|
7360
|
+
text: head
|
|
7361
|
+
+ '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace, full content not retained]'
|
|
7362
|
+
+ kept,
|
|
6886
7363
|
filePath: null,
|
|
6887
7364
|
};
|
|
6888
7365
|
}
|
|
@@ -7085,6 +7562,14 @@ export class AgentFramework {
|
|
|
7085
7562
|
}
|
|
7086
7563
|
return hasImage ? blocks : null;
|
|
7087
7564
|
}
|
|
7565
|
+
toolImageLedgerFor(agentName) {
|
|
7566
|
+
let ledger = this.toolImageLedgers.get(agentName);
|
|
7567
|
+
if (!ledger) {
|
|
7568
|
+
ledger = new ToolImageLedger();
|
|
7569
|
+
this.toolImageLedgers.set(agentName, ledger);
|
|
7570
|
+
}
|
|
7571
|
+
return ledger;
|
|
7572
|
+
}
|
|
7088
7573
|
/** Strategy-derived per-message bound (maxMessageTokens * 4 chars). */
|
|
7089
7574
|
strategyDerivedToolResultChars(agent) {
|
|
7090
7575
|
const strategy = agent.getContextManager().getStrategy();
|
|
@@ -7255,6 +7740,27 @@ export class AgentFramework {
|
|
|
7255
7740
|
this.dispatchGateToolCall(agentName, enrichedCall);
|
|
7256
7741
|
return;
|
|
7257
7742
|
}
|
|
7743
|
+
// Route the tune-out tool (residents) and the subconscious's surface
|
|
7744
|
+
if (enrichedCall.name === 'tune_out' && this.tuneOutCoordinator) {
|
|
7745
|
+
this.dispatchTuneOutToolCall(agentName, enrichedCall);
|
|
7746
|
+
return;
|
|
7747
|
+
}
|
|
7748
|
+
if (this.tuneOutCoordinator &&
|
|
7749
|
+
agentName === this.subconsciousAgentName &&
|
|
7750
|
+
SUBCONSCIOUS_TOOL_NAMES.includes(enrichedCall.name)) {
|
|
7751
|
+
void this.tuneOutCoordinator
|
|
7752
|
+
.handleSubconsciousTool(enrichedCall.name, enrichedCall.input)
|
|
7753
|
+
.then((result) => {
|
|
7754
|
+
this.queue.push({
|
|
7755
|
+
type: 'tool-result',
|
|
7756
|
+
callId: enrichedCall.id,
|
|
7757
|
+
agentName,
|
|
7758
|
+
moduleName: 'tune-out',
|
|
7759
|
+
result,
|
|
7760
|
+
});
|
|
7761
|
+
});
|
|
7762
|
+
return;
|
|
7763
|
+
}
|
|
7258
7764
|
// Route sleep / wake tools
|
|
7259
7765
|
if ((enrichedCall.name === 'sleep' || enrichedCall.name === 'wake') && this.eventGate) {
|
|
7260
7766
|
this.dispatchSleepToolCall(agentName, enrichedCall);
|
|
@@ -7383,12 +7889,19 @@ export class AgentFramework {
|
|
|
7383
7889
|
});
|
|
7384
7890
|
});
|
|
7385
7891
|
}
|
|
7386
|
-
addMessage(participant, content, metadata) {
|
|
7387
|
-
// Route to the
|
|
7388
|
-
const agent =
|
|
7389
|
-
? this.agents.get(
|
|
7390
|
-
: this.
|
|
7892
|
+
addMessage(participant, content, metadata, opts) {
|
|
7893
|
+
// Route to the named agent, else the primary (not ephemeral subagents).
|
|
7894
|
+
const agent = opts?.forAgent
|
|
7895
|
+
? this.agents.get(opts.forAgent)
|
|
7896
|
+
: this.primaryAgentName
|
|
7897
|
+
? this.agents.get(this.primaryAgentName)
|
|
7898
|
+
: this.agents.values().next().value;
|
|
7391
7899
|
if (!agent) {
|
|
7900
|
+
if (opts?.forAgent) {
|
|
7901
|
+
console.error(`[addMessage] delivery target "${opts.forAgent}" is not a registered agent — message dropped ` +
|
|
7902
|
+
`(participant=${participant})`);
|
|
7903
|
+
return '';
|
|
7904
|
+
}
|
|
7392
7905
|
throw new Error('No agents configured');
|
|
7393
7906
|
}
|
|
7394
7907
|
// Defer non-tool_result messages while a tool cycle is mid-flight
|
|
@@ -7408,15 +7921,39 @@ export class AgentFramework {
|
|
|
7408
7921
|
// ALSO injected into the live stream — hear-while-acting), or in
|
|
7409
7922
|
// driveStream's finally when the turn ends.
|
|
7410
7923
|
const hasToolResult = content.some(b => b.type === 'tool_result');
|
|
7924
|
+
// Explicitly-targeted deliveries scope the tool-cycle check to the
|
|
7925
|
+
// target (pendingAssistantBlocks is keyed by agent); the default path
|
|
7926
|
+
// keeps the historical GLOBAL check byte-for-byte (conservative: any
|
|
7927
|
+
// agent's pending cycle defers primary-bound messages).
|
|
7928
|
+
const midToolCycle = opts?.forAgent
|
|
7929
|
+
? this.pendingAssistantBlocks.has(agent.name)
|
|
7930
|
+
: this.pendingAssistantBlocks.size > 0;
|
|
7411
7931
|
if (!hasToolResult &&
|
|
7412
|
-
(
|
|
7932
|
+
(midToolCycle ||
|
|
7413
7933
|
this.activeTurnTokens.has(agent.name) ||
|
|
7414
7934
|
this.activeStreams.has(agent.name))) {
|
|
7415
|
-
this.deferredMessages.push({ participant, content, metadata });
|
|
7416
|
-
return ''; // Deferred — flushed at the next boundary
|
|
7935
|
+
this.deferredMessages.push({ participant, content, metadata, forAgent: opts?.forAgent });
|
|
7936
|
+
return ''; // Deferred — flushed at the target's next boundary
|
|
7417
7937
|
}
|
|
7418
7938
|
return agent.getContextManager().addMessage(participant, content, metadata);
|
|
7419
7939
|
}
|
|
7940
|
+
/**
|
|
7941
|
+
* Splice out the deferred messages addressed to `agentName` (explicitly
|
|
7942
|
+
* via forAgent, or implicitly when it is the primary), leaving other
|
|
7943
|
+
* targets' messages queued for their own boundaries.
|
|
7944
|
+
*/
|
|
7945
|
+
drainDeferredFor(agentName) {
|
|
7946
|
+
if (this.deferredMessages.length === 0)
|
|
7947
|
+
return [];
|
|
7948
|
+
const mine = [];
|
|
7949
|
+
const rest = [];
|
|
7950
|
+
for (const msg of this.deferredMessages) {
|
|
7951
|
+
const target = msg.forAgent ?? this.primaryAgentName;
|
|
7952
|
+
(target === agentName ? mine : rest).push(msg);
|
|
7953
|
+
}
|
|
7954
|
+
this.deferredMessages = rest;
|
|
7955
|
+
return mine;
|
|
7956
|
+
}
|
|
7420
7957
|
editMessage(id, content) {
|
|
7421
7958
|
const agent = this.primaryAgentName
|
|
7422
7959
|
? this.agents.get(this.primaryAgentName)
|
|
@@ -9400,6 +9937,50 @@ export class AgentFramework {
|
|
|
9400
9937
|
* suppression window, optionally announces in the sticky channel, and ends
|
|
9401
9938
|
* the turn (the agent goes idle immediately). `wake` clears sleep.
|
|
9402
9939
|
*/
|
|
9940
|
+
/** tune_out tool (residents): enter or cancel a channel tune-out. */
|
|
9941
|
+
dispatchTuneOutToolCall(agentName, call) {
|
|
9942
|
+
const coordinator = this.tuneOutCoordinator;
|
|
9943
|
+
const input = (call.input ?? {});
|
|
9944
|
+
const channelId = String(input.channelId ?? '');
|
|
9945
|
+
const entry = this.channelRegistry?.listChannelsRaw()
|
|
9946
|
+
.find((e) => e.descriptor.id === channelId);
|
|
9947
|
+
let result;
|
|
9948
|
+
if (!entry) {
|
|
9949
|
+
result = { success: false, error: `unknown channel: ${channelId}`, isError: false };
|
|
9950
|
+
}
|
|
9951
|
+
else if (input.mode === 'cancel') {
|
|
9952
|
+
const r = coordinator.cancel(entry.serverId, channelId, 'agent-tool', 'agent request');
|
|
9953
|
+
result = r.ok
|
|
9954
|
+
? { success: true, data: { cancelled: channelId } }
|
|
9955
|
+
: { success: false, error: r.error, isError: false };
|
|
9956
|
+
}
|
|
9957
|
+
else {
|
|
9958
|
+
const r = coordinator.enter(entry.serverId, channelId, {
|
|
9959
|
+
cadenceSeconds: input.cadenceSeconds,
|
|
9960
|
+
backlogCap: input.backlogCap,
|
|
9961
|
+
maxWakes: input.maxWakes,
|
|
9962
|
+
durationSeconds: input.durationSeconds,
|
|
9963
|
+
}, 'agent-tool');
|
|
9964
|
+
result = r.ok
|
|
9965
|
+
? {
|
|
9966
|
+
success: true,
|
|
9967
|
+
data: {
|
|
9968
|
+
tunedOut: channelId,
|
|
9969
|
+
cadenceSeconds: r.params.cadenceSeconds,
|
|
9970
|
+
backlogCap: r.params.backlogCap,
|
|
9971
|
+
maxWakes: r.params.maxWakes,
|
|
9972
|
+
},
|
|
9973
|
+
}
|
|
9974
|
+
: { success: false, error: r.error, isError: false };
|
|
9975
|
+
}
|
|
9976
|
+
this.queue.push({
|
|
9977
|
+
type: 'tool-result',
|
|
9978
|
+
callId: call.id,
|
|
9979
|
+
agentName,
|
|
9980
|
+
moduleName: 'tune-out',
|
|
9981
|
+
result,
|
|
9982
|
+
});
|
|
9983
|
+
}
|
|
9403
9984
|
dispatchSleepToolCall(agentName, call) {
|
|
9404
9985
|
this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
|
|
9405
9986
|
const gate = this.eventGate;
|
|
@@ -9591,10 +10172,53 @@ export class AgentFramework {
|
|
|
9591
10172
|
})();
|
|
9592
10173
|
}
|
|
9593
10174
|
/**
|
|
9594
|
-
*
|
|
9595
|
-
*
|
|
9596
|
-
*
|
|
9597
|
-
*
|
|
10175
|
+
* `save_recent_image` barrier. Module tool calls in a batch are only
|
|
10176
|
+
* ENQUEUED by dispatch, while the save runs at once — so a [snap, save]
|
|
10177
|
+
* round would inventory before the snapshot exists and silently save the
|
|
10178
|
+
* newest OLDER image (issue #104, same-batch representation). Wait for
|
|
10179
|
+
* every sibling in the round to settle; if one never does, fail closed
|
|
10180
|
+
* rather than trust an index the batch may still change. Sibling saves
|
|
10181
|
+
* produce no images and are not waited on (two saves in one batch would
|
|
10182
|
+
* otherwise deadlock into a double timeout). Script-inner calls
|
|
10183
|
+
* (`pytc-…`) run sequentially inside their script host and skip the
|
|
10184
|
+
* barrier — the host call itself is the only pending sibling they'd see.
|
|
10185
|
+
*/
|
|
10186
|
+
async awaitSiblingToolCalls(agent, call) {
|
|
10187
|
+
if (call.id.startsWith('pytc-'))
|
|
10188
|
+
return;
|
|
10189
|
+
const BARRIER_MS = 120_000;
|
|
10190
|
+
const POLL_MS = 20;
|
|
10191
|
+
const startedAt = Date.now();
|
|
10192
|
+
for (;;) {
|
|
10193
|
+
const state = agent.state;
|
|
10194
|
+
// Idle (puppet path) or the round was torn down: nothing to wait on.
|
|
10195
|
+
if (state.status !== 'waiting_for_tools')
|
|
10196
|
+
return;
|
|
10197
|
+
const open = [...state.pending.values()].filter((p) => p.id !== call.id && p.name !== 'save_recent_image');
|
|
10198
|
+
if (open.length === 0)
|
|
10199
|
+
return;
|
|
10200
|
+
if (Date.now() - startedAt > BARRIER_MS) {
|
|
10201
|
+
throw new Error(`save_recent_image: ${open.length} sibling tool call(s) in this batch still pending after ` +
|
|
10202
|
+
`${Math.round(BARRIER_MS / 1000)}s (${open.map((p) => `${p.name} (call ${p.id})`).join(', ')}) — their results ` +
|
|
10203
|
+
'may carry newer images than anything in your context, so no index can be trusted yet. Nothing written. ' +
|
|
10204
|
+
'Save again once they have finished.');
|
|
10205
|
+
}
|
|
10206
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
10207
|
+
}
|
|
10208
|
+
}
|
|
10209
|
+
/**
|
|
10210
|
+
* Handle the synthesized `save_recent_image` tool (issue #104): build ONE
|
|
10211
|
+
* ordered inventory of every image the calling agent has seen — attachment
|
|
10212
|
+
* image blocks (blobs re-inlined) AND tool-result images (their history
|
|
10213
|
+
* placeholders carry a ref, resolved through the agent's ToolImageLedger)
|
|
10214
|
+
* — counted back from the most recent, then write the requested bytes to
|
|
10215
|
+
* a workspace mount via WorkspaceModule.writeBinary.
|
|
10216
|
+
*
|
|
10217
|
+
* Fail-closed: when the image at a requested position cannot be produced
|
|
10218
|
+
* (bytes evicted, minted by an earlier process, or a placeholder written
|
|
10219
|
+
* before retention existed) the call fails AT THAT INDEX and writes
|
|
10220
|
+
* nothing. It never slides to an older image — a filename must not
|
|
10221
|
+
* authenticate bytes from another surface.
|
|
9598
10222
|
*/
|
|
9599
10223
|
dispatchSaveImageToolCall(agentName, call) {
|
|
9600
10224
|
this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
|
|
@@ -9621,16 +10245,166 @@ export class AgentFramework {
|
|
|
9621
10245
|
if (typeof input.path !== 'string' || input.path.length === 0) {
|
|
9622
10246
|
throw new Error('save_recent_image: `path` (mount-prefixed) is required');
|
|
9623
10247
|
}
|
|
9624
|
-
const
|
|
9625
|
-
|
|
9626
|
-
|
|
10248
|
+
const ledger = this.toolImageLedgerFor(agentName);
|
|
10249
|
+
const describeTool = (c) => `${c.toolName} (call ${c.toolCallId})`;
|
|
10250
|
+
const unavailable = (status) => status === 'evicted'
|
|
10251
|
+
? 'its bytes were evicted from the bounded retention budget'
|
|
10252
|
+
: 'this process has no record of that ref (refs are per-process and a restart clears them; ' +
|
|
10253
|
+
'very old refs age out; a mistyped ref looks the same)';
|
|
10254
|
+
const resolve = (candidate, position) => {
|
|
10255
|
+
const rangeOffset = 0; // filled by the caller
|
|
10256
|
+
if (candidate.kind === 'attachment') {
|
|
10257
|
+
if (candidate.data === null) {
|
|
10258
|
+
throw new Error(`save_recent_image: image at index ${position} is not stored inline (base64) — cannot save it. Nothing written.`);
|
|
10259
|
+
}
|
|
10260
|
+
const bytes = Buffer.from(candidate.data, 'base64');
|
|
10261
|
+
return {
|
|
10262
|
+
rangeOffset,
|
|
10263
|
+
data: candidate.data,
|
|
10264
|
+
mediaType: candidate.mediaType,
|
|
10265
|
+
receipt: {
|
|
10266
|
+
source: 'attachment',
|
|
10267
|
+
mediaType: candidate.mediaType,
|
|
10268
|
+
byteSize: bytes.byteLength,
|
|
10269
|
+
sha256: sha256Hex(bytes),
|
|
10270
|
+
messagesBack: candidate.messagesBack,
|
|
10271
|
+
},
|
|
10272
|
+
};
|
|
10273
|
+
}
|
|
10274
|
+
if (candidate.kind === 'reference') {
|
|
10275
|
+
throw new Error(`save_recent_image: image at index ${position} is a reference (${candidate.refId}, ${candidate.mediaType}, ` +
|
|
10276
|
+
`from ${describeTool(candidate)}) — its bytes are not held here. Nothing written. ` +
|
|
10277
|
+
'Fetch it with fetch_reference and save the materialized file instead.');
|
|
10278
|
+
}
|
|
10279
|
+
if (candidate.ref === null) {
|
|
10280
|
+
throw new Error(`save_recent_image: image at index ${position} is a tool-result image from ${describeTool(candidate)} ` +
|
|
10281
|
+
'recorded without a retention ref (written before tool-image retention existed, or inside a ' +
|
|
10282
|
+
'script run, where tool images are not retained) — its bytes are not available. Nothing written. ' +
|
|
10283
|
+
'Re-run the tool for a fresh image rather than saving an older one.');
|
|
10284
|
+
}
|
|
10285
|
+
const lookup = ledger.lookup(candidate.ref);
|
|
10286
|
+
// The placeholder came out of stored TEXT, which a tool result can
|
|
10287
|
+
// quote from anywhere (a fetch_history of a channel where someone
|
|
10288
|
+
// pasted their save receipt). A genuine placeholder is written by
|
|
10289
|
+
// its own call's serialization, so the ref's provenance names that
|
|
10290
|
+
// call; a quoted or forged one cannot. Refuse the mismatch.
|
|
10291
|
+
if (lookup.status !== 'unknown' && lookup.image.toolCallId !== candidate.toolCallId) {
|
|
10292
|
+
throw new Error(`save_recent_image: image at index ${position} cites ${candidate.ref}, but that image belongs to ` +
|
|
10293
|
+
`${describeTool(lookup.image)}, not to ${describeTool(candidate)} where the placeholder appears — ` +
|
|
10294
|
+
'the placeholder is quoted or forged text, not this result\'s own image. Nothing written.');
|
|
10295
|
+
}
|
|
10296
|
+
if (lookup.status !== 'retained') {
|
|
10297
|
+
throw new Error(`save_recent_image: image at index ${position} (${candidate.ref}, from ${describeTool(candidate)}) ` +
|
|
10298
|
+
`is no longer retained — ${unavailable(lookup.status)}. Nothing written. ` +
|
|
10299
|
+
'Re-run the tool for a fresh image rather than saving an older one.');
|
|
10300
|
+
}
|
|
10301
|
+
const image = lookup.image;
|
|
10302
|
+
return {
|
|
10303
|
+
rangeOffset,
|
|
10304
|
+
data: image.data,
|
|
10305
|
+
mediaType: image.mediaType,
|
|
10306
|
+
receipt: {
|
|
10307
|
+
source: 'tool-result',
|
|
10308
|
+
ref: image.ref,
|
|
10309
|
+
toolName: image.toolName,
|
|
10310
|
+
toolCallId: image.toolCallId,
|
|
10311
|
+
blockIndex: image.blockIndex,
|
|
10312
|
+
mediaType: image.mediaType,
|
|
10313
|
+
byteSize: image.byteSize,
|
|
10314
|
+
sha256: image.sha256,
|
|
10315
|
+
messagesBack: candidate.messagesBack,
|
|
10316
|
+
},
|
|
10317
|
+
};
|
|
10318
|
+
};
|
|
10319
|
+
// Barrier: a save dispatched alongside other calls in one batch must
|
|
10320
|
+
// not inventory before their results exist — those are the newest
|
|
10321
|
+
// images. Waits (bounded) for every sibling to settle, or fails closed.
|
|
10322
|
+
await this.awaitSiblingToolCalls(agent, call);
|
|
10323
|
+
const found = [];
|
|
10324
|
+
let index = 0;
|
|
10325
|
+
let count = 1;
|
|
10326
|
+
/** Direct-ref mode: the one ref to find in the scannable context. */
|
|
10327
|
+
let wantedRef = null;
|
|
10328
|
+
if (input.ref !== undefined) {
|
|
10329
|
+
if (typeof input.ref !== 'string' || !TOOL_IMAGE_REF_RE.test(input.ref)) {
|
|
10330
|
+
throw new Error('save_recent_image: `ref` must look like "img_k7x3q2_7" (copy it from the image placeholder in your context)');
|
|
10331
|
+
}
|
|
10332
|
+
if (input.index !== undefined || input.count !== undefined) {
|
|
10333
|
+
throw new Error('save_recent_image: `ref` is mutually exclusive with `index`/`count`');
|
|
10334
|
+
}
|
|
10335
|
+
wantedRef = input.ref;
|
|
9627
10336
|
}
|
|
9628
|
-
|
|
9629
|
-
|
|
9630
|
-
|
|
9631
|
-
|
|
10337
|
+
else {
|
|
10338
|
+
index = input.index === undefined ? 0 : Number(input.index);
|
|
10339
|
+
if (!Number.isInteger(index) || index < 0) {
|
|
10340
|
+
throw new Error('save_recent_image: `index` must be a non-negative integer');
|
|
10341
|
+
}
|
|
10342
|
+
count = input.count === undefined ? 1 : Number(input.count);
|
|
10343
|
+
const MAX_COUNT = 20;
|
|
10344
|
+
if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) {
|
|
10345
|
+
throw new Error(`save_recent_image: \`count\` must be an integer in 1..${MAX_COUNT}`);
|
|
10346
|
+
}
|
|
9632
10347
|
}
|
|
9633
10348
|
const lastWanted = index + count - 1;
|
|
10349
|
+
let seen = 0;
|
|
10350
|
+
const collecting = () => (wantedRef !== null ? found.length === 0 : found.length < count);
|
|
10351
|
+
// One walk serves both modes. By index: the requested range of the
|
|
10352
|
+
// ordered inventory. By ref: the slot whose placeholder cites that
|
|
10353
|
+
// ref — the ref is resolved THROUGH its occurrence in context, so
|
|
10354
|
+
// the same provenance cross-check applies (a ref that is not visible
|
|
10355
|
+
// in the scannable window — an undone branch, quoted text naming a
|
|
10356
|
+
// real ref — is not saveable by name either).
|
|
10357
|
+
const consider = (candidate) => {
|
|
10358
|
+
if (wantedRef !== null) {
|
|
10359
|
+
if (candidate.kind === 'tool' && candidate.ref === wantedRef && found.length === 0) {
|
|
10360
|
+
found.push(resolve(candidate, seen));
|
|
10361
|
+
}
|
|
10362
|
+
}
|
|
10363
|
+
else if (seen >= index && seen <= lastWanted) {
|
|
10364
|
+
const resolved = resolve(candidate, seen);
|
|
10365
|
+
resolved.rangeOffset = seen - index;
|
|
10366
|
+
found.push(resolved);
|
|
10367
|
+
}
|
|
10368
|
+
seen++;
|
|
10369
|
+
};
|
|
10370
|
+
const considerToolResultImages = (toolCallId, toolName, placeholders, messagesBack) => {
|
|
10371
|
+
for (let p = placeholders.length - 1; p >= 0 && collecting(); p--) {
|
|
10372
|
+
const ph = placeholders[p];
|
|
10373
|
+
if (ph.kind === 'inline') {
|
|
10374
|
+
consider({ kind: 'tool', ref: ph.ref, toolCallId, toolName, mediaType: ph.mediaType, messagesBack });
|
|
10375
|
+
continue;
|
|
10376
|
+
}
|
|
10377
|
+
// Reference stub: the registry's testimony (when this process
|
|
10378
|
+
// still has the record) says whether it was an image; the
|
|
10379
|
+
// sniffed stub text is the fallback. Non-image references
|
|
10380
|
+
// are not slots.
|
|
10381
|
+
const testimony = referenceRegistry.get(ph.refId)?.testimony.mimeType?.toLowerCase() ?? ph.mediaType;
|
|
10382
|
+
if (!testimony || !testimony.startsWith('image/'))
|
|
10383
|
+
continue;
|
|
10384
|
+
consider({ kind: 'reference', refId: ph.refId, mediaType: testimony, toolCallId, toolName, messagesBack });
|
|
10385
|
+
}
|
|
10386
|
+
};
|
|
10387
|
+
// Same-batch results (e.g. a snapshot dispatched alongside the save)
|
|
10388
|
+
// are not in the store yet but ARE the newest images. Classify them
|
|
10389
|
+
// with the very serializer the commit path will run — contradiction
|
|
10390
|
+
// blocks withheld, uri-form blocks as reference stubs, unknown shapes
|
|
10391
|
+
// as no slot at all — so the inventory means the same thing before
|
|
10392
|
+
// and after commit. Retention is idempotent per block, so the
|
|
10393
|
+
// placeholder written at commit time carries the same ref.
|
|
10394
|
+
const state = agent.state;
|
|
10395
|
+
if (state.status === 'waiting_for_tools') {
|
|
10396
|
+
for (let i = state.completed.length - 1; i >= 0 && collecting(); i--) {
|
|
10397
|
+
const tc = state.completed[i];
|
|
10398
|
+
if (tc.result.isError)
|
|
10399
|
+
continue;
|
|
10400
|
+
const text = toolResultDataToHistoryString(tc.result.data, undefined, {
|
|
10401
|
+
imageRef: (blockIndex, image) => ledger.retain({
|
|
10402
|
+
toolCallId: tc.id, toolName: tc.name, blockIndex, data: image.data, mediaType: image.mimeType,
|
|
10403
|
+
}).ref,
|
|
10404
|
+
});
|
|
10405
|
+
considerToolResultImages(tc.id, tc.name, parseImagePlaceholders(text), 0);
|
|
10406
|
+
}
|
|
10407
|
+
}
|
|
9634
10408
|
// Walk the message store tail-first in bounded windows, re-inlining
|
|
9635
10409
|
// blob media, until we've collected the requested range. Scanning is
|
|
9636
10410
|
// capped so a pathological index can't drag the whole store through
|
|
@@ -9639,37 +10413,45 @@ export class AgentFramework {
|
|
|
9639
10413
|
const WINDOW = 25;
|
|
9640
10414
|
const cm = agent.getContextManager();
|
|
9641
10415
|
const total = cm.getMessageCount();
|
|
9642
|
-
let seen = 0;
|
|
9643
10416
|
let scanned = 0;
|
|
9644
|
-
|
|
9645
|
-
for (let end = total; end > 0 && scanned < MAX_SCAN && found.length < count; end -= WINDOW) {
|
|
10417
|
+
for (let end = total; end > 0 && scanned < MAX_SCAN && collecting(); end -= WINDOW) {
|
|
9646
10418
|
const start = Math.max(0, end - WINDOW);
|
|
9647
|
-
|
|
10419
|
+
// By ref, attachment bytes are never needed — skip blob resolution.
|
|
10420
|
+
const { messages } = cm.getMessageWindow(start, end - start, { resolveBlobs: wantedRef === null });
|
|
9648
10421
|
scanned += end - start;
|
|
9649
|
-
for (let i = messages.length - 1; i >= 0 &&
|
|
10422
|
+
for (let i = messages.length - 1; i >= 0 && collecting(); i--) {
|
|
9650
10423
|
const content = messages[i]?.content;
|
|
9651
10424
|
if (!Array.isArray(content))
|
|
9652
10425
|
continue;
|
|
9653
|
-
|
|
10426
|
+
const messagesBack = total - (start + i);
|
|
10427
|
+
for (let b = content.length - 1; b >= 0 && collecting(); b--) {
|
|
9654
10428
|
const block = content[b];
|
|
9655
|
-
if (block?.type
|
|
9656
|
-
|
|
9657
|
-
|
|
9658
|
-
|
|
9659
|
-
|
|
9660
|
-
|
|
9661
|
-
|
|
9662
|
-
rangeOffset: seen - index,
|
|
9663
|
-
data: block.source.data,
|
|
9664
|
-
mediaType: block.source.mediaType ?? 'image/png',
|
|
9665
|
-
messagesBack: total - (start + i),
|
|
10429
|
+
if (block?.type === 'image') {
|
|
10430
|
+
const inline = block.source?.type === 'base64' && typeof block.source.data === 'string';
|
|
10431
|
+
consider({
|
|
10432
|
+
kind: 'attachment',
|
|
10433
|
+
data: inline ? block.source.data : null,
|
|
10434
|
+
mediaType: block.source?.mediaType ?? 'image/png',
|
|
10435
|
+
messagesBack,
|
|
9666
10436
|
});
|
|
9667
10437
|
}
|
|
9668
|
-
|
|
10438
|
+
else if (block?.type === 'tool_result' && typeof block.content === 'string') {
|
|
10439
|
+
considerToolResultImages(block.toolUseId ?? 'unknown', block.toolName ?? 'unknown tool', parseImagePlaceholders(block.content), messagesBack);
|
|
10440
|
+
}
|
|
9669
10441
|
}
|
|
9670
10442
|
}
|
|
9671
10443
|
}
|
|
9672
10444
|
if (found.length === 0) {
|
|
10445
|
+
if (wantedRef !== null) {
|
|
10446
|
+
const lookup = ledger.lookup(wantedRef);
|
|
10447
|
+
throw new Error(lookup.status === 'unknown'
|
|
10448
|
+
? `save_recent_image: ref ${wantedRef} cannot be saved — ${unavailable('unknown')}. Nothing written.`
|
|
10449
|
+
: `save_recent_image: ref ${wantedRef} (from ${describeTool(lookup.image)}) does not appear in your recent ` +
|
|
10450
|
+
`context (scanned ${Math.min(scanned, MAX_SCAN)} messages) — a ref is saved through its placeholder, ` +
|
|
10451
|
+
'which must be visible to you' +
|
|
10452
|
+
(lookup.status === 'evicted' ? '; its bytes were also evicted from the bounded retention budget' : '') +
|
|
10453
|
+
'. Nothing written.');
|
|
10454
|
+
}
|
|
9673
10455
|
throw new Error(seen === 0
|
|
9674
10456
|
? `save_recent_image: no images found in the most recent ${Math.min(scanned, MAX_SCAN)} messages`
|
|
9675
10457
|
: `save_recent_image: only ${seen} image(s) found in the most recent ${Math.min(scanned, MAX_SCAN)} messages (asked for index ${index})`);
|
|
@@ -9705,7 +10487,7 @@ export class AgentFramework {
|
|
|
9705
10487
|
savedFiles.push({
|
|
9706
10488
|
...result.data,
|
|
9707
10489
|
imageIndex: index + image.rangeOffset,
|
|
9708
|
-
|
|
10490
|
+
...image.receipt,
|
|
9709
10491
|
});
|
|
9710
10492
|
}
|
|
9711
10493
|
finish({
|