@animalabs/agent-framework 0.11.0 → 0.13.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 +11 -1
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +60 -4
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +72 -1
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +873 -123
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +7 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +15 -1
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/kv-unified-wire.d.ts +16 -0
- package/dist/src/kv-unified-wire.d.ts.map +1 -0
- package/dist/src/kv-unified-wire.js +2 -0
- package/dist/src/kv-unified-wire.js.map +1 -0
- 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 +188 -5
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.js +28 -2
- package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +6 -1
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +29 -2
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +59 -0
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -0
- package/dist/src/mcpl/reference-fetcher.js +240 -0
- package/dist/src/mcpl/reference-fetcher.js.map +1 -0
- package/dist/src/mcpl/references.d.ts +125 -0
- package/dist/src/mcpl/references.d.ts.map +1 -0
- package/dist/src/mcpl/references.js +262 -0
- package/dist/src/mcpl/references.js.map +1 -0
- package/dist/src/mcpl/types.d.ts +32 -0
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/modules/index.d.ts +2 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +125 -2
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +354 -58
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +7 -0
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +14 -0
- 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 +15 -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/src/usage-accounting.d.ts +5 -0
- package/dist/src/usage-accounting.d.ts.map +1 -0
- package/dist/src/usage-accounting.js +11 -0
- package/dist/src/usage-accounting.js.map +1 -0
- 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/conversation-routing.test.js +39 -0
- package/dist/test/conversation-routing.test.js.map +1 -1
- package/dist/test/kv-unified-wiring.test.d.ts +2 -0
- package/dist/test/kv-unified-wiring.test.d.ts.map +1 -0
- package/dist/test/kv-unified-wiring.test.js +56 -0
- package/dist/test/kv-unified-wiring.test.js.map +1 -0
- package/dist/test/mcpl-push-convert.test.d.ts +2 -0
- package/dist/test/mcpl-push-convert.test.d.ts.map +1 -0
- package/dist/test/mcpl-push-convert.test.js +42 -0
- package/dist/test/mcpl-push-convert.test.js.map +1 -0
- package/dist/test/mcpl-reference-fetcher.test.d.ts +2 -0
- package/dist/test/mcpl-reference-fetcher.test.d.ts.map +1 -0
- package/dist/test/mcpl-reference-fetcher.test.js +199 -0
- package/dist/test/mcpl-reference-fetcher.test.js.map +1 -0
- 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/present-while-acting.test.js +64 -0
- package/dist/test/present-while-acting.test.js.map +1 -1
- package/dist/test/tool-result-history.test.js +78 -0
- package/dist/test/tool-result-history.test.js.map +1 -1
- 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.d.ts +2 -0
- package/dist/test/turn-trigger-provenance.test.d.ts.map +1 -0
- package/dist/test/turn-trigger-provenance.test.js +85 -0
- package/dist/test/turn-trigger-provenance.test.js.map +1 -0
- package/dist/test/usage-accounting.test.d.ts +2 -0
- package/dist/test/usage-accounting.test.d.ts.map +1 -0
- package/dist/test/usage-accounting.test.js +13 -0
- package/dist/test/usage-accounting.test.js.map +1 -0
- package/dist/test/workspace-branch-continuation.test.d.ts +15 -0
- package/dist/test/workspace-branch-continuation.test.d.ts.map +1 -0
- package/dist/test/workspace-branch-continuation.test.js +183 -0
- package/dist/test/workspace-branch-continuation.test.js.map +1 -0
- package/dist/test/workspace-read-file-from-disk.test.d.ts +17 -0
- package/dist/test/workspace-read-file-from-disk.test.d.ts.map +1 -0
- package/dist/test/workspace-read-file-from-disk.test.js +174 -0
- package/dist/test/workspace-read-file-from-disk.test.js.map +1 -0
- package/dist/test/workspace-read-image.test.js +8 -0
- package/dist/test/workspace-read-image.test.js.map +1 -1
- package/dist/test/workspace-restore-order.test.js +1 -1
- package/dist/test/workspace-restore-order.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +77 -4
- package/src/framework.ts +953 -128
- package/src/gate/event-gate.ts +28 -3
- package/src/kv-unified-wire.ts +12 -0
- package/src/mcpl/channel-registry.ts +264 -8
- package/src/mcpl/hook-orchestrator.ts +29 -2
- package/src/mcpl/push-handler.ts +30 -2
- package/src/mcpl/reference-fetcher.ts +240 -0
- package/src/mcpl/references.ts +292 -0
- package/src/mcpl/types.ts +29 -0
- package/src/modules/index.ts +2 -1
- package/src/modules/workspace/index.ts +425 -66
- package/src/modules/workspace/types.ts +7 -0
- package/src/tool-result-history.ts +11 -0
- 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 +16 -0
- package/src/types/framework.ts +7 -0
- package/src/usage-accounting.ts +8 -0
package/dist/src/framework.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
+
import { INLINE_WITHHELD_TEXT, classifyBlock, isInlineContradiction, referenceRegistry, referenceStubOrNull } from './mcpl/references.js';
|
|
3
|
+
import { ReferenceFetcher, DEFAULT_FETCH_MAX_BYTES, EAGER_FETCH_TIMEOUT_MS } from './mcpl/reference-fetcher.js';
|
|
2
4
|
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
3
5
|
import { JsStore } from '@animalabs/chronicle';
|
|
4
6
|
import { MembraneError } from '@animalabs/membrane';
|
|
5
|
-
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';
|
|
6
10
|
import { ProcessQueueImpl } from './queue.js';
|
|
7
11
|
import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK } from './refusal-reactions.js';
|
|
8
12
|
import { Agent } from './agent.js';
|
|
@@ -15,6 +19,7 @@ import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
|
|
|
15
19
|
import { PushHandler } from './mcpl/push-handler.js';
|
|
16
20
|
import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
|
|
17
21
|
import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
|
|
22
|
+
import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js';
|
|
18
23
|
import { InferenceRouter } from './mcpl/inference-router.js';
|
|
19
24
|
import { ChannelRegistry } from './mcpl/channel-registry.js';
|
|
20
25
|
import { ConversationRouter } from './mcpl/conversation-router.js';
|
|
@@ -24,6 +29,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
24
29
|
import { PyRunner, buildInjectedTools } from './code-execution/py-runner.js';
|
|
25
30
|
import { buildCodeExecutionToolDefinition, CODE_EXECUTION_TOOL_NAME, } from './code-execution/tool-definition.js';
|
|
26
31
|
import { splitProseSegments } from './prose-segments.js';
|
|
32
|
+
import { cumulativeDelta } from './usage-accounting.js';
|
|
27
33
|
/** Detect a supported image media type from magic bytes (the model API
|
|
28
34
|
* rejects mislabeled media types, so trust bytes over extensions).
|
|
29
35
|
* Returns undefined for non-image content. */
|
|
@@ -64,8 +70,23 @@ function sniffImageMediaType(data) {
|
|
|
64
70
|
const SILENCING_TOOLS = new Set([
|
|
65
71
|
'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
|
|
66
72
|
]);
|
|
67
|
-
/**
|
|
68
|
-
|
|
73
|
+
/**
|
|
74
|
+
* World-surface publication tools (Eidoverse `say` / `whisper`). An explicit
|
|
75
|
+
* world utterance is the resident's chosen public speech for that round, so
|
|
76
|
+
* it silences adjacent auto-routed prose exactly like a channel send — in
|
|
77
|
+
* EVERY prose-routing mode. Until 2026-09-01 this set was consulted only in
|
|
78
|
+
* hybrid mode; in locus mode a round of ordinary text + `say` published twice
|
|
79
|
+
* (Cairn world seq 15146 = the say text, 15147 = the adjacent prose,
|
|
80
|
+
* byte-for-byte). Hybrid additionally lets these outrank a same-round
|
|
81
|
+
* `>>>destination` envelope.
|
|
82
|
+
*/
|
|
83
|
+
const WORLD_PUBLICATION_TOOLS = new Set(['say', 'whisper']);
|
|
84
|
+
/** True when a tool call (possibly MCPL-prefixed) is an explicit delivery
|
|
85
|
+
* that silences the round's auto-routed prose, regardless of mode. */
|
|
86
|
+
const isSilencingTool = (name) => {
|
|
87
|
+
const bare = bareToolName(name);
|
|
88
|
+
return SILENCING_TOOLS.has(bare) || WORLD_PUBLICATION_TOOLS.has(bare);
|
|
89
|
+
};
|
|
69
90
|
/**
|
|
70
91
|
* True when an injected message is real conversational input — something the
|
|
71
92
|
* agent might actually be replying to — rather than ambient machinery.
|
|
@@ -504,6 +525,16 @@ export class AgentFramework {
|
|
|
504
525
|
frameworkCancelledStreams = new Map();
|
|
505
526
|
/** Active runEphemeralToCompletion runs, keyed by agent name. */
|
|
506
527
|
ephemeralRuns = new Map();
|
|
528
|
+
/** Ephemeral namespaces/names are single-generation for this framework
|
|
529
|
+
* lifetime. Reusing one while an old stream drains would let name-keyed
|
|
530
|
+
* liveness/settlement state cross generations. */
|
|
531
|
+
usedEphemeralAgentNames = new Set();
|
|
532
|
+
/** One-shot generation tickets minted by createEphemeralAgent. */
|
|
533
|
+
ephemeralCandidates = new WeakMap();
|
|
534
|
+
/** Physical ephemeral frames deliberately disposed by their run watchdog/caller.
|
|
535
|
+
* The name is single-generation, so these frames still own their terminal
|
|
536
|
+
* typing/outgoing close even after runEphemeralToCompletion deregisters them. */
|
|
537
|
+
disposedEphemeralFrames = new WeakSet();
|
|
507
538
|
/** Per-agent count of consecutive exhausted inferences (reset on any success).
|
|
508
539
|
* Drives hard-down escalation — see noteInferenceExhausted. */
|
|
509
540
|
consecutiveInferenceFailures = new Map();
|
|
@@ -551,6 +582,14 @@ export class AgentFramework {
|
|
|
551
582
|
primaryAgentName = null;
|
|
552
583
|
// Messages deferred while an agent is waiting_for_tools (to preserve
|
|
553
584
|
// tool_use → tool_result adjacency required by the Anthropic API).
|
|
585
|
+
/** The subconscious resident's registry name (issue #77), or null. */
|
|
586
|
+
subconsciousAgentName = null;
|
|
587
|
+
/** Its windowed strategy — the coordinator moves the anchor on tune-out entry. */
|
|
588
|
+
subconsciousStrategy = null;
|
|
589
|
+
/** Its config (speak_in_channel gate, voice block provenance). */
|
|
590
|
+
subconsciousConfig = null;
|
|
591
|
+
/** Tune-out coordinator (issue #77); non-null iff subconscious + channels. */
|
|
592
|
+
tuneOutCoordinator = null;
|
|
554
593
|
deferredMessages = [];
|
|
555
594
|
// Turn-alive markers (2026-07-31 Mythos phantom-skip incident): agentName →
|
|
556
595
|
// token of the turn currently in progress. Set at startAgentStream ENTRY —
|
|
@@ -569,6 +608,21 @@ export class AgentFramework {
|
|
|
569
608
|
// (KV bust). Invariant: nothing enters the window between a turn's dequeue
|
|
570
609
|
// and its settle except that turn's own blocks.
|
|
571
610
|
activeTurnTokens = new Map();
|
|
611
|
+
/**
|
|
612
|
+
* The InferenceRequest that started each agent's turn in progress — set and
|
|
613
|
+
* cleared exactly where the turn token is. Read-only outside: the host
|
|
614
|
+
* stamps gateway telemetry (why this call exists, in which channel, woken
|
|
615
|
+
* by whom) from it, so a household ledger can attribute wakes without
|
|
616
|
+
* guessing from the newest message in the window.
|
|
617
|
+
*/
|
|
618
|
+
activeTurnTriggers = new Map();
|
|
619
|
+
/** The trigger of `agentName`'s turn in progress, if a turn is running. */
|
|
620
|
+
getActiveTurnTrigger(agentName) {
|
|
621
|
+
return this.activeTurnTriggers.get(agentName);
|
|
622
|
+
}
|
|
623
|
+
/** Structured calls executed in the current logical turn. Unlike the
|
|
624
|
+
* per-stream local this survives framework retries and budget restarts. */
|
|
625
|
+
logicalTurnToolCalls = new WeakMap();
|
|
572
626
|
nextTurnToken = 1;
|
|
573
627
|
// Undo/redo state
|
|
574
628
|
turnCounters = new Map(); // agentName → next turnIndex
|
|
@@ -780,6 +834,12 @@ export class AgentFramework {
|
|
|
780
834
|
for (const agentConfig of config.agents) {
|
|
781
835
|
await framework.createAgent(agentConfig);
|
|
782
836
|
}
|
|
837
|
+
// The subconscious resident (issue #77) registers after the residents so
|
|
838
|
+
// it can never become primary; it is excluded from every broadcast
|
|
839
|
+
// fan-out and woken only by the tune-out coordinator's explicit pushes.
|
|
840
|
+
if (config.subconscious?.enabled) {
|
|
841
|
+
await framework.createSubconsciousAgent(config.subconscious);
|
|
842
|
+
}
|
|
783
843
|
// Finish any branch-local suppression interrupted after Chronicle switched
|
|
784
844
|
// branches. This runs before modules, MCPL connections, or inbound traffic.
|
|
785
845
|
await framework.resumePreparedDiscordSuppressions();
|
|
@@ -824,11 +884,11 @@ export class AgentFramework {
|
|
|
824
884
|
initialConfig: config.gate.config,
|
|
825
885
|
privilegedUsersPath: config.gate.privilegedUsersPath,
|
|
826
886
|
emitTrace: (e) => framework.emitTrace(e),
|
|
827
|
-
addMessage: (p, c, m) => framework.addMessage(p, c, m),
|
|
887
|
+
addMessage: (p, c, m, forAgent) => framework.addMessage(p, c, m, forAgent ? { forAgent } : undefined),
|
|
828
888
|
requestInference: (agentName, reason, source) => {
|
|
829
889
|
framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
|
|
830
890
|
},
|
|
831
|
-
getAgentNames: () => [...framework.agents.keys()],
|
|
891
|
+
getAgentNames: () => [...framework.agents.keys()].filter((n) => n !== framework.subconsciousAgentName),
|
|
832
892
|
});
|
|
833
893
|
}
|
|
834
894
|
// Liveness watchdog: fail hard if the main thread wedges (opt-in).
|
|
@@ -879,6 +939,30 @@ export class AgentFramework {
|
|
|
879
939
|
}
|
|
880
940
|
await framework.initializeMcpl(config.mcplServers, config.inferenceRouting);
|
|
881
941
|
}
|
|
942
|
+
// Tune-out coordinator (issue #77): needs both the subconscious resident
|
|
943
|
+
// and the MCPL channel subsystem; created after both exist.
|
|
944
|
+
if (framework.subconsciousAgentName && framework.channelRegistry) {
|
|
945
|
+
framework.tuneOutCoordinator = new TuneOutCoordinator(framework.channelRegistry, framework.mcplServerRegistry, {
|
|
946
|
+
addMessage: (participant, content, metadata, forAgent) => framework.addMessage(participant, content, metadata, forAgent ? { forAgent } : undefined),
|
|
947
|
+
requestInference: (agentName, reason, source) => {
|
|
948
|
+
framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
|
|
949
|
+
},
|
|
950
|
+
subconsciousName: () => framework.subconsciousAgentName,
|
|
951
|
+
primaryName: () => framework.primaryAgentName,
|
|
952
|
+
getStoredMessages: () => {
|
|
953
|
+
const primary = framework.primaryAgentName
|
|
954
|
+
? framework.agents.get(framework.primaryAgentName) : undefined;
|
|
955
|
+
return primary ? primary.getContextManager().getAllMessages() : [];
|
|
956
|
+
},
|
|
957
|
+
currentSequence: () => framework.store.currentSequence(),
|
|
958
|
+
setSubconsciousAnchor: (sequence) => framework.subconsciousStrategy?.setAnchor(sequence),
|
|
959
|
+
isForkBound: (channelId) => [...framework.conversationAgentHomes.values()].includes(channelId),
|
|
960
|
+
isPrivilegedAuthor: (authorId) => framework.eventGate?.isPrivilegedUser(authorId) ?? false,
|
|
961
|
+
allowChannelSpeech: () => !!framework.subconsciousConfig?.allowChannelSpeech,
|
|
962
|
+
emitTrace: (e) => framework.emitTrace(e),
|
|
963
|
+
});
|
|
964
|
+
framework.tuneOutCoordinator.resumeActiveEpochs();
|
|
965
|
+
}
|
|
882
966
|
// Diagnostics: `kill -USR2 <pid>` dumps live wake/inference state to stderr
|
|
883
967
|
// (journal) without a restart — for catching the wake-wedge on the running
|
|
884
968
|
// process. Shows the gate's `inferring` set + buffered-event count (the
|
|
@@ -943,6 +1027,7 @@ export class AgentFramework {
|
|
|
943
1027
|
this.running = false;
|
|
944
1028
|
this.providerAdmissionClosed = true;
|
|
945
1029
|
this.queue.close();
|
|
1030
|
+
this.tuneOutCoordinator?.stop();
|
|
946
1031
|
// Kill running code_execution scripts before cancelling streams: a
|
|
947
1032
|
// zombie script must not keep firing side-effectful tool calls into a
|
|
948
1033
|
// framework that is shutting down.
|
|
@@ -1374,7 +1459,12 @@ export class AgentFramework {
|
|
|
1374
1459
|
*/
|
|
1375
1460
|
getAllTools() {
|
|
1376
1461
|
const moduleTools = this.moduleRegistry.getAllTools();
|
|
1377
|
-
|
|
1462
|
+
// Copy: getChannelTools() returns the registry's shared definitions
|
|
1463
|
+
// array; pushing onto it would append another tune_out on every call.
|
|
1464
|
+
const channelTools = [...(this.channelRegistry?.getChannelTools() ?? [])];
|
|
1465
|
+
if (this.tuneOutCoordinator) {
|
|
1466
|
+
channelTools.push(AgentFramework.TUNE_OUT_TOOL);
|
|
1467
|
+
}
|
|
1378
1468
|
const gateTools = this.eventGate
|
|
1379
1469
|
? [
|
|
1380
1470
|
this.eventGate.getToolDefinition(),
|
|
@@ -1392,7 +1482,8 @@ export class AgentFramework {
|
|
|
1392
1482
|
...gateTools,
|
|
1393
1483
|
this.buildAgentSettingsTool(),
|
|
1394
1484
|
...(this.getWorkspaceModule()
|
|
1395
|
-
? [AgentFramework.SAVE_IMAGE_TOOL, AgentFramework.READ_IMAGE_TOOL
|
|
1485
|
+
? [AgentFramework.SAVE_IMAGE_TOOL, AgentFramework.READ_IMAGE_TOOL,
|
|
1486
|
+
AgentFramework.FETCH_REFERENCE_TOOL]
|
|
1396
1487
|
: []),
|
|
1397
1488
|
...(this.codeExecutionConfig
|
|
1398
1489
|
? [buildCodeExecutionToolDefinition(this.codeExecutionConfig)]
|
|
@@ -1406,6 +1497,57 @@ export class AgentFramework {
|
|
|
1406
1497
|
? module
|
|
1407
1498
|
: undefined;
|
|
1408
1499
|
}
|
|
1500
|
+
/** RFC-005 host-mediated reference fetcher (lazy; needs server configs +
|
|
1501
|
+
* a writable workspace mount for storage). */
|
|
1502
|
+
referenceFetcherInstance;
|
|
1503
|
+
getReferenceFetcher() {
|
|
1504
|
+
if (!this.referenceFetcherInstance) {
|
|
1505
|
+
this.referenceFetcherInstance = new ReferenceFetcher((serverId) => {
|
|
1506
|
+
const cfg = this.mcplServerConfigs.get(serverId);
|
|
1507
|
+
if (!cfg?.url)
|
|
1508
|
+
return undefined;
|
|
1509
|
+
return { url: cfg.url, token: cfg.token, autofetch: cfg.autofetch };
|
|
1510
|
+
}, async (fileName, data, mimeType) => {
|
|
1511
|
+
const workspace = this.getWorkspaceModule();
|
|
1512
|
+
if (!workspace)
|
|
1513
|
+
return null;
|
|
1514
|
+
const mount = this.firstWritableMountName(workspace);
|
|
1515
|
+
if (!mount)
|
|
1516
|
+
return null;
|
|
1517
|
+
const path = `${mount}/refs/${fileName}`;
|
|
1518
|
+
const res = await workspace.writeBinary(path, data, mimeType);
|
|
1519
|
+
return res.success ? path : null;
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
return this.referenceFetcherInstance;
|
|
1523
|
+
}
|
|
1524
|
+
/** Register every RFC-005 reference in a tool-result content array against
|
|
1525
|
+
* its server, then eagerly fetch the ones inside the autofetch envelope
|
|
1526
|
+
* (below the eager ceiling, connection origin — never worse than the
|
|
1527
|
+
* server having inlined the bytes). Failures are non-fatal: the stub then
|
|
1528
|
+
* says fetch_reference instead of a path. */
|
|
1529
|
+
async autoFetchReferences(content, serverId, eager) {
|
|
1530
|
+
if (!content)
|
|
1531
|
+
return;
|
|
1532
|
+
const fetcher = this.getReferenceFetcher();
|
|
1533
|
+
const eligible = [];
|
|
1534
|
+
for (const block of content) {
|
|
1535
|
+
const c = classifyBlock(block);
|
|
1536
|
+
if (c.kind !== 'reference')
|
|
1537
|
+
continue;
|
|
1538
|
+
const record = referenceRegistry.register(c.testimony, serverId);
|
|
1539
|
+
if (eager && fetcher.isEagerEligible(record))
|
|
1540
|
+
eligible.push(record);
|
|
1541
|
+
}
|
|
1542
|
+
// Parallel with a count cap: N small fetches cost one round-trip of
|
|
1543
|
+
// wall-clock, and anything past the cap stays lazy (stub says so).
|
|
1544
|
+
const MAX_EAGER_PER_RESULT = 8;
|
|
1545
|
+
await Promise.all(eligible.slice(0, MAX_EAGER_PER_RESULT).map(async (record) => {
|
|
1546
|
+
const outcome = await fetcher.fetch(record, { timeoutMs: EAGER_FETCH_TIMEOUT_MS });
|
|
1547
|
+
if (!outcome.ok)
|
|
1548
|
+
console.error(`[rfc005] eager fetch of ${record.refId} failed: ${outcome.error}`);
|
|
1549
|
+
}));
|
|
1550
|
+
}
|
|
1409
1551
|
/** Core agent_settings keys — extension keys must not collide with these. */
|
|
1410
1552
|
static AGENT_SETTINGS_CORE_KEYS = [
|
|
1411
1553
|
'context_budget_tokens',
|
|
@@ -1552,6 +1694,18 @@ export class AgentFramework {
|
|
|
1552
1694
|
};
|
|
1553
1695
|
}
|
|
1554
1696
|
getToolsForAgent(agentName, snapshot) {
|
|
1697
|
+
// The subconscious gets its own small surface (plus think/skip_reply/
|
|
1698
|
+
// end_turn), not the residents' tool board: no channel lifecycle, no
|
|
1699
|
+
// gate rules, no workspace — it observes, judges, and reports.
|
|
1700
|
+
if (agentName === this.subconsciousAgentName) {
|
|
1701
|
+
const basics = this.getAllTools()
|
|
1702
|
+
.filter((t) => t.name === 'think' || t.name === 'skip_reply' || t.name === 'end_turn')
|
|
1703
|
+
.map((t) => t.name === 'think'
|
|
1704
|
+
? this.buildThinkTool(snapshot?.sameRoundThinkTextPolicy
|
|
1705
|
+
?? this.getAgentRuntimeSettings(agentName).sameRoundThinkTextPolicy)
|
|
1706
|
+
: t);
|
|
1707
|
+
return [...SUBCONSCIOUS_TOOLS, ...basics];
|
|
1708
|
+
}
|
|
1555
1709
|
return this.getAllTools().map((tool) => {
|
|
1556
1710
|
if (tool.name === 'think') {
|
|
1557
1711
|
return this.buildThinkTool(snapshot?.sameRoundThinkTextPolicy
|
|
@@ -1907,21 +2061,35 @@ export class AgentFramework {
|
|
|
1907
2061
|
* Call cleanup() when done to release the ContextManager.
|
|
1908
2062
|
*/
|
|
1909
2063
|
async createEphemeralAgent(config) {
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
2064
|
+
// Names are Chronicle namespaces and generation identities. Reserve before
|
|
2065
|
+
// opening the namespace so a second creation cannot append to an earlier
|
|
2066
|
+
// generation before runEphemeralToCompletion has a chance to reject it.
|
|
2067
|
+
if (this.usedEphemeralAgentNames.has(config.name) || this.agents.has(config.name)) {
|
|
2068
|
+
throw new Error(`Ephemeral agent name \"${config.name}\" is already registered or has been used in this framework`);
|
|
2069
|
+
}
|
|
2070
|
+
this.usedEphemeralAgentNames.add(config.name);
|
|
2071
|
+
try {
|
|
2072
|
+
const namespace = `subagent/${config.name}`;
|
|
2073
|
+
const contextManager = await ContextManager.open({
|
|
2074
|
+
store: this.store,
|
|
2075
|
+
namespace,
|
|
2076
|
+
isolate: true,
|
|
2077
|
+
strategy: config.strategy ?? new PassthroughStrategy(),
|
|
2078
|
+
membrane: this.membrane,
|
|
2079
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
2080
|
+
});
|
|
2081
|
+
const agent = new Agent(config, contextManager, this.membrane);
|
|
2082
|
+
this.ephemeralCandidates.set(agent, contextManager);
|
|
2083
|
+
const cleanup = () => {
|
|
2084
|
+
// Don't close the store — it's shared. Just release the CM.
|
|
2085
|
+
// Data persists in the store under the namespace for investigation.
|
|
2086
|
+
};
|
|
2087
|
+
return { agent, contextManager, cleanup };
|
|
2088
|
+
}
|
|
2089
|
+
catch (error) {
|
|
2090
|
+
this.usedEphemeralAgentNames.delete(config.name);
|
|
2091
|
+
throw error;
|
|
2092
|
+
}
|
|
1925
2093
|
}
|
|
1926
2094
|
createDeferred() {
|
|
1927
2095
|
let resolve;
|
|
@@ -1942,6 +2110,18 @@ export class AgentFramework {
|
|
|
1942
2110
|
}
|
|
1943
2111
|
run.lastActivity = Date.now();
|
|
1944
2112
|
}
|
|
2113
|
+
/** Record structured calls for the current logical turn. Ignore late stream
|
|
2114
|
+
* events after an ephemeral/conversation agent has been disposed. */
|
|
2115
|
+
recordLogicalTurnToolCalls(agent, turnToken, count) {
|
|
2116
|
+
if (count <= 0 || this.agents.get(agent.name) !== agent)
|
|
2117
|
+
return;
|
|
2118
|
+
const state = this.logicalTurnToolCalls.get(agent);
|
|
2119
|
+
// The Agent object is the generation: a late stream from a disposed
|
|
2120
|
+
// ephemeral/conversation fork cannot contaminate a same-name successor.
|
|
2121
|
+
if (!state || state.turnToken !== turnToken)
|
|
2122
|
+
return;
|
|
2123
|
+
this.logicalTurnToolCalls.set(agent, { turnToken, count: state.count + count });
|
|
2124
|
+
}
|
|
1945
2125
|
recordEphemeralToolCalls(agentName, count) {
|
|
1946
2126
|
const run = this.ephemeralRuns.get(agentName);
|
|
1947
2127
|
if (!run)
|
|
@@ -1978,7 +2158,18 @@ export class AgentFramework {
|
|
|
1978
2158
|
* The task message should already be in the context manager.
|
|
1979
2159
|
*/
|
|
1980
2160
|
async runEphemeralToCompletion(agent, contextManager, watchdogs) {
|
|
1981
|
-
//
|
|
2161
|
+
// Only a fresh object returned by createEphemeralAgent may enter this path.
|
|
2162
|
+
// Never overwrite a resident/conversation owner or a concurrent run: their
|
|
2163
|
+
// cleanup is name-keyed and could cancel/deregister the legitimate owner.
|
|
2164
|
+
if (this.ephemeralCandidates.get(agent) !== contextManager) {
|
|
2165
|
+
throw new Error(`Ephemeral agent "${agent.name}" has no fresh generation ticket from this framework`);
|
|
2166
|
+
}
|
|
2167
|
+
// Consume before any await/registration: the exact (Agent, ContextManager)
|
|
2168
|
+
// generation is one-shot even if startup later fails.
|
|
2169
|
+
this.ephemeralCandidates.delete(agent);
|
|
2170
|
+
if (this.agents.has(agent.name) || this.ephemeralRuns.has(agent.name)) {
|
|
2171
|
+
throw new Error(`Ephemeral agent "${agent.name}" is already registered or running`);
|
|
2172
|
+
}
|
|
1982
2173
|
this.agents.set(agent.name, agent);
|
|
1983
2174
|
const run = {
|
|
1984
2175
|
settle: this.createDeferred(),
|
|
@@ -2037,8 +2228,26 @@ export class AgentFramework {
|
|
|
2037
2228
|
clearTimeout(startupWatchdog);
|
|
2038
2229
|
if (completionWatchdog)
|
|
2039
2230
|
clearInterval(completionWatchdog);
|
|
2040
|
-
|
|
2041
|
-
this
|
|
2231
|
+
// Mark before cancellation: an adapter may synchronously settle its
|
|
2232
|
+
// iterator, and this physical frame still owns terminal typing/outgoing
|
|
2233
|
+
// closure even though the ephemeral name is about to be deregistered.
|
|
2234
|
+
this.disposedEphemeralFrames.add(agent);
|
|
2235
|
+
// Cancel before deregistration. Adapters may still yield a queued event;
|
|
2236
|
+
// driveStream's Agent-identity check above discards it before dispatch.
|
|
2237
|
+
agent.cancelStream();
|
|
2238
|
+
if (this.ephemeralRuns.get(agent.name) === run)
|
|
2239
|
+
this.ephemeralRuns.delete(agent.name);
|
|
2240
|
+
if (this.agents.get(agent.name) === agent) {
|
|
2241
|
+
// Exact-generation disposal owns these name-keyed entries. Remove them
|
|
2242
|
+
// before deregistration; the late driveStream finally will see the
|
|
2243
|
+
// generation mismatch and leave any future owner untouched.
|
|
2244
|
+
this.activeStreams.delete(agent.name);
|
|
2245
|
+
this.pendingAssistantBlocks.delete(agent.name);
|
|
2246
|
+
// The late stream finalizer intentionally refuses name-keyed cleanup once
|
|
2247
|
+
// deregistered, so disposal must release EventGate liveness itself.
|
|
2248
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
2249
|
+
this.agents.delete(agent.name);
|
|
2250
|
+
}
|
|
2042
2251
|
// Spawn-and-dispose bookkeeping (main, d453165/fee96a7): without this,
|
|
2043
2252
|
// ephemeral agents leave checkpoint-tree keys and diagnostics map
|
|
2044
2253
|
// entries behind for the life of the store/session.
|
|
@@ -2049,6 +2258,8 @@ export class AgentFramework {
|
|
|
2049
2258
|
// unbounded map growth. Blind delete is safe: the agent is already out
|
|
2050
2259
|
// of the map, and a late driveStream finally token-match no-ops.
|
|
2051
2260
|
this.activeTurnTokens.delete(agent.name);
|
|
2261
|
+
this.activeTurnTriggers.delete(agent.name);
|
|
2262
|
+
this.logicalTurnToolCalls.delete(agent);
|
|
2052
2263
|
}
|
|
2053
2264
|
}
|
|
2054
2265
|
/**
|
|
@@ -2061,6 +2272,44 @@ export class AgentFramework {
|
|
|
2061
2272
|
* Query inference logs.
|
|
2062
2273
|
* Returns entries with summary info (doesn't resolve blobs).
|
|
2063
2274
|
*/
|
|
2275
|
+
/** Tune-out (issue #77): divert a channel to your subconscious. Present
|
|
2276
|
+
* only when the subconscious resident is configured. */
|
|
2277
|
+
static TUNE_OUT_TOOL = {
|
|
2278
|
+
name: 'tune_out',
|
|
2279
|
+
description: 'Divert a channel to your subconscious for a while: its traffic stops ' +
|
|
2280
|
+
'entering your context and stops waking you. Your subconscious watches ' +
|
|
2281
|
+
'it instead — summarizing on a cadence in its own voice, judging ' +
|
|
2282
|
+
'whether mentions merit interrupting, and able to cancel. People who ' +
|
|
2283
|
+
'mention you get an automatic reaction so they know you saw nothing. ' +
|
|
2284
|
+
'Cancelling (mode "cancel") delivers the diverted backlog. The ' +
|
|
2285
|
+
'tune-out auto-cancels after maxWakes mention-bursts.',
|
|
2286
|
+
inputSchema: {
|
|
2287
|
+
type: 'object',
|
|
2288
|
+
properties: {
|
|
2289
|
+
channelId: { type: 'string' },
|
|
2290
|
+
mode: { type: 'string', enum: ['enter', 'cancel'], description: 'Default: enter.' },
|
|
2291
|
+
cadenceSeconds: {
|
|
2292
|
+
type: 'number',
|
|
2293
|
+
description: `Summary cadence in seconds (default ${TUNE_OUT_DEFAULTS.cadenceSeconds}; floors at 30).`,
|
|
2294
|
+
},
|
|
2295
|
+
backlogCap: {
|
|
2296
|
+
type: 'number',
|
|
2297
|
+
description: `Max raw messages delivered at cancel (default ${TUNE_OUT_DEFAULTS.backlogCap}). ` +
|
|
2298
|
+
'0 = deliver no raw backlog at all: your subconscious\'s report is the whole delivery.',
|
|
2299
|
+
},
|
|
2300
|
+
maxWakes: {
|
|
2301
|
+
type: 'number',
|
|
2302
|
+
description: `Wake budget before auto-cancel (default ${TUNE_OUT_DEFAULTS.maxWakes}; floors at 1).`,
|
|
2303
|
+
},
|
|
2304
|
+
durationSeconds: {
|
|
2305
|
+
type: 'number',
|
|
2306
|
+
description: 'Optional bounded duration: the tune-out auto-cancels (backlog ' +
|
|
2307
|
+
'delivered) after this many seconds; floors at 60. Unset = until cancelled.',
|
|
2308
|
+
},
|
|
2309
|
+
},
|
|
2310
|
+
required: ['channelId'],
|
|
2311
|
+
},
|
|
2312
|
+
};
|
|
2064
2313
|
/** Synthesized sleep/wake tool definitions (present when a gate is wired). */
|
|
2065
2314
|
static SLEEP_TOOLS = [
|
|
2066
2315
|
{
|
|
@@ -2265,6 +2514,26 @@ export class AgentFramework {
|
|
|
2265
2514
|
required: ['path'],
|
|
2266
2515
|
},
|
|
2267
2516
|
};
|
|
2517
|
+
/** Synthesized fetch_reference tool — RFC-005 on-demand dereference.
|
|
2518
|
+
* Present when a workspace module is registered (storage target). */
|
|
2519
|
+
static FETCH_REFERENCE_TOOL = {
|
|
2520
|
+
name: 'fetch_reference',
|
|
2521
|
+
description: 'Fetch a referenced payload (a [ref_…] id from a tool result or event) ' +
|
|
2522
|
+
'to a workspace file via the host-mediated fetcher. The fetch is ' +
|
|
2523
|
+
'origin-bound to the server that sent the reference, size-capped, and ' +
|
|
2524
|
+
'digest-verified when the reference carries one. Returns the workspace ' +
|
|
2525
|
+
'path plus verified size/type; then use ordinary workspace tools on it. ' +
|
|
2526
|
+
'Small payloads may already be fetched automatically — their stubs show ' +
|
|
2527
|
+
'a saved path instead.',
|
|
2528
|
+
inputSchema: {
|
|
2529
|
+
type: 'object',
|
|
2530
|
+
properties: {
|
|
2531
|
+
ref_id: { type: 'string', description: 'The reference id, e.g. "ref_1a_x9z2".' },
|
|
2532
|
+
max_bytes: { type: 'number', description: 'Optional per-call byte ceiling (default host policy).' },
|
|
2533
|
+
},
|
|
2534
|
+
required: ['ref_id'],
|
|
2535
|
+
},
|
|
2536
|
+
};
|
|
2268
2537
|
/** Synthesized read_image tool — present when a workspace module is
|
|
2269
2538
|
* registered. Returns the image as a native image block in the tool
|
|
2270
2539
|
* result, so the agent SEES it in the live turn (history keeps a compact
|
|
@@ -3292,6 +3561,15 @@ export class AgentFramework {
|
|
|
3292
3561
|
// streaming agent keeps the original Membrane.
|
|
3293
3562
|
membrane: this.auxiliaryMembraneFor(config.name),
|
|
3294
3563
|
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
3564
|
+
// Tune-out (issue #77): messages stamped at ingestion as diverted
|
|
3565
|
+
// (metadata.tuneOut = { epochId }) never enter a resident's compiled
|
|
3566
|
+
// view — not at emission, not via chunking into the memory pyramid.
|
|
3567
|
+
// The stamp is permanent: after cancel, the backlog arrives as one
|
|
3568
|
+
// appended <tuned-out-backlog> dump instead of retro-inserting into
|
|
3569
|
+
// the timeline (KV-prefix-stable delivery). The stored originals
|
|
3570
|
+
// remain in the shared slot for the subconscious's merged view,
|
|
3571
|
+
// fetch_history, and audit.
|
|
3572
|
+
viewFilter: (message) => !message.metadata?.tuneOut,
|
|
3295
3573
|
});
|
|
3296
3574
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
3297
3575
|
const restoredSettings = this.readAgentRuntimeSettings(config.name);
|
|
@@ -3306,6 +3584,68 @@ export class AgentFramework {
|
|
|
3306
3584
|
}
|
|
3307
3585
|
return agent;
|
|
3308
3586
|
}
|
|
3587
|
+
/**
|
|
3588
|
+
* Create and register the subconscious resident (issue #77): a persistent
|
|
3589
|
+
* side-agent with an isolated slot (`subconscious/<primary>`) whose
|
|
3590
|
+
* strategy view merges the residents' shared timeline with its own —
|
|
3591
|
+
* including the tuned-out-stamped messages the residents' viewFilter
|
|
3592
|
+
* excludes; the backlog is precisely what it exists to see. Windowed
|
|
3593
|
+
* passthrough, no memory pyramid: its durable output is what it delivers
|
|
3594
|
+
* into the resident's window, which accumulates there.
|
|
3595
|
+
*/
|
|
3596
|
+
async createSubconsciousAgent(cfg) {
|
|
3597
|
+
const primaryName = this.primaryAgentName;
|
|
3598
|
+
const primaryConfig = primaryName ? this.agentConfigs.get(primaryName) : undefined;
|
|
3599
|
+
if (!primaryName || !primaryConfig) {
|
|
3600
|
+
throw new Error('subconscious requires a primary agent to attend to');
|
|
3601
|
+
}
|
|
3602
|
+
const name = cfg.name ?? 'Subconscious';
|
|
3603
|
+
if (this.agents.has(name)) {
|
|
3604
|
+
throw new Error(`subconscious name "${name}" collides with a registered agent`);
|
|
3605
|
+
}
|
|
3606
|
+
const strategy = new WindowedPassthroughStrategy({
|
|
3607
|
+
reAnchorFraction: cfg.reAnchorFraction,
|
|
3608
|
+
// Mirror the principal's per-message truncation ceiling so both see
|
|
3609
|
+
// oversized tool results / attachments under the same policy.
|
|
3610
|
+
maxMessageTokens: primaryConfig.strategy?.maxMessageTokens,
|
|
3611
|
+
});
|
|
3612
|
+
const contextManager = await ContextManager.open({
|
|
3613
|
+
store: this.store,
|
|
3614
|
+
namespace: `subconscious/${primaryName}`,
|
|
3615
|
+
isolate: true,
|
|
3616
|
+
// The residents' shared un-namespaced slot, merged read-only and
|
|
3617
|
+
// UNfiltered (no viewFilter here — stamped messages included).
|
|
3618
|
+
auxiliaryMessageViews: [{}],
|
|
3619
|
+
strategy,
|
|
3620
|
+
membrane: this.membrane,
|
|
3621
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
3622
|
+
});
|
|
3623
|
+
// Inherit the principal's inference settings wholesale (fork-template
|
|
3624
|
+
// idiom): reasoning effort (thinking), provider params, token ceilings,
|
|
3625
|
+
// caching, refusal handling, timezone — a same-model side-process must
|
|
3626
|
+
// not silently run under different inference conditions than the
|
|
3627
|
+
// resident it serves. Only identity- and surface-defining fields are
|
|
3628
|
+
// overridden below.
|
|
3629
|
+
const agentConfig = {
|
|
3630
|
+
...primaryConfig,
|
|
3631
|
+
name,
|
|
3632
|
+
model: cfg.model ?? primaryConfig.model,
|
|
3633
|
+
systemPrompt: cfg.systemPrompt,
|
|
3634
|
+
strategy: undefined, // its CM owns the windowed strategy instance
|
|
3635
|
+
// Prose is never auto-routed: a cadence-triggered turn carries no
|
|
3636
|
+
// locus, and bare prose must not fall through to the default channel.
|
|
3637
|
+
// Speech happens only via speak_in_channel (its own marked voice).
|
|
3638
|
+
proseRouting: 'explicit',
|
|
3639
|
+
allowedTools: [...SUBCONSCIOUS_TOOL_NAMES, 'think', 'skip_reply', 'end_turn'],
|
|
3640
|
+
};
|
|
3641
|
+
const agent = new Agent(agentConfig, contextManager, this.membrane);
|
|
3642
|
+
this.agents.set(name, agent);
|
|
3643
|
+
this.agentConfigs.set(name, agentConfig);
|
|
3644
|
+
this.subconsciousAgentName = name;
|
|
3645
|
+
this.subconsciousStrategy = strategy;
|
|
3646
|
+
this.subconsciousConfig = cfg;
|
|
3647
|
+
return agent;
|
|
3648
|
+
}
|
|
3309
3649
|
async runLoop() {
|
|
3310
3650
|
while (this.running) {
|
|
3311
3651
|
try {
|
|
@@ -3430,12 +3770,11 @@ export class AgentFramework {
|
|
|
3430
3770
|
// queued, they flush at the target's own next boundary — with
|
|
3431
3771
|
// injection — or in driveStream's finally when its turn ends.
|
|
3432
3772
|
const midTurnInjections = [];
|
|
3433
|
-
|
|
3434
|
-
const
|
|
3435
|
-
if (
|
|
3436
|
-
const deferred = this.deferredMessages.splice(0);
|
|
3773
|
+
{
|
|
3774
|
+
const deferred = this.drainDeferredFor(agent.name);
|
|
3775
|
+
if (deferred.length > 0) {
|
|
3437
3776
|
for (const msg of deferred) {
|
|
3438
|
-
|
|
3777
|
+
agent.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
|
|
3439
3778
|
// Injection guards: tool blocks would corrupt the tool-cycle
|
|
3440
3779
|
// structure the membrane enforces, and a message named as the
|
|
3441
3780
|
// agent itself would render as an ASSISTANT turn on the wire
|
|
@@ -3732,7 +4071,7 @@ export class AgentFramework {
|
|
|
3732
4071
|
// Broadcast requests exclude conversation forks — they are driven by
|
|
3733
4072
|
// their own channel's messages, not by framework-wide events.
|
|
3734
4073
|
const targetAgents = response.requestInference === true
|
|
3735
|
-
? Array.from(this.agents.keys()).filter((n) => !this.conversationAgentHomes.has(n))
|
|
4074
|
+
? Array.from(this.agents.keys()).filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName)
|
|
3736
4075
|
: response.requestInference;
|
|
3737
4076
|
for (const agentName of targetAgents) {
|
|
3738
4077
|
const agent = this.agents.get(agentName);
|
|
@@ -3799,11 +4138,31 @@ export class AgentFramework {
|
|
|
3799
4138
|
Object.assign(metadata, invitation.metadataPatch);
|
|
3800
4139
|
}
|
|
3801
4140
|
}
|
|
4141
|
+
// Tune-out divert (issue #77): stamped, stored, no resident wake. The
|
|
4142
|
+
// coordinator has already handled wake bookkeeping (coalesced
|
|
4143
|
+
// subconscious invocation, durable count, suppression ack) before we
|
|
4144
|
+
// stamp; the subconscious reads the message through its merged view.
|
|
4145
|
+
const divert = this.tuneOutCoordinator?.onIncoming(event.serverId, event.channelId, event.messageId, event.tags, event.author?.id,
|
|
4146
|
+
// The resident's gate verdict rides the event; it preconditions
|
|
4147
|
+
// subconscious wakes (gate composes, it is not replaced).
|
|
4148
|
+
event.triggerInference !== false) ?? null;
|
|
4149
|
+
if (divert) {
|
|
4150
|
+
metadata.tuneOut = { epochId: divert.epochId };
|
|
4151
|
+
}
|
|
3802
4152
|
const id = this.addMessage('user', incomingContent, metadata);
|
|
3803
4153
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
|
|
3804
|
-
if (event.triggerInference) {
|
|
4154
|
+
if (event.triggerInference && !divert) {
|
|
3805
4155
|
const addressed = isAddressedMessage(event.tags, event.metadata);
|
|
3806
|
-
|
|
4156
|
+
// Honor explicit targeting, mirroring the push-event path: an event
|
|
4157
|
+
// that names its agents wakes exactly those; an untargeted event
|
|
4158
|
+
// keeps the historical broadcast. (targetAgents was declared on
|
|
4159
|
+
// McplChannelIncomingEvent from the start but never honored here —
|
|
4160
|
+
// tune-out's wake routing is the first setter.)
|
|
4161
|
+
const targetAgents = event.targetAgents
|
|
4162
|
+
?? [...this.agents.keys()].filter((n) => n !== this.subconsciousAgentName);
|
|
4163
|
+
for (const agentName of targetAgents) {
|
|
4164
|
+
if (!this.agents.has(agentName))
|
|
4165
|
+
continue;
|
|
3807
4166
|
this.pendingRequests.push({
|
|
3808
4167
|
agentName,
|
|
3809
4168
|
reason: 'mcpl:channel-incoming',
|
|
@@ -3812,6 +4171,7 @@ export class AgentFramework {
|
|
|
3812
4171
|
// Route this turn's auto-published speech back to THIS channel, not
|
|
3813
4172
|
// the global most-recent-inbound locus (item-3 redux, trunk agents).
|
|
3814
4173
|
channelId: event.channelId,
|
|
4174
|
+
counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
|
|
3815
4175
|
// Addressed messages outrank ambient chatter when a batched wake
|
|
3816
4176
|
// picks the turn's frozen speech locus.
|
|
3817
4177
|
addressed,
|
|
@@ -3896,6 +4256,7 @@ export class AgentFramework {
|
|
|
3896
4256
|
// A fork's home channel wins in routeSpeech regardless, but carry the
|
|
3897
4257
|
// triggering channel too so the trunk/active path stays consistent.
|
|
3898
4258
|
channelId: event.channelId,
|
|
4259
|
+
counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
|
|
3899
4260
|
addressed: isAddressedMessage(event.tags, event.metadata),
|
|
3900
4261
|
});
|
|
3901
4262
|
}
|
|
@@ -3914,38 +4275,48 @@ export class AgentFramework {
|
|
|
3914
4275
|
if (!template || !templateConfig) {
|
|
3915
4276
|
throw new Error(`conversation template agent "${router.templateAgent}" not found`);
|
|
3916
4277
|
}
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
4278
|
+
if (this.usedEphemeralAgentNames.has(name) || this.agents.has(name)) {
|
|
4279
|
+
throw new Error(`Conversation agent name "${name}" is already registered or reserved`);
|
|
4280
|
+
}
|
|
4281
|
+
this.usedEphemeralAgentNames.add(name);
|
|
4282
|
+
try {
|
|
4283
|
+
const contextManager = await ContextManager.open({
|
|
4284
|
+
store: this.store,
|
|
4285
|
+
namespace: `conversations/${name}`,
|
|
4286
|
+
isolate: true,
|
|
4287
|
+
// Strategy instances are stateful — never share the template's.
|
|
4288
|
+
strategy: router.strategyFactory?.() ?? new PassthroughStrategy(),
|
|
4289
|
+
// Dynamic conversation forks retain the established provider policy in
|
|
4290
|
+
// this bounded first slice. Provider cooldown ownership belongs to the
|
|
4291
|
+
// persistent resident only; generation-unique forks must not leak gates.
|
|
4292
|
+
membrane: this.membrane,
|
|
4293
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
4294
|
+
});
|
|
4295
|
+
// Seed with the template's compiled context, renaming the template
|
|
4296
|
+
// participant so the fork reads its inheritance as its own history.
|
|
4297
|
+
// Guard: only seed a genuinely fresh namespace. Generation counters are
|
|
4298
|
+
// persisted precisely so names aren't reused, but if this namespace has
|
|
4299
|
+
// history anyway (counter state lost, crash between spawn and persist),
|
|
4300
|
+
// seeding again would stack another template copy on top of it.
|
|
4301
|
+
const { messages: existing } = await contextManager.compile();
|
|
4302
|
+
if (existing.length === 0) {
|
|
4303
|
+
const { messages: compiled } = await template.getContextManager().compile();
|
|
4304
|
+
for (const msg of compiled) {
|
|
4305
|
+
const participant = msg.participant === template.name ? name : msg.participant;
|
|
4306
|
+
contextManager.addMessage(participant, msg.content);
|
|
4307
|
+
}
|
|
4308
|
+
}
|
|
4309
|
+
const config = { ...templateConfig, name, strategy: undefined };
|
|
4310
|
+
const agent = new Agent(config, contextManager, this.membrane);
|
|
4311
|
+
this.agents.set(name, agent);
|
|
4312
|
+
this.agentConfigs.set(name, config);
|
|
4313
|
+
this.conversationAgentHomes.set(name, channelId);
|
|
4314
|
+
return agent;
|
|
4315
|
+
}
|
|
4316
|
+
catch (error) {
|
|
4317
|
+
this.usedEphemeralAgentNames.delete(name);
|
|
4318
|
+
throw error;
|
|
4319
|
+
}
|
|
3949
4320
|
}
|
|
3950
4321
|
/** Persist the router's generation counters (see hydration in create()). */
|
|
3951
4322
|
persistConversationRouterState() {
|
|
@@ -3969,10 +4340,13 @@ export class AgentFramework {
|
|
|
3969
4340
|
disposeConversationAgent(agentName) {
|
|
3970
4341
|
this.closingConversationAgents.delete(agentName);
|
|
3971
4342
|
const channelId = this.conversationAgentHomes.get(agentName);
|
|
4343
|
+
const agent = this.agents.get(agentName);
|
|
3972
4344
|
this.agents.delete(agentName);
|
|
3973
4345
|
this.agentConfigs.delete(agentName);
|
|
3974
4346
|
this.conversationAgentHomes.delete(agentName);
|
|
3975
4347
|
this.evictTurnCheckpoints(agentName);
|
|
4348
|
+
if (agent)
|
|
4349
|
+
this.logicalTurnToolCalls.delete(agent);
|
|
3976
4350
|
this.emitTrace({
|
|
3977
4351
|
type: 'mcpl:conversation-disposed',
|
|
3978
4352
|
agentName,
|
|
@@ -4172,7 +4546,7 @@ export class AgentFramework {
|
|
|
4172
4546
|
if (event.triggerInference) {
|
|
4173
4547
|
// Default broadcast excludes conversation forks (channel-driven).
|
|
4174
4548
|
const targetAgents = event.targetAgents
|
|
4175
|
-
?? [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n));
|
|
4549
|
+
?? [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
|
|
4176
4550
|
for (const agentName of targetAgents) {
|
|
4177
4551
|
this.pendingRequests.push({
|
|
4178
4552
|
agentName,
|
|
@@ -4354,6 +4728,10 @@ export class AgentFramework {
|
|
|
4354
4728
|
// exactly this class of silent drop. One stderr line per drop.
|
|
4355
4729
|
console.error(`[inference-dropped] agent=${agentName} reason=policy-skip ` +
|
|
4356
4730
|
`requests=${requests.length} triggers=${requests.map((r) => r.reason).join(',')}`);
|
|
4731
|
+
// A context-budget restart inherits the predecessor's EventGate liveness.
|
|
4732
|
+
// If policy drops the queued successor, no driveStream remains to release it.
|
|
4733
|
+
if (budgetRestart)
|
|
4734
|
+
this.eventGate?.onInferenceEnded(agentName);
|
|
4357
4735
|
const gate = this.providerGates.get(agentName);
|
|
4358
4736
|
if (gate?.primaryPending && this.providerAccelerationRecoveries.has(agentName)) {
|
|
4359
4737
|
gate.primaryPending = false;
|
|
@@ -4380,21 +4758,29 @@ export class AgentFramework {
|
|
|
4380
4758
|
// newest (2026-07-21 Cairn lounge misroute, turn-start variant).
|
|
4381
4759
|
// Non-channel wakes (heartbeats, module events, reactions — which never
|
|
4382
4760
|
// carry channelId) leave both undefined → global fallback.
|
|
4383
|
-
|
|
4384
|
-
|
|
4761
|
+
// Track the winning REQUEST, not just its channel: channel, addressed
|
|
4762
|
+
// and counterparty must come from the same message, or a batch of
|
|
4763
|
+
// "ambient from A, then addressed from B" would report B's channel
|
|
4764
|
+
// with A's author (review finding on the provenance change).
|
|
4765
|
+
let ambientReq;
|
|
4766
|
+
let addressedReq;
|
|
4385
4767
|
for (const r of requests) {
|
|
4386
4768
|
if (!r.channelId)
|
|
4387
4769
|
continue;
|
|
4388
|
-
|
|
4770
|
+
ambientReq = r;
|
|
4389
4771
|
if (r.addressed)
|
|
4390
|
-
|
|
4772
|
+
addressedReq = r;
|
|
4391
4773
|
}
|
|
4392
|
-
const
|
|
4393
|
-
const triggerAddressed = addressedChannel !== undefined;
|
|
4774
|
+
const channelReq = addressedReq ?? ambientReq;
|
|
4394
4775
|
await this.startAgentStream(agent, {
|
|
4395
4776
|
...trigger,
|
|
4396
|
-
channelId:
|
|
4397
|
-
addressed:
|
|
4777
|
+
channelId: channelReq?.channelId,
|
|
4778
|
+
addressed: addressedReq !== undefined,
|
|
4779
|
+
// A context-budget restart continues the same logical turn: it keeps
|
|
4780
|
+
// the channel for routing but names no author — the restart is its
|
|
4781
|
+
// own cause, and borrowing another request's author would be false
|
|
4782
|
+
// provenance.
|
|
4783
|
+
counterparty: budgetRestart ? undefined : channelReq?.counterparty,
|
|
4398
4784
|
});
|
|
4399
4785
|
}
|
|
4400
4786
|
}
|
|
@@ -4862,6 +5248,7 @@ export class AgentFramework {
|
|
|
4862
5248
|
// no-ops instead of clobbering a successor's marker.
|
|
4863
5249
|
const turnToken = this.nextTurnToken++;
|
|
4864
5250
|
this.activeTurnTokens.set(agent.name, turnToken);
|
|
5251
|
+
this.activeTurnTriggers.set(agent.name, trigger);
|
|
4865
5252
|
let tokenHandedOff = false;
|
|
4866
5253
|
try {
|
|
4867
5254
|
tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate);
|
|
@@ -4869,6 +5256,7 @@ export class AgentFramework {
|
|
|
4869
5256
|
finally {
|
|
4870
5257
|
if (!tokenHandedOff && this.activeTurnTokens.get(agent.name) === turnToken) {
|
|
4871
5258
|
this.activeTurnTokens.delete(agent.name);
|
|
5259
|
+
this.activeTurnTriggers.delete(agent.name);
|
|
4872
5260
|
}
|
|
4873
5261
|
if (!tokenHandedOff && ownsProviderGate)
|
|
4874
5262
|
this.releasePrimaryProviderGate(agent.name);
|
|
@@ -4897,9 +5285,8 @@ export class AgentFramework {
|
|
|
4897
5285
|
if (attempt === 0 &&
|
|
4898
5286
|
trigger?.reason !== 'context_budget_restart' &&
|
|
4899
5287
|
this.deferredMessages.length > 0) {
|
|
4900
|
-
|
|
4901
|
-
|
|
4902
|
-
const deferred = this.deferredMessages.splice(0);
|
|
5288
|
+
{
|
|
5289
|
+
const deferred = this.drainDeferredFor(agent.name);
|
|
4903
5290
|
for (const msg of deferred) {
|
|
4904
5291
|
// Per-message try/catch (mirrors announceLocusIfChanged): one
|
|
4905
5292
|
// poison message — or a transient store-write failure — must not
|
|
@@ -4953,9 +5340,22 @@ export class AgentFramework {
|
|
|
4953
5340
|
// BEFORE this turn compiles, so the agent always knows where its voice
|
|
4954
5341
|
// goes (announce-on-change only — no per-turn chatter, append-only for
|
|
4955
5342
|
// KV stability).
|
|
5343
|
+
if (trigger?.reason === 'context_budget_restart') {
|
|
5344
|
+
const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
5345
|
+
this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
|
|
5346
|
+
}
|
|
4956
5347
|
if (trigger?.reason !== 'context_budget_restart') {
|
|
4957
5348
|
if (attempt === 0)
|
|
4958
5349
|
this.maybePrimeProseMode(agent);
|
|
5350
|
+
const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
5351
|
+
if (attempt === 0) {
|
|
5352
|
+
// A true new turn gets a fresh generation and count.
|
|
5353
|
+
this.logicalTurnToolCalls.set(agent, { turnToken, count: 0 });
|
|
5354
|
+
}
|
|
5355
|
+
else {
|
|
5356
|
+
// A framework retry is a new physical stream in the same logical turn.
|
|
5357
|
+
this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
|
|
5358
|
+
}
|
|
4959
5359
|
// Fresh turn: forget the previous turn's explicit-send engagements and
|
|
4960
5360
|
// prose deliveries — both are strictly turn-scoped (a restart continues
|
|
4961
5361
|
// the same logical turn and keeps them).
|
|
@@ -4993,7 +5393,11 @@ export class AgentFramework {
|
|
|
4993
5393
|
// context-budget restart, which skips the re-pin but re-emits this).
|
|
4994
5394
|
channelId: this.turnLocusPins.get(agent.name),
|
|
4995
5395
|
});
|
|
4996
|
-
|
|
5396
|
+
// A budget restart continues the same logical inference window. The
|
|
5397
|
+
// predecessor keeps EventGate liveness until its successor terminates.
|
|
5398
|
+
if (trigger?.reason !== 'context_budget_restart') {
|
|
5399
|
+
this.eventGate?.onInferenceStarted(agent.name);
|
|
5400
|
+
}
|
|
4997
5401
|
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
|
|
4998
5402
|
// Typing indicator, started at TURN START. It says "attending", which is
|
|
4999
5403
|
// true from the moment the turn is accepted — but it used to start inside
|
|
@@ -5047,8 +5451,32 @@ export class AgentFramework {
|
|
|
5047
5451
|
console.error('beforeInference hook error:', error);
|
|
5048
5452
|
}
|
|
5049
5453
|
}
|
|
5050
|
-
|
|
5051
|
-
|
|
5454
|
+
// An ephemeral watchdog may dispose this Agent while hooks/compile await.
|
|
5455
|
+
// Do not create a provider stream after its generation lost ownership.
|
|
5456
|
+
if (this.agents.get(agent.name) !== agent) {
|
|
5457
|
+
this.channelRegistry?.stopTyping();
|
|
5458
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
5459
|
+
return false;
|
|
5460
|
+
}
|
|
5461
|
+
// The subconscious's standing dispositions ride a fixed system-position
|
|
5462
|
+
// injection (antra, #tuneout-talk): never repeated in its timeline, and
|
|
5463
|
+
// — since it has no long-term memory — structurally unable to wash out
|
|
5464
|
+
// of its window. Bytes change only when a disposition changes.
|
|
5465
|
+
if (agent.name === this.subconsciousAgentName && this.tuneOutCoordinator) {
|
|
5466
|
+
const dispositionInjection = this.tuneOutCoordinator.getDispositionsInjection();
|
|
5467
|
+
if (dispositionInjection) {
|
|
5468
|
+
injections = injections ? [...injections, dispositionInjection] : [dispositionInjection];
|
|
5469
|
+
}
|
|
5470
|
+
}
|
|
5471
|
+
const { stream, request: compiledRequest, takeKvSubmission, drainKvSubmissionIds, } = await agent.startStreamWithInjections(tools, injections);
|
|
5472
|
+
if (this.agents.get(agent.name) !== agent) {
|
|
5473
|
+
stream.cancel();
|
|
5474
|
+
agent.cancelStream();
|
|
5475
|
+
this.channelRegistry?.stopTyping();
|
|
5476
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
5477
|
+
return false;
|
|
5478
|
+
}
|
|
5479
|
+
const handle = this.driveStream(agent, stream, requestSnapshot, trigger, attempt, compiledRequest, ownsProviderGate, takeKvSubmission, drainKvSubmissionIds, new Set(tools.map((tool) => tool.name)));
|
|
5052
5480
|
this.activeStreams.set(agent.name, handle);
|
|
5053
5481
|
// Handoff: driveStream captured the token in its synchronous prefix;
|
|
5054
5482
|
// its finally now owns clearing it. The caller's finally must not.
|
|
@@ -5088,6 +5516,7 @@ export class AgentFramework {
|
|
|
5088
5516
|
// the eventGate `inferring` leak) — every exit path must clear it.
|
|
5089
5517
|
if (this.activeTurnTokens.get(agent.name) === turnToken) {
|
|
5090
5518
|
this.activeTurnTokens.delete(agent.name);
|
|
5519
|
+
this.activeTurnTriggers.delete(agent.name);
|
|
5091
5520
|
}
|
|
5092
5521
|
this.settleAgent(agent.name, {
|
|
5093
5522
|
stopReason: 'exhausted',
|
|
@@ -5120,15 +5549,26 @@ export class AgentFramework {
|
|
|
5120
5549
|
return false;
|
|
5121
5550
|
}
|
|
5122
5551
|
}
|
|
5123
|
-
async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest, ownsProviderGate = false) {
|
|
5552
|
+
async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest, ownsProviderGate = false, takeKvSubmission, drainKvSubmissionIds, knownToolNames = new Set()) {
|
|
5124
5553
|
const startTime = Date.now();
|
|
5125
5554
|
const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
|
|
5126
5555
|
const myStreamId = agent.streamId;
|
|
5556
|
+
// Membrane usage events are cumulative across the native/XML tool loop.
|
|
5557
|
+
// Keep the previous cumulative sample so consumers that operate at the
|
|
5558
|
+
// physical provider-call boundary (estimator calibration and kv receipt
|
|
5559
|
+
// acceptance) see this call only, not an ever-growing turn total.
|
|
5560
|
+
let previousUsage = {
|
|
5561
|
+
inputTokens: 0,
|
|
5562
|
+
outputTokens: 0,
|
|
5563
|
+
cacheCreationTokens: 0,
|
|
5564
|
+
cacheReadTokens: 0,
|
|
5565
|
+
};
|
|
5127
5566
|
// This turn's alive-marker, set at startAgentStream entry. Safe to read
|
|
5128
5567
|
// from the map here: the turn-alive busy check in processInferenceRequests
|
|
5129
5568
|
// means no successor turn can have replaced it while we compiled.
|
|
5130
5569
|
const myTurnToken = this.activeTurnTokens.get(agent.name);
|
|
5131
5570
|
let hadToolCalls = false;
|
|
5571
|
+
let generationLost = false;
|
|
5132
5572
|
// ---- Present-while-acting turn state ---------------------------------
|
|
5133
5573
|
// Output locus for the WHOLE logical turn: frozen in startAgentStream
|
|
5134
5574
|
// (home → addressed trigger → global default) before this stream began,
|
|
@@ -5203,13 +5643,20 @@ export class AgentFramework {
|
|
|
5203
5643
|
// failed, a framework-cancelled stream sets aborted. Best-effort
|
|
5204
5644
|
// notifications — consumers dedupe by inferenceId and keep a timeout.
|
|
5205
5645
|
let lifecyclePhase = 'completed';
|
|
5646
|
+
let preserveEventGateForSuccessor = false;
|
|
5206
5647
|
this.hookOrchestrator?.emitLifecycle({
|
|
5207
5648
|
inferenceId: outgoingInferenceId,
|
|
5208
5649
|
conversationId: agent.name,
|
|
5209
5650
|
turnIndex: 0,
|
|
5210
5651
|
phase: 'started',
|
|
5211
5652
|
});
|
|
5212
|
-
|
|
5653
|
+
// The wrapper guard needs the complete response before it can distinguish
|
|
5654
|
+
// an exact invocation-shaped body from ordinary XML/prose. Buffer guarded
|
|
5655
|
+
// turns until completion: otherwise speculative outgoing chunks could
|
|
5656
|
+
// expose the wrapper before the fail-closed classifier runs.
|
|
5657
|
+
const proseStream = this.channelRegistry &&
|
|
5658
|
+
agent.proseRouting !== 'disabled' &&
|
|
5659
|
+
!agent.toolWrapperProseGuard
|
|
5213
5660
|
? new ProseStreamRouter({
|
|
5214
5661
|
mode: agent.proseRouting === 'explicit' ? 'explicit' : agent.proseRouting === 'hybrid' ? 'hybrid' : 'locus',
|
|
5215
5662
|
initialTarget: typingChannel,
|
|
@@ -5235,6 +5682,14 @@ export class AgentFramework {
|
|
|
5235
5682
|
};
|
|
5236
5683
|
try {
|
|
5237
5684
|
for await (const event of stream) {
|
|
5685
|
+
// Ignore every late event from an Agent generation that no longer owns
|
|
5686
|
+
// this name (ephemeral watchdog/disposal, conversation replacement).
|
|
5687
|
+
if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
|
|
5688
|
+
generationLost = true;
|
|
5689
|
+
lifecyclePhase = 'aborted';
|
|
5690
|
+
stream.cancel();
|
|
5691
|
+
break;
|
|
5692
|
+
}
|
|
5238
5693
|
this.touchEphemeralRun(agent.name, true);
|
|
5239
5694
|
switch (event.type) {
|
|
5240
5695
|
case 'tokens':
|
|
@@ -5297,6 +5752,7 @@ export class AgentFramework {
|
|
|
5297
5752
|
case 'tool-calls': {
|
|
5298
5753
|
adoptInjectedRound();
|
|
5299
5754
|
hadToolCalls = true;
|
|
5755
|
+
this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length);
|
|
5300
5756
|
this.recordEphemeralToolCalls(agent.name, event.calls.length);
|
|
5301
5757
|
this.emitTrace({
|
|
5302
5758
|
type: 'inference:tool_calls_yielded',
|
|
@@ -5374,8 +5830,7 @@ export class AgentFramework {
|
|
|
5374
5830
|
const roundToolNames = event.calls.map((c) => c.name);
|
|
5375
5831
|
const hasSameRoundPrivateThink = roundToolNames.includes('think') &&
|
|
5376
5832
|
requestSnapshot.sameRoundThinkTextPolicy === 'private';
|
|
5377
|
-
if (roundToolNames.some(
|
|
5378
|
-
(agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n))))) {
|
|
5833
|
+
if (roundToolNames.some(isSilencingTool)) {
|
|
5379
5834
|
turnSilenced = true;
|
|
5380
5835
|
}
|
|
5381
5836
|
if (roundContent && roundContent.length > 0) {
|
|
@@ -5479,7 +5934,29 @@ export class AgentFramework {
|
|
|
5479
5934
|
const terminalContent = lastToolIdx >= 0
|
|
5480
5935
|
? response.content.slice(lastToolIdx + 1)
|
|
5481
5936
|
: response.content;
|
|
5482
|
-
|
|
5937
|
+
// This is a whole-response boundary, not a trailing-prose
|
|
5938
|
+
// classifier. If the turn executed any genuine structured tool
|
|
5939
|
+
// call, preserve its later prose exactly as ordinary history.
|
|
5940
|
+
const logicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
5941
|
+
const logicalTurnHadToolCalls = logicalToolState?.turnToken === myTurnToken && (logicalToolState?.count ?? 0) > 0;
|
|
5942
|
+
const guardedWrapperTool = agent.toolWrapperProseGuard && !logicalTurnHadToolCalls
|
|
5943
|
+
? detectKnownToolWrapperProse(response.content, knownToolNames)
|
|
5944
|
+
: null;
|
|
5945
|
+
if (guardedWrapperTool) {
|
|
5946
|
+
// AF #133: containment only. Never execute wrapper text, never
|
|
5947
|
+
// publish it, and never retain it as the freshest assistant
|
|
5948
|
+
// self-seed. Exact raw output remains in the provider ledger.
|
|
5949
|
+
agent.getContextManager().addMessage('user', [{
|
|
5950
|
+
type: 'text',
|
|
5951
|
+
text: '[tool-boundary] No tool was called.',
|
|
5952
|
+
}], {
|
|
5953
|
+
system: true,
|
|
5954
|
+
kind: 'tool-wrapper-prose-contained',
|
|
5955
|
+
toolName: guardedWrapperTool,
|
|
5956
|
+
});
|
|
5957
|
+
console.error(`[tool-boundary] ${agent.name}: contained whole-response prose wrapper for registered tool ${guardedWrapperTool}; no tool called`);
|
|
5958
|
+
}
|
|
5959
|
+
else if (lastToolIdx >= 0) {
|
|
5483
5960
|
if (terminalContent.length > 0) {
|
|
5484
5961
|
agent.addAssistantResponse(terminalContent);
|
|
5485
5962
|
}
|
|
@@ -5502,7 +5979,9 @@ export class AgentFramework {
|
|
|
5502
5979
|
// convention remains "text is thoughts"; on a text-only turn all
|
|
5503
5980
|
// text is speech.
|
|
5504
5981
|
const isTextBlock = (block) => block.type === 'text';
|
|
5505
|
-
const allText =
|
|
5982
|
+
const allText = guardedWrapperTool
|
|
5983
|
+
? []
|
|
5984
|
+
: response.content.filter(isTextBlock);
|
|
5506
5985
|
const speechContent = hadToolCalls ? [] : allText;
|
|
5507
5986
|
const thoughts = hadToolCalls ? allText : [];
|
|
5508
5987
|
const du = response.details?.usage;
|
|
@@ -5514,19 +5993,30 @@ export class AgentFramework {
|
|
|
5514
5993
|
cacheRead: du.cacheReadTokens,
|
|
5515
5994
|
}
|
|
5516
5995
|
: undefined;
|
|
5996
|
+
// Async completion work may overlap a replacement stream. Recheck
|
|
5997
|
+
// physical ownership before any terminal state/settlement side effect.
|
|
5998
|
+
if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
|
|
5999
|
+
generationLost = true;
|
|
6000
|
+
lifecyclePhase = 'aborted';
|
|
6001
|
+
stream.cancel();
|
|
6002
|
+
return;
|
|
6003
|
+
}
|
|
5517
6004
|
// Reset agent state before emitting inference:completed. Traces are
|
|
5518
6005
|
// observability-only, but external synchronous listeners should
|
|
5519
6006
|
// still see the terminal state at the terminal trace boundary.
|
|
5520
6007
|
// Speech dispatch happens after but doesn't depend on the status
|
|
5521
6008
|
// field.
|
|
5522
6009
|
agent.reset();
|
|
5523
|
-
this.
|
|
6010
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
|
|
6011
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
5524
6012
|
this.settleAgent(agent.name, {
|
|
5525
6013
|
stopReason: 'completed',
|
|
5526
|
-
speech:
|
|
5527
|
-
|
|
5528
|
-
|
|
5529
|
-
|
|
6014
|
+
speech: guardedWrapperTool
|
|
6015
|
+
? ''
|
|
6016
|
+
: terminalContent
|
|
6017
|
+
.filter((block) => block.type === 'text')
|
|
6018
|
+
.map((block) => block.text)
|
|
6019
|
+
.join('\n'),
|
|
5530
6020
|
});
|
|
5531
6021
|
this.emitTrace({
|
|
5532
6022
|
type: 'inference:completed',
|
|
@@ -5745,8 +6235,7 @@ export class AgentFramework {
|
|
|
5745
6235
|
.filter((n) => typeof n === 'string');
|
|
5746
6236
|
const silenced = liveProseRouting
|
|
5747
6237
|
? turnSilenced
|
|
5748
|
-
: turnSilenced || toolNames.some(
|
|
5749
|
-
(agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n))));
|
|
6238
|
+
: turnSilenced || toolNames.some(isSilencingTool);
|
|
5750
6239
|
const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content);
|
|
5751
6240
|
// Preserve in-channel ordering: everything enqueued live must
|
|
5752
6241
|
// land before the trailing prose. Awaited even when silenced —
|
|
@@ -5868,7 +6357,8 @@ export class AgentFramework {
|
|
|
5868
6357
|
agent.reset();
|
|
5869
6358
|
if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
|
|
5870
6359
|
lifecyclePhase = 'failed';
|
|
5871
|
-
this.
|
|
6360
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
|
|
6361
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
5872
6362
|
break;
|
|
5873
6363
|
}
|
|
5874
6364
|
const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
|
|
@@ -5892,7 +6382,8 @@ export class AgentFramework {
|
|
|
5892
6382
|
// itself) means retrying the same context can never succeed.
|
|
5893
6383
|
...this.classifyInferenceError(err),
|
|
5894
6384
|
});
|
|
5895
|
-
this.
|
|
6385
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
|
|
6386
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
5896
6387
|
if (action.emit) {
|
|
5897
6388
|
this.pushEvent(action.emit);
|
|
5898
6389
|
}
|
|
@@ -5913,7 +6404,14 @@ export class AgentFramework {
|
|
|
5913
6404
|
if (cancelKind !== undefined) {
|
|
5914
6405
|
this.frameworkCancelledStreams.delete(cancelKey);
|
|
5915
6406
|
lifecyclePhase = 'aborted'; // §10.5 — terminal emitted in finally
|
|
5916
|
-
|
|
6407
|
+
// A budget restart continues the same EventGate window. Do not
|
|
6408
|
+
// end it here or in finally; the replacement owns final release.
|
|
6409
|
+
if (cancelKind === 'budget_restart') {
|
|
6410
|
+
preserveEventGateForSuccessor = true;
|
|
6411
|
+
}
|
|
6412
|
+
else if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) {
|
|
6413
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
6414
|
+
}
|
|
5917
6415
|
// endTurn IS a logical turn end — earlier rounds may have
|
|
5918
6416
|
// live-routed prose (narrate → skip_reply is a real shape),
|
|
5919
6417
|
// so settle the delivery chain and drop the receipt. A
|
|
@@ -5960,7 +6458,8 @@ export class AgentFramework {
|
|
|
5960
6458
|
request: compiledRequest ?? { note: 'streaming request aborted' },
|
|
5961
6459
|
durationMs,
|
|
5962
6460
|
});
|
|
5963
|
-
this.
|
|
6461
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
|
|
6462
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
5964
6463
|
}
|
|
5965
6464
|
break;
|
|
5966
6465
|
}
|
|
@@ -5980,14 +6479,46 @@ export class AgentFramework {
|
|
|
5980
6479
|
// loop (5 calls x ~160k reported as 884k), which is not a
|
|
5981
6480
|
// window-shaped number and drove the multiplier to 2.37 before
|
|
5982
6481
|
// the guards caught it.
|
|
6482
|
+
const cumulativeUsage = {
|
|
6483
|
+
inputTokens: event.usage.inputTokens ?? 0,
|
|
6484
|
+
outputTokens: event.usage.outputTokens ?? 0,
|
|
6485
|
+
cacheCreationTokens: event.usage.cacheCreationTokens ?? 0,
|
|
6486
|
+
cacheReadTokens: event.usage.cacheReadTokens ?? 0,
|
|
6487
|
+
};
|
|
6488
|
+
const perCallUsage = {
|
|
6489
|
+
inputTokens: cumulativeDelta(cumulativeUsage.inputTokens, previousUsage.inputTokens),
|
|
6490
|
+
outputTokens: cumulativeDelta(cumulativeUsage.outputTokens, previousUsage.outputTokens),
|
|
6491
|
+
cacheCreationTokens: cumulativeDelta(cumulativeUsage.cacheCreationTokens, previousUsage.cacheCreationTokens),
|
|
6492
|
+
cacheReadTokens: cumulativeDelta(cumulativeUsage.cacheReadTokens, previousUsage.cacheReadTokens),
|
|
6493
|
+
};
|
|
6494
|
+
previousUsage = cumulativeUsage;
|
|
6495
|
+
const strat = agent.getContextManager?.()?.getStrategy?.();
|
|
5983
6496
|
try {
|
|
5984
|
-
const realTotal =
|
|
5985
|
-
|
|
5986
|
-
|
|
5987
|
-
const strat = agent.getContextManager?.()?.getStrategy?.();
|
|
6497
|
+
const realTotal = perCallUsage.inputTokens +
|
|
6498
|
+
perCallUsage.cacheCreationTokens +
|
|
6499
|
+
perCallUsage.cacheReadTokens;
|
|
5988
6500
|
strat?.reportRealInputTokens?.(realTotal);
|
|
5989
6501
|
}
|
|
5990
6502
|
catch { /* calibration is best-effort */ }
|
|
6503
|
+
const kvSubmission = takeKvSubmission?.();
|
|
6504
|
+
if (kvSubmission) {
|
|
6505
|
+
try {
|
|
6506
|
+
strat?.reportKvUnifiedAccepted?.({
|
|
6507
|
+
submissionId: kvSubmission.submissionId,
|
|
6508
|
+
acceptedAt: Date.now(),
|
|
6509
|
+
wireReceipt: kvSubmission.wireReceipt,
|
|
6510
|
+
});
|
|
6511
|
+
}
|
|
6512
|
+
catch {
|
|
6513
|
+
// The queue item has already been consumed. Explicitly close
|
|
6514
|
+
// the receipt flight so one persistence error cannot wedge
|
|
6515
|
+
// every later provider call on this branch.
|
|
6516
|
+
try {
|
|
6517
|
+
strat?.reportKvUnifiedFailed?.(kvSubmission.submissionId);
|
|
6518
|
+
}
|
|
6519
|
+
catch { /* teardown only */ }
|
|
6520
|
+
}
|
|
6521
|
+
}
|
|
5991
6522
|
this.emitTrace({
|
|
5992
6523
|
type: 'inference:usage',
|
|
5993
6524
|
agentName: agent.name,
|
|
@@ -6004,6 +6535,14 @@ export class AgentFramework {
|
|
|
6004
6535
|
}
|
|
6005
6536
|
}
|
|
6006
6537
|
catch (error) {
|
|
6538
|
+
// A superseded physical stream may throw after its replacement starts.
|
|
6539
|
+
// It has no authority to settle, reset, gate-release, or publish failure.
|
|
6540
|
+
if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
|
|
6541
|
+
generationLost = true;
|
|
6542
|
+
lifecyclePhase = 'aborted';
|
|
6543
|
+
stream.cancel();
|
|
6544
|
+
return;
|
|
6545
|
+
}
|
|
6007
6546
|
// Stream itself threw. Organization acceleration is deferred from a
|
|
6008
6547
|
// fresh compile; every other throw keeps the ordinary exhausted path.
|
|
6009
6548
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
@@ -6016,7 +6555,8 @@ export class AgentFramework {
|
|
|
6016
6555
|
this.abortAgentScript(agent.name, 'provider acceleration cooldown');
|
|
6017
6556
|
lifecyclePhase = 'failed';
|
|
6018
6557
|
agent.reset();
|
|
6019
|
-
this.
|
|
6558
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
|
|
6559
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
6020
6560
|
return;
|
|
6021
6561
|
}
|
|
6022
6562
|
this.emitTrace({
|
|
@@ -6051,9 +6591,33 @@ export class AgentFramework {
|
|
|
6051
6591
|
this.abortAgentScript(agent.name, 'stream threw');
|
|
6052
6592
|
lifecyclePhase = 'failed'; // §10.5 — terminal emitted in finally
|
|
6053
6593
|
agent.reset();
|
|
6054
|
-
this.
|
|
6594
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
|
|
6595
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
6055
6596
|
}
|
|
6056
6597
|
finally {
|
|
6598
|
+
const unsettledKvSubmissions = drainKvSubmissionIds?.() ?? [];
|
|
6599
|
+
if (unsettledKvSubmissions.length > 0) {
|
|
6600
|
+
try {
|
|
6601
|
+
const strategy = agent.getContextManager().getStrategy();
|
|
6602
|
+
for (const submissionId of unsettledKvSubmissions) {
|
|
6603
|
+
strategy.reportKvUnifiedFailed?.(submissionId);
|
|
6604
|
+
}
|
|
6605
|
+
}
|
|
6606
|
+
catch { /* receipt cleanup must not mask inference teardown */ }
|
|
6607
|
+
}
|
|
6608
|
+
// A framework retry/budget restart can overlap physical streams on the
|
|
6609
|
+
// same Agent object. Name + Agent identity is not enough: only the
|
|
6610
|
+
// current stream generation may mutate name-keyed teardown state.
|
|
6611
|
+
const ownsPhysicalStream = this.agents.get(agent.name) === agent && agent.streamId === myStreamId;
|
|
6612
|
+
// Ephemeral completion resolves its caller before this finally runs, so
|
|
6613
|
+
// runEphemeralToCompletion may already have deregistered the name. The
|
|
6614
|
+
// terminal frame still owns its typing/outgoing completion unless it was
|
|
6615
|
+
// genuinely superseded or handed EventGate liveness to a budget restart.
|
|
6616
|
+
const disposedEphemeralWithoutSuccessor = generationLost &&
|
|
6617
|
+
this.disposedEphemeralFrames.has(agent) &&
|
|
6618
|
+
!this.agents.has(agent.name);
|
|
6619
|
+
const frameReachedTerminal = (!generationLost || disposedEphemeralWithoutSuccessor) &&
|
|
6620
|
+
!preserveEventGateForSuccessor;
|
|
6057
6621
|
// §10.5: exactly one terminal per `started`, on every exit path the
|
|
6058
6622
|
// host controls. Which one was decided by the path taken (default
|
|
6059
6623
|
// completed; catch → failed; framework-cancel → aborted). A host
|
|
@@ -6072,28 +6636,35 @@ export class AgentFramework {
|
|
|
6072
6636
|
// all incoming events → the agent silently stops waking on messages
|
|
6073
6637
|
// (typing still stops, compression still runs — matching the observed
|
|
6074
6638
|
// wedge). onInferenceEnded is idempotent, so a redundant call is safe.
|
|
6075
|
-
|
|
6076
|
-
|
|
6639
|
+
if (ownsPhysicalStream && !preserveEventGateForSuccessor) {
|
|
6640
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
6641
|
+
}
|
|
6642
|
+
if (!generationLost && ownsPhysicalStream) {
|
|
6643
|
+
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
|
|
6644
|
+
}
|
|
6077
6645
|
if (ownsProviderGate)
|
|
6078
6646
|
this.releasePrimaryProviderGate(agent.name);
|
|
6079
6647
|
// Stop the typing indicator on every exit path (complete, error,
|
|
6080
6648
|
// exhausted, abort) so it never sticks after the turn ends.
|
|
6081
|
-
|
|
6649
|
+
if (frameReachedTerminal)
|
|
6650
|
+
this.channelRegistry?.stopTyping();
|
|
6082
6651
|
// Spec 14.3: flush any held line-start text, then close each streamed
|
|
6083
6652
|
// channel with its final moderated content — the consumer's signal to
|
|
6084
6653
|
// finalize (end the TTS utterance, settle the rendered message).
|
|
6085
|
-
if (proseStream) {
|
|
6654
|
+
if (frameReachedTerminal && proseStream) {
|
|
6086
6655
|
emitOutgoing(proseStream.finish());
|
|
6087
6656
|
for (const [channelId, text] of proseStream.byChannel()) {
|
|
6088
6657
|
this.channelRegistry.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text);
|
|
6089
6658
|
}
|
|
6090
6659
|
}
|
|
6091
6660
|
this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
|
|
6092
|
-
|
|
6093
|
-
|
|
6661
|
+
if (ownsPhysicalStream) {
|
|
6662
|
+
this.activeStreams.delete(agent.name);
|
|
6663
|
+
this.pendingAssistantBlocks.delete(agent.name);
|
|
6664
|
+
}
|
|
6094
6665
|
// A conversation fork whose TTL closure turn just finished is done for
|
|
6095
6666
|
// good — dispose it so the agent map doesn't grow monotonically.
|
|
6096
|
-
if (this.closingConversationAgents.has(agent.name)) {
|
|
6667
|
+
if (ownsPhysicalStream && this.closingConversationAgents.has(agent.name)) {
|
|
6097
6668
|
this.disposeConversationAgent(agent.name);
|
|
6098
6669
|
}
|
|
6099
6670
|
// The turn is torn down — release the turn-alive marker BEFORE the
|
|
@@ -6105,12 +6676,16 @@ export class AgentFramework {
|
|
|
6105
6676
|
// injection / teardown delivers the messages at a correct position.
|
|
6106
6677
|
if (myTurnToken !== undefined && this.activeTurnTokens.get(agent.name) === myTurnToken) {
|
|
6107
6678
|
this.activeTurnTokens.delete(agent.name);
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6679
|
+
this.activeTurnTriggers.delete(agent.name);
|
|
6680
|
+
}
|
|
6681
|
+
// Flush any deferred messages (e.g. if stream failed while tools were
|
|
6682
|
+
// pending). Only THIS agent's messages: other targets' entries wait
|
|
6683
|
+
// for their own boundaries (re-adding via addMessage re-defers if the
|
|
6684
|
+
// target has meanwhile started a turn).
|
|
6685
|
+
if (frameReachedTerminal && this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
|
|
6686
|
+
const deferred = this.drainDeferredFor(agent.name);
|
|
6112
6687
|
for (const msg of deferred) {
|
|
6113
|
-
this.addMessage(msg.participant, msg.content, msg.metadata);
|
|
6688
|
+
this.addMessage(msg.participant, msg.content, msg.metadata, msg.forAgent ? { forAgent: msg.forAgent } : undefined);
|
|
6114
6689
|
}
|
|
6115
6690
|
}
|
|
6116
6691
|
}
|
|
@@ -6760,6 +7335,23 @@ export class AgentFramework {
|
|
|
6760
7335
|
}
|
|
6761
7336
|
if (result.data === undefined)
|
|
6762
7337
|
return '';
|
|
7338
|
+
// RFC-005 "eagerly lazy": a script receiving a result is the strongest
|
|
7339
|
+
// available signal that the bytes are wanted (scripts process, they do
|
|
7340
|
+
// not browse) — materialize every referenced payload now, so the stub
|
|
7341
|
+
// the script receives carries a workspace path it can open directly.
|
|
7342
|
+
if (Array.isArray(result.data)) {
|
|
7343
|
+
const fetcher = this.getReferenceFetcher();
|
|
7344
|
+
for (const block of result.data) {
|
|
7345
|
+
const c = classifyBlock(block);
|
|
7346
|
+
if (c.kind !== 'reference')
|
|
7347
|
+
continue;
|
|
7348
|
+
const record = referenceRegistry.findByUri(c.testimony.uri)
|
|
7349
|
+
?? referenceRegistry.register(c.testimony);
|
|
7350
|
+
const outcome = await fetcher.fetch(record);
|
|
7351
|
+
if (!outcome.ok)
|
|
7352
|
+
console.error(`[rfc005] script-demand fetch of ${record.refId} failed: ${outcome.error}`);
|
|
7353
|
+
}
|
|
7354
|
+
}
|
|
6763
7355
|
// Scripts get (nearly) full data — the script environment IS the spill:
|
|
6764
7356
|
// filtering big results in code is the whole point. 5MB protocol safety
|
|
6765
7357
|
// cap only; no context-cap truncation, no file spill.
|
|
@@ -6864,6 +7456,11 @@ export class AgentFramework {
|
|
|
6864
7456
|
if (!raw || typeof raw !== 'object')
|
|
6865
7457
|
return null;
|
|
6866
7458
|
const b = raw;
|
|
7459
|
+
if (isInlineContradiction(b)) {
|
|
7460
|
+
// RFC-005 vector 2: withhold inline data claiming bulk disposition.
|
|
7461
|
+
blocks.push({ type: 'text', text: INLINE_WITHHELD_TEXT });
|
|
7462
|
+
continue;
|
|
7463
|
+
}
|
|
6867
7464
|
if (b.type === 'text' && typeof b.text === 'string') {
|
|
6868
7465
|
let text = b.text;
|
|
6869
7466
|
if (maxChars && text.length > maxChars) {
|
|
@@ -6881,6 +7478,15 @@ export class AgentFramework {
|
|
|
6881
7478
|
source: { type: 'base64', data: b.data, mediaType: b.mimeType },
|
|
6882
7479
|
});
|
|
6883
7480
|
}
|
|
7481
|
+
else if (b.type === 'resource' || ((b.type === 'image' || b.type === 'audio') && typeof b.uri === 'string')) {
|
|
7482
|
+
// RFC-005 reference block → bounded stub. Previously this bailed the
|
|
7483
|
+
// whole array to JSON, which both inlined the reference verbatim and
|
|
7484
|
+
// demoted any adjacent image block to stringified base64.
|
|
7485
|
+
const stub = referenceStubOrNull(b, 'from tool result');
|
|
7486
|
+
if (stub === null)
|
|
7487
|
+
return null;
|
|
7488
|
+
blocks.push({ type: 'text', text: stub });
|
|
7489
|
+
}
|
|
6884
7490
|
else {
|
|
6885
7491
|
return null; // unknown shape — bail to JSON path
|
|
6886
7492
|
}
|
|
@@ -7048,11 +7654,36 @@ export class AgentFramework {
|
|
|
7048
7654
|
this.dispatchReadImageToolCall(agentName, enrichedCall);
|
|
7049
7655
|
return;
|
|
7050
7656
|
}
|
|
7657
|
+
if (enrichedCall.name === 'fetch_reference') {
|
|
7658
|
+
this.dispatchFetchReferenceToolCall(agentName, enrichedCall);
|
|
7659
|
+
return;
|
|
7660
|
+
}
|
|
7051
7661
|
// Route gate_status tool
|
|
7052
7662
|
if (enrichedCall.name === 'gate_status' && this.eventGate) {
|
|
7053
7663
|
this.dispatchGateToolCall(agentName, enrichedCall);
|
|
7054
7664
|
return;
|
|
7055
7665
|
}
|
|
7666
|
+
// Route the tune-out tool (residents) and the subconscious's surface
|
|
7667
|
+
if (enrichedCall.name === 'tune_out' && this.tuneOutCoordinator) {
|
|
7668
|
+
this.dispatchTuneOutToolCall(agentName, enrichedCall);
|
|
7669
|
+
return;
|
|
7670
|
+
}
|
|
7671
|
+
if (this.tuneOutCoordinator &&
|
|
7672
|
+
agentName === this.subconsciousAgentName &&
|
|
7673
|
+
SUBCONSCIOUS_TOOL_NAMES.includes(enrichedCall.name)) {
|
|
7674
|
+
void this.tuneOutCoordinator
|
|
7675
|
+
.handleSubconsciousTool(enrichedCall.name, enrichedCall.input)
|
|
7676
|
+
.then((result) => {
|
|
7677
|
+
this.queue.push({
|
|
7678
|
+
type: 'tool-result',
|
|
7679
|
+
callId: enrichedCall.id,
|
|
7680
|
+
agentName,
|
|
7681
|
+
moduleName: 'tune-out',
|
|
7682
|
+
result,
|
|
7683
|
+
});
|
|
7684
|
+
});
|
|
7685
|
+
return;
|
|
7686
|
+
}
|
|
7056
7687
|
// Route sleep / wake tools
|
|
7057
7688
|
if ((enrichedCall.name === 'sleep' || enrichedCall.name === 'wake') && this.eventGate) {
|
|
7058
7689
|
this.dispatchSleepToolCall(agentName, enrichedCall);
|
|
@@ -7181,12 +7812,19 @@ export class AgentFramework {
|
|
|
7181
7812
|
});
|
|
7182
7813
|
});
|
|
7183
7814
|
}
|
|
7184
|
-
addMessage(participant, content, metadata) {
|
|
7185
|
-
// Route to the
|
|
7186
|
-
const agent =
|
|
7187
|
-
? this.agents.get(
|
|
7188
|
-
: this.
|
|
7815
|
+
addMessage(participant, content, metadata, opts) {
|
|
7816
|
+
// Route to the named agent, else the primary (not ephemeral subagents).
|
|
7817
|
+
const agent = opts?.forAgent
|
|
7818
|
+
? this.agents.get(opts.forAgent)
|
|
7819
|
+
: this.primaryAgentName
|
|
7820
|
+
? this.agents.get(this.primaryAgentName)
|
|
7821
|
+
: this.agents.values().next().value;
|
|
7189
7822
|
if (!agent) {
|
|
7823
|
+
if (opts?.forAgent) {
|
|
7824
|
+
console.error(`[addMessage] delivery target "${opts.forAgent}" is not a registered agent — message dropped ` +
|
|
7825
|
+
`(participant=${participant})`);
|
|
7826
|
+
return '';
|
|
7827
|
+
}
|
|
7190
7828
|
throw new Error('No agents configured');
|
|
7191
7829
|
}
|
|
7192
7830
|
// Defer non-tool_result messages while a tool cycle is mid-flight
|
|
@@ -7206,15 +7844,39 @@ export class AgentFramework {
|
|
|
7206
7844
|
// ALSO injected into the live stream — hear-while-acting), or in
|
|
7207
7845
|
// driveStream's finally when the turn ends.
|
|
7208
7846
|
const hasToolResult = content.some(b => b.type === 'tool_result');
|
|
7847
|
+
// Explicitly-targeted deliveries scope the tool-cycle check to the
|
|
7848
|
+
// target (pendingAssistantBlocks is keyed by agent); the default path
|
|
7849
|
+
// keeps the historical GLOBAL check byte-for-byte (conservative: any
|
|
7850
|
+
// agent's pending cycle defers primary-bound messages).
|
|
7851
|
+
const midToolCycle = opts?.forAgent
|
|
7852
|
+
? this.pendingAssistantBlocks.has(agent.name)
|
|
7853
|
+
: this.pendingAssistantBlocks.size > 0;
|
|
7209
7854
|
if (!hasToolResult &&
|
|
7210
|
-
(
|
|
7855
|
+
(midToolCycle ||
|
|
7211
7856
|
this.activeTurnTokens.has(agent.name) ||
|
|
7212
7857
|
this.activeStreams.has(agent.name))) {
|
|
7213
|
-
this.deferredMessages.push({ participant, content, metadata });
|
|
7214
|
-
return ''; // Deferred — flushed at the next boundary
|
|
7858
|
+
this.deferredMessages.push({ participant, content, metadata, forAgent: opts?.forAgent });
|
|
7859
|
+
return ''; // Deferred — flushed at the target's next boundary
|
|
7215
7860
|
}
|
|
7216
7861
|
return agent.getContextManager().addMessage(participant, content, metadata);
|
|
7217
7862
|
}
|
|
7863
|
+
/**
|
|
7864
|
+
* Splice out the deferred messages addressed to `agentName` (explicitly
|
|
7865
|
+
* via forAgent, or implicitly when it is the primary), leaving other
|
|
7866
|
+
* targets' messages queued for their own boundaries.
|
|
7867
|
+
*/
|
|
7868
|
+
drainDeferredFor(agentName) {
|
|
7869
|
+
if (this.deferredMessages.length === 0)
|
|
7870
|
+
return [];
|
|
7871
|
+
const mine = [];
|
|
7872
|
+
const rest = [];
|
|
7873
|
+
for (const msg of this.deferredMessages) {
|
|
7874
|
+
const target = msg.forAgent ?? this.primaryAgentName;
|
|
7875
|
+
(target === agentName ? mine : rest).push(msg);
|
|
7876
|
+
}
|
|
7877
|
+
this.deferredMessages = rest;
|
|
7878
|
+
return mine;
|
|
7879
|
+
}
|
|
7218
7880
|
editMessage(id, content) {
|
|
7219
7881
|
const agent = this.primaryAgentName
|
|
7220
7882
|
? this.agents.get(this.primaryAgentName)
|
|
@@ -8856,6 +9518,13 @@ export class AgentFramework {
|
|
|
8856
9518
|
}
|
|
8857
9519
|
}
|
|
8858
9520
|
server.sendToolsCall(toolName, args, stateParams)
|
|
9521
|
+
.then(async (result) => {
|
|
9522
|
+
// RFC-005: register references and eagerly fetch the small ones
|
|
9523
|
+
// before serialization, so their stubs carry a saved path.
|
|
9524
|
+
await this.autoFetchReferences(result.content, serverId, true)
|
|
9525
|
+
.catch((e) => console.error('[rfc005] autofetch error:', e.message));
|
|
9526
|
+
return result;
|
|
9527
|
+
})
|
|
8859
9528
|
.then((result) => {
|
|
8860
9529
|
const durationMs = Date.now() - startTime;
|
|
8861
9530
|
this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, durationMs });
|
|
@@ -9191,6 +9860,50 @@ export class AgentFramework {
|
|
|
9191
9860
|
* suppression window, optionally announces in the sticky channel, and ends
|
|
9192
9861
|
* the turn (the agent goes idle immediately). `wake` clears sleep.
|
|
9193
9862
|
*/
|
|
9863
|
+
/** tune_out tool (residents): enter or cancel a channel tune-out. */
|
|
9864
|
+
dispatchTuneOutToolCall(agentName, call) {
|
|
9865
|
+
const coordinator = this.tuneOutCoordinator;
|
|
9866
|
+
const input = (call.input ?? {});
|
|
9867
|
+
const channelId = String(input.channelId ?? '');
|
|
9868
|
+
const entry = this.channelRegistry?.listChannelsRaw()
|
|
9869
|
+
.find((e) => e.descriptor.id === channelId);
|
|
9870
|
+
let result;
|
|
9871
|
+
if (!entry) {
|
|
9872
|
+
result = { success: false, error: `unknown channel: ${channelId}`, isError: false };
|
|
9873
|
+
}
|
|
9874
|
+
else if (input.mode === 'cancel') {
|
|
9875
|
+
const r = coordinator.cancel(entry.serverId, channelId, 'agent-tool', 'agent request');
|
|
9876
|
+
result = r.ok
|
|
9877
|
+
? { success: true, data: { cancelled: channelId } }
|
|
9878
|
+
: { success: false, error: r.error, isError: false };
|
|
9879
|
+
}
|
|
9880
|
+
else {
|
|
9881
|
+
const r = coordinator.enter(entry.serverId, channelId, {
|
|
9882
|
+
cadenceSeconds: input.cadenceSeconds,
|
|
9883
|
+
backlogCap: input.backlogCap,
|
|
9884
|
+
maxWakes: input.maxWakes,
|
|
9885
|
+
durationSeconds: input.durationSeconds,
|
|
9886
|
+
}, 'agent-tool');
|
|
9887
|
+
result = r.ok
|
|
9888
|
+
? {
|
|
9889
|
+
success: true,
|
|
9890
|
+
data: {
|
|
9891
|
+
tunedOut: channelId,
|
|
9892
|
+
cadenceSeconds: r.params.cadenceSeconds,
|
|
9893
|
+
backlogCap: r.params.backlogCap,
|
|
9894
|
+
maxWakes: r.params.maxWakes,
|
|
9895
|
+
},
|
|
9896
|
+
}
|
|
9897
|
+
: { success: false, error: r.error, isError: false };
|
|
9898
|
+
}
|
|
9899
|
+
this.queue.push({
|
|
9900
|
+
type: 'tool-result',
|
|
9901
|
+
callId: call.id,
|
|
9902
|
+
agentName,
|
|
9903
|
+
moduleName: 'tune-out',
|
|
9904
|
+
result,
|
|
9905
|
+
});
|
|
9906
|
+
}
|
|
9194
9907
|
dispatchSleepToolCall(agentName, call) {
|
|
9195
9908
|
this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
|
|
9196
9909
|
const gate = this.eventGate;
|
|
@@ -9264,6 +9977,43 @@ export class AgentFramework {
|
|
|
9264
9977
|
* blocks) delivers the actual image to the model; history storage keeps
|
|
9265
9978
|
* the standard compact `[image: …]` placeholder.
|
|
9266
9979
|
*/
|
|
9980
|
+
/** RFC-005 on-demand dereference: [ref id] → workspace file, via the
|
|
9981
|
+
* origin-bound, ceiling-bounded, digest-verified host fetcher. */
|
|
9982
|
+
dispatchFetchReferenceToolCall(agentName, call) {
|
|
9983
|
+
this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
|
|
9984
|
+
const startTime = Date.now();
|
|
9985
|
+
void (async () => {
|
|
9986
|
+
const args = (call.input ?? {});
|
|
9987
|
+
const refId = String(args.ref_id ?? '');
|
|
9988
|
+
const record = referenceRegistry.get(refId);
|
|
9989
|
+
let result;
|
|
9990
|
+
if (!record) {
|
|
9991
|
+
// Vector 20: a stale or unknown id is a defined miss, never a
|
|
9992
|
+
// different record.
|
|
9993
|
+
result = { success: false, isError: true,
|
|
9994
|
+
error: `unknown reference: ${refId || '(missing ref_id)'} — it may have been evicted; ids are never reused` };
|
|
9995
|
+
}
|
|
9996
|
+
else {
|
|
9997
|
+
const maxBytes = typeof args.max_bytes === 'number'
|
|
9998
|
+
? Math.min(args.max_bytes, DEFAULT_FETCH_MAX_BYTES) : undefined;
|
|
9999
|
+
const outcome = await this.getReferenceFetcher().fetch(record, { maxBytes });
|
|
10000
|
+
result = outcome.ok
|
|
10001
|
+
? { success: true, data: JSON.stringify({ path: outcome.path, bytes: outcome.bytes,
|
|
10002
|
+
mime_type: outcome.mimeType, digest_verified: outcome.digestVerified ?? 'no digest claimed' }) }
|
|
10003
|
+
: { success: false, isError: true, error: outcome.error };
|
|
10004
|
+
}
|
|
10005
|
+
this.emitTrace({
|
|
10006
|
+
type: result.isError ? 'tool:failed' : 'tool:completed',
|
|
10007
|
+
module: 'workspace', tool: call.name, callId: call.id,
|
|
10008
|
+
durationMs: Date.now() - startTime,
|
|
10009
|
+
...(result.isError ? { error: result.error } : {}),
|
|
10010
|
+
});
|
|
10011
|
+
this.pushEvent({
|
|
10012
|
+
type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace',
|
|
10013
|
+
result: { ...result, isError: result.isError ?? false },
|
|
10014
|
+
});
|
|
10015
|
+
})();
|
|
10016
|
+
}
|
|
9267
10017
|
dispatchReadImageToolCall(agentName, call) {
|
|
9268
10018
|
this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
|
|
9269
10019
|
const finish = (result) => {
|