@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/src/framework.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
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 type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane';
|
|
5
7
|
import { MembraneError } from '@animalabs/membrane';
|
|
6
|
-
import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager';
|
|
8
|
+
import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy } from '@animalabs/context-manager';
|
|
9
|
+
import type { CacheWireReceipt } from './kv-unified-wire.js';
|
|
10
|
+
import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES, type SubconsciousConfig } from './tune-out/tools.js';
|
|
11
|
+
import { TuneOutCoordinator, TUNE_OUT_DEFAULTS } from './tune-out/coordinator.js';
|
|
7
12
|
import type {
|
|
8
13
|
MessageId,
|
|
9
14
|
MessageMetadata,
|
|
@@ -64,6 +69,7 @@ import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
|
|
|
64
69
|
import { PushHandler, type McplPushEvent } from './mcpl/push-handler.js';
|
|
65
70
|
import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
|
|
66
71
|
import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
|
|
72
|
+
import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js';
|
|
67
73
|
import { InferenceRouter } from './mcpl/inference-router.js';
|
|
68
74
|
import { ChannelRegistry, type ChannelToolOrigin } from './mcpl/channel-registry.js';
|
|
69
75
|
import { ConversationRouter } from './mcpl/conversation-router.js';
|
|
@@ -81,6 +87,7 @@ import {
|
|
|
81
87
|
CODE_EXECUTION_TOOL_NAME,
|
|
82
88
|
} from './code-execution/tool-definition.js';
|
|
83
89
|
import { splitProseSegments } from './prose-segments.js';
|
|
90
|
+
import { cumulativeDelta } from './usage-accounting.js';
|
|
84
91
|
|
|
85
92
|
/** Detect a supported image media type from magic bytes (the model API
|
|
86
93
|
* rejects mislabeled media types, so trust bytes over extensions).
|
|
@@ -126,8 +133,24 @@ const SILENCING_TOOLS = new Set([
|
|
|
126
133
|
'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
|
|
127
134
|
]);
|
|
128
135
|
|
|
129
|
-
/**
|
|
130
|
-
|
|
136
|
+
/**
|
|
137
|
+
* World-surface publication tools (Eidoverse `say` / `whisper`). An explicit
|
|
138
|
+
* world utterance is the resident's chosen public speech for that round, so
|
|
139
|
+
* it silences adjacent auto-routed prose exactly like a channel send — in
|
|
140
|
+
* EVERY prose-routing mode. Until 2026-09-01 this set was consulted only in
|
|
141
|
+
* hybrid mode; in locus mode a round of ordinary text + `say` published twice
|
|
142
|
+
* (Cairn world seq 15146 = the say text, 15147 = the adjacent prose,
|
|
143
|
+
* byte-for-byte). Hybrid additionally lets these outrank a same-round
|
|
144
|
+
* `>>>destination` envelope.
|
|
145
|
+
*/
|
|
146
|
+
const WORLD_PUBLICATION_TOOLS = new Set(['say', 'whisper']);
|
|
147
|
+
|
|
148
|
+
/** True when a tool call (possibly MCPL-prefixed) is an explicit delivery
|
|
149
|
+
* that silences the round's auto-routed prose, regardless of mode. */
|
|
150
|
+
const isSilencingTool = (name: string): boolean => {
|
|
151
|
+
const bare = bareToolName(name);
|
|
152
|
+
return SILENCING_TOOLS.has(bare) || WORLD_PUBLICATION_TOOLS.has(bare);
|
|
153
|
+
};
|
|
131
154
|
|
|
132
155
|
/**
|
|
133
156
|
* True when an injected message is real conversational input — something the
|
|
@@ -768,6 +791,16 @@ export class AgentFramework {
|
|
|
768
791
|
private frameworkCancelledStreams: Map<string, 'turn_ended' | 'budget_restart'> = new Map();
|
|
769
792
|
/** Active runEphemeralToCompletion runs, keyed by agent name. */
|
|
770
793
|
private ephemeralRuns: Map<string, EphemeralRun> = new Map();
|
|
794
|
+
/** Ephemeral namespaces/names are single-generation for this framework
|
|
795
|
+
* lifetime. Reusing one while an old stream drains would let name-keyed
|
|
796
|
+
* liveness/settlement state cross generations. */
|
|
797
|
+
private usedEphemeralAgentNames: Set<string> = new Set();
|
|
798
|
+
/** One-shot generation tickets minted by createEphemeralAgent. */
|
|
799
|
+
private ephemeralCandidates: WeakMap<Agent, ContextManager> = new WeakMap();
|
|
800
|
+
/** Physical ephemeral frames deliberately disposed by their run watchdog/caller.
|
|
801
|
+
* The name is single-generation, so these frames still own their terminal
|
|
802
|
+
* typing/outgoing close even after runEphemeralToCompletion deregisters them. */
|
|
803
|
+
private disposedEphemeralFrames: WeakSet<Agent> = new WeakSet();
|
|
771
804
|
/** Per-agent count of consecutive exhausted inferences (reset on any success).
|
|
772
805
|
* Drives hard-down escalation — see noteInferenceExhausted. */
|
|
773
806
|
private consecutiveInferenceFailures: Map<string, number> = new Map();
|
|
@@ -821,7 +854,23 @@ export class AgentFramework {
|
|
|
821
854
|
|
|
822
855
|
// Messages deferred while an agent is waiting_for_tools (to preserve
|
|
823
856
|
// tool_use → tool_result adjacency required by the Anthropic API).
|
|
824
|
-
|
|
857
|
+
/** The subconscious resident's registry name (issue #77), or null. */
|
|
858
|
+
private subconsciousAgentName: string | null = null;
|
|
859
|
+
/** Its windowed strategy — the coordinator moves the anchor on tune-out entry. */
|
|
860
|
+
private subconsciousStrategy: WindowedPassthroughStrategy | null = null;
|
|
861
|
+
/** Its config (speak_in_channel gate, voice block provenance). */
|
|
862
|
+
private subconsciousConfig: SubconsciousConfig | null = null;
|
|
863
|
+
/** Tune-out coordinator (issue #77); non-null iff subconscious + channels. */
|
|
864
|
+
private tuneOutCoordinator: TuneOutCoordinator | null = null;
|
|
865
|
+
|
|
866
|
+
private deferredMessages: Array<{
|
|
867
|
+
participant: string;
|
|
868
|
+
content: ContentBlock[];
|
|
869
|
+
metadata?: MessageMetadata;
|
|
870
|
+
/** Delivery target (registry name). Absent = the primary agent —
|
|
871
|
+
* the historical behavior of every existing caller. */
|
|
872
|
+
forAgent?: string;
|
|
873
|
+
}> = [];
|
|
825
874
|
|
|
826
875
|
// Turn-alive markers (2026-07-31 Mythos phantom-skip incident): agentName →
|
|
827
876
|
// token of the turn currently in progress. Set at startAgentStream ENTRY —
|
|
@@ -840,6 +889,23 @@ export class AgentFramework {
|
|
|
840
889
|
// (KV bust). Invariant: nothing enters the window between a turn's dequeue
|
|
841
890
|
// and its settle except that turn's own blocks.
|
|
842
891
|
private activeTurnTokens: Map<string, number> = new Map();
|
|
892
|
+
/**
|
|
893
|
+
* The InferenceRequest that started each agent's turn in progress — set and
|
|
894
|
+
* cleared exactly where the turn token is. Read-only outside: the host
|
|
895
|
+
* stamps gateway telemetry (why this call exists, in which channel, woken
|
|
896
|
+
* by whom) from it, so a household ledger can attribute wakes without
|
|
897
|
+
* guessing from the newest message in the window.
|
|
898
|
+
*/
|
|
899
|
+
private activeTurnTriggers: Map<string, InferenceRequest | undefined> = new Map();
|
|
900
|
+
|
|
901
|
+
/** The trigger of `agentName`'s turn in progress, if a turn is running. */
|
|
902
|
+
getActiveTurnTrigger(agentName: string): InferenceRequest | undefined {
|
|
903
|
+
return this.activeTurnTriggers.get(agentName);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** Structured calls executed in the current logical turn. Unlike the
|
|
907
|
+
* per-stream local this survives framework retries and budget restarts. */
|
|
908
|
+
private logicalTurnToolCalls: WeakMap<Agent, { turnToken: number; count: number }> = new WeakMap();
|
|
843
909
|
private nextTurnToken = 1;
|
|
844
910
|
|
|
845
911
|
// Undo/redo state
|
|
@@ -1098,6 +1164,13 @@ export class AgentFramework {
|
|
|
1098
1164
|
await framework.createAgent(agentConfig);
|
|
1099
1165
|
}
|
|
1100
1166
|
|
|
1167
|
+
// The subconscious resident (issue #77) registers after the residents so
|
|
1168
|
+
// it can never become primary; it is excluded from every broadcast
|
|
1169
|
+
// fan-out and woken only by the tune-out coordinator's explicit pushes.
|
|
1170
|
+
if (config.subconscious?.enabled) {
|
|
1171
|
+
await framework.createSubconsciousAgent(config.subconscious);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1101
1174
|
// Finish any branch-local suppression interrupted after Chronicle switched
|
|
1102
1175
|
// branches. This runs before modules, MCPL connections, or inbound traffic.
|
|
1103
1176
|
await framework.resumePreparedDiscordSuppressions();
|
|
@@ -1147,11 +1220,12 @@ export class AgentFramework {
|
|
|
1147
1220
|
initialConfig: config.gate.config,
|
|
1148
1221
|
privilegedUsersPath: config.gate.privilegedUsersPath,
|
|
1149
1222
|
emitTrace: (e) => framework.emitTrace(e as { type: TraceEvent['type']; [key: string]: unknown }),
|
|
1150
|
-
addMessage: (p, c, m) => framework.addMessage(p, c, m as MessageMetadata),
|
|
1223
|
+
addMessage: (p, c, m, forAgent) => framework.addMessage(p, c, m as MessageMetadata, forAgent ? { forAgent } : undefined),
|
|
1151
1224
|
requestInference: (agentName, reason, source) => {
|
|
1152
1225
|
framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
|
|
1153
1226
|
},
|
|
1154
|
-
getAgentNames: () => [...framework.agents.keys()]
|
|
1227
|
+
getAgentNames: () => [...framework.agents.keys()].filter(
|
|
1228
|
+
(n) => n !== framework.subconsciousAgentName),
|
|
1155
1229
|
});
|
|
1156
1230
|
}
|
|
1157
1231
|
|
|
@@ -1214,6 +1288,40 @@ export class AgentFramework {
|
|
|
1214
1288
|
await framework.initializeMcpl(config.mcplServers, config.inferenceRouting);
|
|
1215
1289
|
}
|
|
1216
1290
|
|
|
1291
|
+
// Tune-out coordinator (issue #77): needs both the subconscious resident
|
|
1292
|
+
// and the MCPL channel subsystem; created after both exist.
|
|
1293
|
+
if (framework.subconsciousAgentName && framework.channelRegistry) {
|
|
1294
|
+
framework.tuneOutCoordinator = new TuneOutCoordinator(
|
|
1295
|
+
framework.channelRegistry,
|
|
1296
|
+
framework.mcplServerRegistry!,
|
|
1297
|
+
{
|
|
1298
|
+
addMessage: (participant, content, metadata, forAgent) =>
|
|
1299
|
+
framework.addMessage(participant, content, metadata as MessageMetadata,
|
|
1300
|
+
forAgent ? { forAgent } : undefined),
|
|
1301
|
+
requestInference: (agentName, reason, source) => {
|
|
1302
|
+
framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
|
|
1303
|
+
},
|
|
1304
|
+
subconsciousName: () => framework.subconsciousAgentName,
|
|
1305
|
+
primaryName: () => framework.primaryAgentName,
|
|
1306
|
+
getStoredMessages: () => {
|
|
1307
|
+
const primary = framework.primaryAgentName
|
|
1308
|
+
? framework.agents.get(framework.primaryAgentName) : undefined;
|
|
1309
|
+
return primary ? primary.getContextManager().getAllMessages() : [];
|
|
1310
|
+
},
|
|
1311
|
+
currentSequence: () => framework.store.currentSequence(),
|
|
1312
|
+
setSubconsciousAnchor: (sequence) =>
|
|
1313
|
+
framework.subconsciousStrategy?.setAnchor(sequence),
|
|
1314
|
+
isForkBound: (channelId) =>
|
|
1315
|
+
[...framework.conversationAgentHomes.values()].includes(channelId),
|
|
1316
|
+
isPrivilegedAuthor: (authorId) =>
|
|
1317
|
+
framework.eventGate?.isPrivilegedUser(authorId) ?? false,
|
|
1318
|
+
allowChannelSpeech: () => !!framework.subconsciousConfig?.allowChannelSpeech,
|
|
1319
|
+
emitTrace: (e) => framework.emitTrace(e as never),
|
|
1320
|
+
},
|
|
1321
|
+
);
|
|
1322
|
+
framework.tuneOutCoordinator.resumeActiveEpochs();
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1217
1325
|
// Diagnostics: `kill -USR2 <pid>` dumps live wake/inference state to stderr
|
|
1218
1326
|
// (journal) without a restart — for catching the wake-wedge on the running
|
|
1219
1327
|
// process. Shows the gate's `inferring` set + buffered-event count (the
|
|
@@ -1283,6 +1391,7 @@ export class AgentFramework {
|
|
|
1283
1391
|
this.running = false;
|
|
1284
1392
|
this.providerAdmissionClosed = true;
|
|
1285
1393
|
this.queue.close();
|
|
1394
|
+
this.tuneOutCoordinator?.stop();
|
|
1286
1395
|
|
|
1287
1396
|
// Kill running code_execution scripts before cancelling streams: a
|
|
1288
1397
|
// zombie script must not keep firing side-effectful tool calls into a
|
|
@@ -1718,7 +1827,12 @@ export class AgentFramework {
|
|
|
1718
1827
|
*/
|
|
1719
1828
|
getAllTools(): import('./types/index.js').ToolDefinition[] {
|
|
1720
1829
|
const moduleTools = this.moduleRegistry.getAllTools();
|
|
1721
|
-
|
|
1830
|
+
// Copy: getChannelTools() returns the registry's shared definitions
|
|
1831
|
+
// array; pushing onto it would append another tune_out on every call.
|
|
1832
|
+
const channelTools = [...(this.channelRegistry?.getChannelTools() ?? [])];
|
|
1833
|
+
if (this.tuneOutCoordinator) {
|
|
1834
|
+
channelTools.push(AgentFramework.TUNE_OUT_TOOL);
|
|
1835
|
+
}
|
|
1722
1836
|
const gateTools = this.eventGate
|
|
1723
1837
|
? [
|
|
1724
1838
|
this.eventGate.getToolDefinition(),
|
|
@@ -1736,7 +1850,8 @@ export class AgentFramework {
|
|
|
1736
1850
|
...gateTools,
|
|
1737
1851
|
this.buildAgentSettingsTool(),
|
|
1738
1852
|
...(this.getWorkspaceModule()
|
|
1739
|
-
? [AgentFramework.SAVE_IMAGE_TOOL, AgentFramework.READ_IMAGE_TOOL
|
|
1853
|
+
? [AgentFramework.SAVE_IMAGE_TOOL, AgentFramework.READ_IMAGE_TOOL,
|
|
1854
|
+
AgentFramework.FETCH_REFERENCE_TOOL]
|
|
1740
1855
|
: []),
|
|
1741
1856
|
...(this.codeExecutionConfig
|
|
1742
1857
|
? [buildCodeExecutionToolDefinition(this.codeExecutionConfig)]
|
|
@@ -1752,6 +1867,55 @@ export class AgentFramework {
|
|
|
1752
1867
|
: undefined;
|
|
1753
1868
|
}
|
|
1754
1869
|
|
|
1870
|
+
/** RFC-005 host-mediated reference fetcher (lazy; needs server configs +
|
|
1871
|
+
* a writable workspace mount for storage). */
|
|
1872
|
+
private referenceFetcherInstance?: ReferenceFetcher;
|
|
1873
|
+
private getReferenceFetcher(): ReferenceFetcher {
|
|
1874
|
+
if (!this.referenceFetcherInstance) {
|
|
1875
|
+
this.referenceFetcherInstance = new ReferenceFetcher(
|
|
1876
|
+
(serverId) => {
|
|
1877
|
+
const cfg = this.mcplServerConfigs.get(serverId);
|
|
1878
|
+
if (!cfg?.url) return undefined;
|
|
1879
|
+
return { url: cfg.url, token: cfg.token, autofetch: cfg.autofetch };
|
|
1880
|
+
},
|
|
1881
|
+
async (fileName, data, mimeType) => {
|
|
1882
|
+
const workspace = this.getWorkspaceModule();
|
|
1883
|
+
if (!workspace) return null;
|
|
1884
|
+
const mount = this.firstWritableMountName(workspace);
|
|
1885
|
+
if (!mount) return null;
|
|
1886
|
+
const path = `${mount}/refs/${fileName}`;
|
|
1887
|
+
const res = await workspace.writeBinary(path, data, mimeType);
|
|
1888
|
+
return res.success ? path : null;
|
|
1889
|
+
},
|
|
1890
|
+
);
|
|
1891
|
+
}
|
|
1892
|
+
return this.referenceFetcherInstance;
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
/** Register every RFC-005 reference in a tool-result content array against
|
|
1896
|
+
* its server, then eagerly fetch the ones inside the autofetch envelope
|
|
1897
|
+
* (below the eager ceiling, connection origin — never worse than the
|
|
1898
|
+
* server having inlined the bytes). Failures are non-fatal: the stub then
|
|
1899
|
+
* says fetch_reference instead of a path. */
|
|
1900
|
+
private async autoFetchReferences(content: unknown[] | undefined, serverId: string, eager: boolean): Promise<void> {
|
|
1901
|
+
if (!content) return;
|
|
1902
|
+
const fetcher = this.getReferenceFetcher();
|
|
1903
|
+
const eligible: import('./mcpl/references.js').ReferenceRecord[] = [];
|
|
1904
|
+
for (const block of content) {
|
|
1905
|
+
const c = classifyBlock(block);
|
|
1906
|
+
if (c.kind !== 'reference') continue;
|
|
1907
|
+
const record = referenceRegistry.register(c.testimony, serverId);
|
|
1908
|
+
if (eager && fetcher.isEagerEligible(record)) eligible.push(record);
|
|
1909
|
+
}
|
|
1910
|
+
// Parallel with a count cap: N small fetches cost one round-trip of
|
|
1911
|
+
// wall-clock, and anything past the cap stays lazy (stub says so).
|
|
1912
|
+
const MAX_EAGER_PER_RESULT = 8;
|
|
1913
|
+
await Promise.all(eligible.slice(0, MAX_EAGER_PER_RESULT).map(async (record) => {
|
|
1914
|
+
const outcome = await fetcher.fetch(record, { timeoutMs: EAGER_FETCH_TIMEOUT_MS });
|
|
1915
|
+
if (!outcome.ok) console.error(`[rfc005] eager fetch of ${record.refId} failed: ${outcome.error}`);
|
|
1916
|
+
}));
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1755
1919
|
/** Core agent_settings keys — extension keys must not collide with these. */
|
|
1756
1920
|
private static readonly AGENT_SETTINGS_CORE_KEYS = [
|
|
1757
1921
|
'context_budget_tokens',
|
|
@@ -1918,6 +2082,20 @@ export class AgentFramework {
|
|
|
1918
2082
|
agentName: string,
|
|
1919
2083
|
snapshot?: InferenceToolSnapshot,
|
|
1920
2084
|
): import('./types/index.js').ToolDefinition[] {
|
|
2085
|
+
// The subconscious gets its own small surface (plus think/skip_reply/
|
|
2086
|
+
// end_turn), not the residents' tool board: no channel lifecycle, no
|
|
2087
|
+
// gate rules, no workspace — it observes, judges, and reports.
|
|
2088
|
+
if (agentName === this.subconsciousAgentName) {
|
|
2089
|
+
const basics = this.getAllTools()
|
|
2090
|
+
.filter((t) => t.name === 'think' || t.name === 'skip_reply' || t.name === 'end_turn')
|
|
2091
|
+
.map((t) => t.name === 'think'
|
|
2092
|
+
? this.buildThinkTool(
|
|
2093
|
+
snapshot?.sameRoundThinkTextPolicy
|
|
2094
|
+
?? this.getAgentRuntimeSettings(agentName).sameRoundThinkTextPolicy,
|
|
2095
|
+
)
|
|
2096
|
+
: t);
|
|
2097
|
+
return [...SUBCONSCIOUS_TOOLS, ...basics];
|
|
2098
|
+
}
|
|
1921
2099
|
return this.getAllTools().map((tool) => {
|
|
1922
2100
|
if (tool.name === 'think') {
|
|
1923
2101
|
return this.buildThinkTool(
|
|
@@ -2338,25 +2516,38 @@ export class AgentFramework {
|
|
|
2338
2516
|
contextManager: ContextManager;
|
|
2339
2517
|
cleanup: () => void;
|
|
2340
2518
|
}> {
|
|
2341
|
-
|
|
2519
|
+
// Names are Chronicle namespaces and generation identities. Reserve before
|
|
2520
|
+
// opening the namespace so a second creation cannot append to an earlier
|
|
2521
|
+
// generation before runEphemeralToCompletion has a chance to reject it.
|
|
2522
|
+
if (this.usedEphemeralAgentNames.has(config.name) || this.agents.has(config.name)) {
|
|
2523
|
+
throw new Error(`Ephemeral agent name \"${config.name}\" is already registered or has been used in this framework`);
|
|
2524
|
+
}
|
|
2525
|
+
this.usedEphemeralAgentNames.add(config.name);
|
|
2526
|
+
try {
|
|
2527
|
+
const namespace = `subagent/${config.name}`;
|
|
2342
2528
|
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2529
|
+
const contextManager = await ContextManager.open({
|
|
2530
|
+
store: this.store,
|
|
2531
|
+
namespace,
|
|
2532
|
+
isolate: true,
|
|
2533
|
+
strategy: config.strategy ?? new PassthroughStrategy(),
|
|
2534
|
+
membrane: this.membrane,
|
|
2535
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
2536
|
+
});
|
|
2351
2537
|
|
|
2352
|
-
|
|
2538
|
+
const agent = new Agent(config, contextManager, this.membrane);
|
|
2539
|
+
this.ephemeralCandidates.set(agent, contextManager);
|
|
2353
2540
|
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2541
|
+
const cleanup = () => {
|
|
2542
|
+
// Don't close the store — it's shared. Just release the CM.
|
|
2543
|
+
// Data persists in the store under the namespace for investigation.
|
|
2544
|
+
};
|
|
2358
2545
|
|
|
2359
|
-
|
|
2546
|
+
return { agent, contextManager, cleanup };
|
|
2547
|
+
} catch (error) {
|
|
2548
|
+
this.usedEphemeralAgentNames.delete(config.name);
|
|
2549
|
+
throw error;
|
|
2550
|
+
}
|
|
2360
2551
|
}
|
|
2361
2552
|
|
|
2362
2553
|
private createDeferred<T>(): Deferred<T> {
|
|
@@ -2379,6 +2570,17 @@ export class AgentFramework {
|
|
|
2379
2570
|
run.lastActivity = Date.now();
|
|
2380
2571
|
}
|
|
2381
2572
|
|
|
2573
|
+
/** Record structured calls for the current logical turn. Ignore late stream
|
|
2574
|
+
* events after an ephemeral/conversation agent has been disposed. */
|
|
2575
|
+
private recordLogicalTurnToolCalls(agent: Agent, turnToken: number, count: number): void {
|
|
2576
|
+
if (count <= 0 || this.agents.get(agent.name) !== agent) return;
|
|
2577
|
+
const state = this.logicalTurnToolCalls.get(agent);
|
|
2578
|
+
// The Agent object is the generation: a late stream from a disposed
|
|
2579
|
+
// ephemeral/conversation fork cannot contaminate a same-name successor.
|
|
2580
|
+
if (!state || state.turnToken !== turnToken) return;
|
|
2581
|
+
this.logicalTurnToolCalls.set(agent, { turnToken, count: state.count + count });
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2382
2584
|
private recordEphemeralToolCalls(agentName: string, count: number): void {
|
|
2383
2585
|
const run = this.ephemeralRuns.get(agentName);
|
|
2384
2586
|
if (!run) return;
|
|
@@ -2422,7 +2624,18 @@ export class AgentFramework {
|
|
|
2422
2624
|
contextManager: ContextManager,
|
|
2423
2625
|
watchdogs?: { startupTimeoutMs?: number; idleTimeoutMs?: number; idlePollMs?: number },
|
|
2424
2626
|
): Promise<{ speech: string; toolCallsCount: number }> {
|
|
2425
|
-
//
|
|
2627
|
+
// Only a fresh object returned by createEphemeralAgent may enter this path.
|
|
2628
|
+
// Never overwrite a resident/conversation owner or a concurrent run: their
|
|
2629
|
+
// cleanup is name-keyed and could cancel/deregister the legitimate owner.
|
|
2630
|
+
if (this.ephemeralCandidates.get(agent) !== contextManager) {
|
|
2631
|
+
throw new Error(`Ephemeral agent "${agent.name}" has no fresh generation ticket from this framework`);
|
|
2632
|
+
}
|
|
2633
|
+
// Consume before any await/registration: the exact (Agent, ContextManager)
|
|
2634
|
+
// generation is one-shot even if startup later fails.
|
|
2635
|
+
this.ephemeralCandidates.delete(agent);
|
|
2636
|
+
if (this.agents.has(agent.name) || this.ephemeralRuns.has(agent.name)) {
|
|
2637
|
+
throw new Error(`Ephemeral agent "${agent.name}" is already registered or running`);
|
|
2638
|
+
}
|
|
2426
2639
|
this.agents.set(agent.name, agent);
|
|
2427
2640
|
const run: EphemeralRun = {
|
|
2428
2641
|
settle: this.createDeferred<AgentSettleResult>(),
|
|
@@ -2487,8 +2700,25 @@ export class AgentFramework {
|
|
|
2487
2700
|
} finally {
|
|
2488
2701
|
if (startupWatchdog) clearTimeout(startupWatchdog);
|
|
2489
2702
|
if (completionWatchdog) clearInterval(completionWatchdog);
|
|
2490
|
-
|
|
2491
|
-
this
|
|
2703
|
+
// Mark before cancellation: an adapter may synchronously settle its
|
|
2704
|
+
// iterator, and this physical frame still owns terminal typing/outgoing
|
|
2705
|
+
// closure even though the ephemeral name is about to be deregistered.
|
|
2706
|
+
this.disposedEphemeralFrames.add(agent);
|
|
2707
|
+
// Cancel before deregistration. Adapters may still yield a queued event;
|
|
2708
|
+
// driveStream's Agent-identity check above discards it before dispatch.
|
|
2709
|
+
agent.cancelStream();
|
|
2710
|
+
if (this.ephemeralRuns.get(agent.name) === run) this.ephemeralRuns.delete(agent.name);
|
|
2711
|
+
if (this.agents.get(agent.name) === agent) {
|
|
2712
|
+
// Exact-generation disposal owns these name-keyed entries. Remove them
|
|
2713
|
+
// before deregistration; the late driveStream finally will see the
|
|
2714
|
+
// generation mismatch and leave any future owner untouched.
|
|
2715
|
+
this.activeStreams.delete(agent.name);
|
|
2716
|
+
this.pendingAssistantBlocks.delete(agent.name);
|
|
2717
|
+
// The late stream finalizer intentionally refuses name-keyed cleanup once
|
|
2718
|
+
// deregistered, so disposal must release EventGate liveness itself.
|
|
2719
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
2720
|
+
this.agents.delete(agent.name);
|
|
2721
|
+
}
|
|
2492
2722
|
// Spawn-and-dispose bookkeeping (main, d453165/fee96a7): without this,
|
|
2493
2723
|
// ephemeral agents leave checkpoint-tree keys and diagnostics map
|
|
2494
2724
|
// entries behind for the life of the store/session.
|
|
@@ -2499,6 +2729,8 @@ export class AgentFramework {
|
|
|
2499
2729
|
// unbounded map growth. Blind delete is safe: the agent is already out
|
|
2500
2730
|
// of the map, and a late driveStream finally token-match no-ops.
|
|
2501
2731
|
this.activeTurnTokens.delete(agent.name);
|
|
2732
|
+
this.activeTurnTriggers.delete(agent.name);
|
|
2733
|
+
this.logicalTurnToolCalls.delete(agent);
|
|
2502
2734
|
}
|
|
2503
2735
|
}
|
|
2504
2736
|
|
|
@@ -2513,6 +2745,48 @@ export class AgentFramework {
|
|
|
2513
2745
|
* Query inference logs.
|
|
2514
2746
|
* Returns entries with summary info (doesn't resolve blobs).
|
|
2515
2747
|
*/
|
|
2748
|
+
/** Tune-out (issue #77): divert a channel to your subconscious. Present
|
|
2749
|
+
* only when the subconscious resident is configured. */
|
|
2750
|
+
private static readonly TUNE_OUT_TOOL: import('./types/index.js').ToolDefinition = {
|
|
2751
|
+
name: 'tune_out',
|
|
2752
|
+
description:
|
|
2753
|
+
'Divert a channel to your subconscious for a while: its traffic stops ' +
|
|
2754
|
+
'entering your context and stops waking you. Your subconscious watches ' +
|
|
2755
|
+
'it instead — summarizing on a cadence in its own voice, judging ' +
|
|
2756
|
+
'whether mentions merit interrupting, and able to cancel. People who ' +
|
|
2757
|
+
'mention you get an automatic reaction so they know you saw nothing. ' +
|
|
2758
|
+
'Cancelling (mode "cancel") delivers the diverted backlog. The ' +
|
|
2759
|
+
'tune-out auto-cancels after maxWakes mention-bursts.',
|
|
2760
|
+
inputSchema: {
|
|
2761
|
+
type: 'object',
|
|
2762
|
+
properties: {
|
|
2763
|
+
channelId: { type: 'string' },
|
|
2764
|
+
mode: { type: 'string', enum: ['enter', 'cancel'], description: 'Default: enter.' },
|
|
2765
|
+
cadenceSeconds: {
|
|
2766
|
+
type: 'number',
|
|
2767
|
+
description: `Summary cadence in seconds (default ${TUNE_OUT_DEFAULTS.cadenceSeconds}; floors at 30).`,
|
|
2768
|
+
},
|
|
2769
|
+
backlogCap: {
|
|
2770
|
+
type: 'number',
|
|
2771
|
+
description:
|
|
2772
|
+
`Max raw messages delivered at cancel (default ${TUNE_OUT_DEFAULTS.backlogCap}). ` +
|
|
2773
|
+
'0 = deliver no raw backlog at all: your subconscious\'s report is the whole delivery.',
|
|
2774
|
+
},
|
|
2775
|
+
maxWakes: {
|
|
2776
|
+
type: 'number',
|
|
2777
|
+
description: `Wake budget before auto-cancel (default ${TUNE_OUT_DEFAULTS.maxWakes}; floors at 1).`,
|
|
2778
|
+
},
|
|
2779
|
+
durationSeconds: {
|
|
2780
|
+
type: 'number',
|
|
2781
|
+
description:
|
|
2782
|
+
'Optional bounded duration: the tune-out auto-cancels (backlog ' +
|
|
2783
|
+
'delivered) after this many seconds; floors at 60. Unset = until cancelled.',
|
|
2784
|
+
},
|
|
2785
|
+
},
|
|
2786
|
+
required: ['channelId'],
|
|
2787
|
+
},
|
|
2788
|
+
};
|
|
2789
|
+
|
|
2516
2790
|
/** Synthesized sleep/wake tool definitions (present when a gate is wired). */
|
|
2517
2791
|
private static readonly SLEEP_TOOLS: import('./types/index.js').ToolDefinition[] = [
|
|
2518
2792
|
{
|
|
@@ -2730,6 +3004,28 @@ export class AgentFramework {
|
|
|
2730
3004
|
},
|
|
2731
3005
|
};
|
|
2732
3006
|
|
|
3007
|
+
/** Synthesized fetch_reference tool — RFC-005 on-demand dereference.
|
|
3008
|
+
* Present when a workspace module is registered (storage target). */
|
|
3009
|
+
private static readonly FETCH_REFERENCE_TOOL: import('./types/index.js').ToolDefinition = {
|
|
3010
|
+
name: 'fetch_reference',
|
|
3011
|
+
description:
|
|
3012
|
+
'Fetch a referenced payload (a [ref_…] id from a tool result or event) ' +
|
|
3013
|
+
'to a workspace file via the host-mediated fetcher. The fetch is ' +
|
|
3014
|
+
'origin-bound to the server that sent the reference, size-capped, and ' +
|
|
3015
|
+
'digest-verified when the reference carries one. Returns the workspace ' +
|
|
3016
|
+
'path plus verified size/type; then use ordinary workspace tools on it. ' +
|
|
3017
|
+
'Small payloads may already be fetched automatically — their stubs show ' +
|
|
3018
|
+
'a saved path instead.',
|
|
3019
|
+
inputSchema: {
|
|
3020
|
+
type: 'object',
|
|
3021
|
+
properties: {
|
|
3022
|
+
ref_id: { type: 'string', description: 'The reference id, e.g. "ref_1a_x9z2".' },
|
|
3023
|
+
max_bytes: { type: 'number', description: 'Optional per-call byte ceiling (default host policy).' },
|
|
3024
|
+
},
|
|
3025
|
+
required: ['ref_id'],
|
|
3026
|
+
},
|
|
3027
|
+
};
|
|
3028
|
+
|
|
2733
3029
|
/** Synthesized read_image tool — present when a workspace module is
|
|
2734
3030
|
* registered. Returns the image as a native image block in the tool
|
|
2735
3031
|
* result, so the agent SEES it in the live turn (history keeps a compact
|
|
@@ -3934,6 +4230,15 @@ export class AgentFramework {
|
|
|
3934
4230
|
// streaming agent keeps the original Membrane.
|
|
3935
4231
|
membrane: this.auxiliaryMembraneFor(config.name),
|
|
3936
4232
|
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
4233
|
+
// Tune-out (issue #77): messages stamped at ingestion as diverted
|
|
4234
|
+
// (metadata.tuneOut = { epochId }) never enter a resident's compiled
|
|
4235
|
+
// view — not at emission, not via chunking into the memory pyramid.
|
|
4236
|
+
// The stamp is permanent: after cancel, the backlog arrives as one
|
|
4237
|
+
// appended <tuned-out-backlog> dump instead of retro-inserting into
|
|
4238
|
+
// the timeline (KV-prefix-stable delivery). The stored originals
|
|
4239
|
+
// remain in the shared slot for the subconscious's merged view,
|
|
4240
|
+
// fetch_history, and audit.
|
|
4241
|
+
viewFilter: (message) => !(message.metadata as { tuneOut?: unknown } | undefined)?.tuneOut,
|
|
3937
4242
|
});
|
|
3938
4243
|
|
|
3939
4244
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
@@ -3954,6 +4259,71 @@ export class AgentFramework {
|
|
|
3954
4259
|
return agent;
|
|
3955
4260
|
}
|
|
3956
4261
|
|
|
4262
|
+
/**
|
|
4263
|
+
* Create and register the subconscious resident (issue #77): a persistent
|
|
4264
|
+
* side-agent with an isolated slot (`subconscious/<primary>`) whose
|
|
4265
|
+
* strategy view merges the residents' shared timeline with its own —
|
|
4266
|
+
* including the tuned-out-stamped messages the residents' viewFilter
|
|
4267
|
+
* excludes; the backlog is precisely what it exists to see. Windowed
|
|
4268
|
+
* passthrough, no memory pyramid: its durable output is what it delivers
|
|
4269
|
+
* into the resident's window, which accumulates there.
|
|
4270
|
+
*/
|
|
4271
|
+
private async createSubconsciousAgent(cfg: SubconsciousConfig): Promise<Agent> {
|
|
4272
|
+
const primaryName = this.primaryAgentName;
|
|
4273
|
+
const primaryConfig = primaryName ? this.agentConfigs.get(primaryName) : undefined;
|
|
4274
|
+
if (!primaryName || !primaryConfig) {
|
|
4275
|
+
throw new Error('subconscious requires a primary agent to attend to');
|
|
4276
|
+
}
|
|
4277
|
+
const name = cfg.name ?? 'Subconscious';
|
|
4278
|
+
if (this.agents.has(name)) {
|
|
4279
|
+
throw new Error(`subconscious name "${name}" collides with a registered agent`);
|
|
4280
|
+
}
|
|
4281
|
+
|
|
4282
|
+
const strategy = new WindowedPassthroughStrategy({
|
|
4283
|
+
reAnchorFraction: cfg.reAnchorFraction,
|
|
4284
|
+
// Mirror the principal's per-message truncation ceiling so both see
|
|
4285
|
+
// oversized tool results / attachments under the same policy.
|
|
4286
|
+
maxMessageTokens: primaryConfig.strategy?.maxMessageTokens,
|
|
4287
|
+
});
|
|
4288
|
+
const contextManager = await ContextManager.open({
|
|
4289
|
+
store: this.store,
|
|
4290
|
+
namespace: `subconscious/${primaryName}`,
|
|
4291
|
+
isolate: true,
|
|
4292
|
+
// The residents' shared un-namespaced slot, merged read-only and
|
|
4293
|
+
// UNfiltered (no viewFilter here — stamped messages included).
|
|
4294
|
+
auxiliaryMessageViews: [{}],
|
|
4295
|
+
strategy,
|
|
4296
|
+
membrane: this.membrane,
|
|
4297
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
4298
|
+
});
|
|
4299
|
+
|
|
4300
|
+
// Inherit the principal's inference settings wholesale (fork-template
|
|
4301
|
+
// idiom): reasoning effort (thinking), provider params, token ceilings,
|
|
4302
|
+
// caching, refusal handling, timezone — a same-model side-process must
|
|
4303
|
+
// not silently run under different inference conditions than the
|
|
4304
|
+
// resident it serves. Only identity- and surface-defining fields are
|
|
4305
|
+
// overridden below.
|
|
4306
|
+
const agentConfig: AgentConfig = {
|
|
4307
|
+
...primaryConfig,
|
|
4308
|
+
name,
|
|
4309
|
+
model: cfg.model ?? primaryConfig.model,
|
|
4310
|
+
systemPrompt: cfg.systemPrompt,
|
|
4311
|
+
strategy: undefined, // its CM owns the windowed strategy instance
|
|
4312
|
+
// Prose is never auto-routed: a cadence-triggered turn carries no
|
|
4313
|
+
// locus, and bare prose must not fall through to the default channel.
|
|
4314
|
+
// Speech happens only via speak_in_channel (its own marked voice).
|
|
4315
|
+
proseRouting: 'explicit',
|
|
4316
|
+
allowedTools: [...SUBCONSCIOUS_TOOL_NAMES, 'think', 'skip_reply', 'end_turn'],
|
|
4317
|
+
};
|
|
4318
|
+
const agent = new Agent(agentConfig, contextManager, this.membrane);
|
|
4319
|
+
this.agents.set(name, agent);
|
|
4320
|
+
this.agentConfigs.set(name, agentConfig);
|
|
4321
|
+
this.subconsciousAgentName = name;
|
|
4322
|
+
this.subconsciousStrategy = strategy;
|
|
4323
|
+
this.subconsciousConfig = cfg;
|
|
4324
|
+
return agent;
|
|
4325
|
+
}
|
|
4326
|
+
|
|
3957
4327
|
private async runLoop(): Promise<void> {
|
|
3958
4328
|
while (this.running) {
|
|
3959
4329
|
try {
|
|
@@ -4092,12 +4462,11 @@ export class AgentFramework {
|
|
|
4092
4462
|
// queued, they flush at the target's own next boundary — with
|
|
4093
4463
|
// injection — or in driveStream's finally when its turn ends.
|
|
4094
4464
|
const midTurnInjections: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata }> = [];
|
|
4095
|
-
|
|
4096
|
-
const
|
|
4097
|
-
if (
|
|
4098
|
-
const deferred = this.deferredMessages.splice(0);
|
|
4465
|
+
{
|
|
4466
|
+
const deferred = this.drainDeferredFor(agent.name);
|
|
4467
|
+
if (deferred.length > 0) {
|
|
4099
4468
|
for (const msg of deferred) {
|
|
4100
|
-
|
|
4469
|
+
agent.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
|
|
4101
4470
|
// Injection guards: tool blocks would corrupt the tool-cycle
|
|
4102
4471
|
// structure the membrane enforces, and a message named as the
|
|
4103
4472
|
// agent itself would render as an ASSISTANT turn on the wire
|
|
@@ -4432,7 +4801,8 @@ export class AgentFramework {
|
|
|
4432
4801
|
// their own channel's messages, not by framework-wide events.
|
|
4433
4802
|
const targetAgents =
|
|
4434
4803
|
response.requestInference === true
|
|
4435
|
-
? Array.from(this.agents.keys()).filter(
|
|
4804
|
+
? Array.from(this.agents.keys()).filter(
|
|
4805
|
+
(n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName)
|
|
4436
4806
|
: response.requestInference;
|
|
4437
4807
|
|
|
4438
4808
|
for (const agentName of targetAgents) {
|
|
@@ -4469,6 +4839,7 @@ export class AgentFramework {
|
|
|
4469
4839
|
metadata?: Record<string, unknown>;
|
|
4470
4840
|
tags?: string[];
|
|
4471
4841
|
triggerInference?: boolean;
|
|
4842
|
+
targetAgents?: string[];
|
|
4472
4843
|
}): Promise<void> {
|
|
4473
4844
|
const metadata: Record<string, unknown> = {
|
|
4474
4845
|
...event.metadata,
|
|
@@ -4515,12 +4886,38 @@ export class AgentFramework {
|
|
|
4515
4886
|
}
|
|
4516
4887
|
}
|
|
4517
4888
|
|
|
4889
|
+
// Tune-out divert (issue #77): stamped, stored, no resident wake. The
|
|
4890
|
+
// coordinator has already handled wake bookkeeping (coalesced
|
|
4891
|
+
// subconscious invocation, durable count, suppression ack) before we
|
|
4892
|
+
// stamp; the subconscious reads the message through its merged view.
|
|
4893
|
+
const divert = this.tuneOutCoordinator?.onIncoming(
|
|
4894
|
+
event.serverId,
|
|
4895
|
+
event.channelId,
|
|
4896
|
+
event.messageId,
|
|
4897
|
+
event.tags,
|
|
4898
|
+
event.author?.id,
|
|
4899
|
+
// The resident's gate verdict rides the event; it preconditions
|
|
4900
|
+
// subconscious wakes (gate composes, it is not replaced).
|
|
4901
|
+
event.triggerInference !== false,
|
|
4902
|
+
) ?? null;
|
|
4903
|
+
if (divert) {
|
|
4904
|
+
metadata.tuneOut = { epochId: divert.epochId };
|
|
4905
|
+
}
|
|
4906
|
+
|
|
4518
4907
|
const id = this.addMessage('user', incomingContent, metadata);
|
|
4519
4908
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
|
|
4520
4909
|
|
|
4521
|
-
if (event.triggerInference) {
|
|
4910
|
+
if (event.triggerInference && !divert) {
|
|
4522
4911
|
const addressed = isAddressedMessage(event.tags, event.metadata);
|
|
4523
|
-
|
|
4912
|
+
// Honor explicit targeting, mirroring the push-event path: an event
|
|
4913
|
+
// that names its agents wakes exactly those; an untargeted event
|
|
4914
|
+
// keeps the historical broadcast. (targetAgents was declared on
|
|
4915
|
+
// McplChannelIncomingEvent from the start but never honored here —
|
|
4916
|
+
// tune-out's wake routing is the first setter.)
|
|
4917
|
+
const targetAgents = event.targetAgents
|
|
4918
|
+
?? [...this.agents.keys()].filter((n) => n !== this.subconsciousAgentName);
|
|
4919
|
+
for (const agentName of targetAgents) {
|
|
4920
|
+
if (!this.agents.has(agentName)) continue;
|
|
4524
4921
|
this.pendingRequests.push({
|
|
4525
4922
|
agentName,
|
|
4526
4923
|
reason: 'mcpl:channel-incoming',
|
|
@@ -4529,6 +4926,7 @@ export class AgentFramework {
|
|
|
4529
4926
|
// Route this turn's auto-published speech back to THIS channel, not
|
|
4530
4927
|
// the global most-recent-inbound locus (item-3 redux, trunk agents).
|
|
4531
4928
|
channelId: event.channelId,
|
|
4929
|
+
counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
|
|
4532
4930
|
// Addressed messages outrank ambient chatter when a batched wake
|
|
4533
4931
|
// picks the turn's frozen speech locus.
|
|
4534
4932
|
addressed,
|
|
@@ -4630,6 +5028,7 @@ export class AgentFramework {
|
|
|
4630
5028
|
// A fork's home channel wins in routeSpeech regardless, but carry the
|
|
4631
5029
|
// triggering channel too so the trunk/active path stays consistent.
|
|
4632
5030
|
channelId: event.channelId,
|
|
5031
|
+
counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
|
|
4633
5032
|
addressed: isAddressedMessage(event.tags, event.metadata),
|
|
4634
5033
|
});
|
|
4635
5034
|
}
|
|
@@ -4649,41 +5048,50 @@ export class AgentFramework {
|
|
|
4649
5048
|
if (!template || !templateConfig) {
|
|
4650
5049
|
throw new Error(`conversation template agent "${router.templateAgent}" not found`);
|
|
4651
5050
|
}
|
|
5051
|
+
if (this.usedEphemeralAgentNames.has(name) || this.agents.has(name)) {
|
|
5052
|
+
throw new Error(`Conversation agent name "${name}" is already registered or reserved`);
|
|
5053
|
+
}
|
|
5054
|
+
this.usedEphemeralAgentNames.add(name);
|
|
5055
|
+
try {
|
|
4652
5056
|
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
5057
|
+
const contextManager = await ContextManager.open({
|
|
5058
|
+
store: this.store,
|
|
5059
|
+
namespace: `conversations/${name}`,
|
|
5060
|
+
isolate: true,
|
|
5061
|
+
// Strategy instances are stateful — never share the template's.
|
|
5062
|
+
strategy: router.strategyFactory?.() ?? new PassthroughStrategy(),
|
|
5063
|
+
// Dynamic conversation forks retain the established provider policy in
|
|
5064
|
+
// this bounded first slice. Provider cooldown ownership belongs to the
|
|
5065
|
+
// persistent resident only; generation-unique forks must not leak gates.
|
|
5066
|
+
membrane: this.membrane,
|
|
5067
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
5068
|
+
});
|
|
4665
5069
|
|
|
4666
|
-
|
|
4667
|
-
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
5070
|
+
// Seed with the template's compiled context, renaming the template
|
|
5071
|
+
// participant so the fork reads its inheritance as its own history.
|
|
5072
|
+
// Guard: only seed a genuinely fresh namespace. Generation counters are
|
|
5073
|
+
// persisted precisely so names aren't reused, but if this namespace has
|
|
5074
|
+
// history anyway (counter state lost, crash between spawn and persist),
|
|
5075
|
+
// seeding again would stack another template copy on top of it.
|
|
5076
|
+
const { messages: existing } = await contextManager.compile();
|
|
5077
|
+
if (existing.length === 0) {
|
|
5078
|
+
const { messages: compiled } = await template.getContextManager().compile();
|
|
5079
|
+
for (const msg of compiled) {
|
|
5080
|
+
const participant = msg.participant === template.name ? name : msg.participant;
|
|
5081
|
+
contextManager.addMessage(participant, msg.content);
|
|
5082
|
+
}
|
|
4678
5083
|
}
|
|
4679
|
-
}
|
|
4680
5084
|
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
5085
|
+
const config: AgentConfig = { ...templateConfig, name, strategy: undefined };
|
|
5086
|
+
const agent = new Agent(config, contextManager, this.membrane);
|
|
5087
|
+
this.agents.set(name, agent);
|
|
5088
|
+
this.agentConfigs.set(name, config);
|
|
5089
|
+
this.conversationAgentHomes.set(name, channelId);
|
|
5090
|
+
return agent;
|
|
5091
|
+
} catch (error) {
|
|
5092
|
+
this.usedEphemeralAgentNames.delete(name);
|
|
5093
|
+
throw error;
|
|
5094
|
+
}
|
|
4687
5095
|
}
|
|
4688
5096
|
|
|
4689
5097
|
/** Persist the router's generation counters (see hydration in create()). */
|
|
@@ -4707,10 +5115,12 @@ export class AgentFramework {
|
|
|
4707
5115
|
private disposeConversationAgent(agentName: string): void {
|
|
4708
5116
|
this.closingConversationAgents.delete(agentName);
|
|
4709
5117
|
const channelId = this.conversationAgentHomes.get(agentName);
|
|
5118
|
+
const agent = this.agents.get(agentName);
|
|
4710
5119
|
this.agents.delete(agentName);
|
|
4711
5120
|
this.agentConfigs.delete(agentName);
|
|
4712
5121
|
this.conversationAgentHomes.delete(agentName);
|
|
4713
5122
|
this.evictTurnCheckpoints(agentName);
|
|
5123
|
+
if (agent) this.logicalTurnToolCalls.delete(agent);
|
|
4714
5124
|
this.emitTrace({
|
|
4715
5125
|
type: 'mcpl:conversation-disposed',
|
|
4716
5126
|
agentName,
|
|
@@ -4946,7 +5356,8 @@ export class AgentFramework {
|
|
|
4946
5356
|
if (event.triggerInference) {
|
|
4947
5357
|
// Default broadcast excludes conversation forks (channel-driven).
|
|
4948
5358
|
const targetAgents = event.targetAgents
|
|
4949
|
-
?? [...this.agents.keys()].filter(
|
|
5359
|
+
?? [...this.agents.keys()].filter(
|
|
5360
|
+
(n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
|
|
4950
5361
|
for (const agentName of targetAgents) {
|
|
4951
5362
|
this.pendingRequests.push({
|
|
4952
5363
|
agentName,
|
|
@@ -5147,6 +5558,9 @@ export class AgentFramework {
|
|
|
5147
5558
|
`[inference-dropped] agent=${agentName} reason=policy-skip ` +
|
|
5148
5559
|
`requests=${requests.length} triggers=${requests.map((r) => r.reason).join(',')}`,
|
|
5149
5560
|
);
|
|
5561
|
+
// A context-budget restart inherits the predecessor's EventGate liveness.
|
|
5562
|
+
// If policy drops the queued successor, no driveStream remains to release it.
|
|
5563
|
+
if (budgetRestart) this.eventGate?.onInferenceEnded(agentName);
|
|
5150
5564
|
const gate = this.providerGates.get(agentName);
|
|
5151
5565
|
if (gate?.primaryPending && this.providerAccelerationRecoveries.has(agentName)) {
|
|
5152
5566
|
gate.primaryPending = false;
|
|
@@ -5174,19 +5588,27 @@ export class AgentFramework {
|
|
|
5174
5588
|
// newest (2026-07-21 Cairn lounge misroute, turn-start variant).
|
|
5175
5589
|
// Non-channel wakes (heartbeats, module events, reactions — which never
|
|
5176
5590
|
// carry channelId) leave both undefined → global fallback.
|
|
5177
|
-
|
|
5178
|
-
|
|
5591
|
+
// Track the winning REQUEST, not just its channel: channel, addressed
|
|
5592
|
+
// and counterparty must come from the same message, or a batch of
|
|
5593
|
+
// "ambient from A, then addressed from B" would report B's channel
|
|
5594
|
+
// with A's author (review finding on the provenance change).
|
|
5595
|
+
let ambientReq: InferenceRequest | undefined;
|
|
5596
|
+
let addressedReq: InferenceRequest | undefined;
|
|
5179
5597
|
for (const r of requests) {
|
|
5180
5598
|
if (!r.channelId) continue;
|
|
5181
|
-
|
|
5182
|
-
if (r.addressed)
|
|
5599
|
+
ambientReq = r;
|
|
5600
|
+
if (r.addressed) addressedReq = r;
|
|
5183
5601
|
}
|
|
5184
|
-
const
|
|
5185
|
-
const triggerAddressed = addressedChannel !== undefined;
|
|
5602
|
+
const channelReq = addressedReq ?? ambientReq;
|
|
5186
5603
|
await this.startAgentStream(agent, {
|
|
5187
5604
|
...trigger,
|
|
5188
|
-
channelId:
|
|
5189
|
-
addressed:
|
|
5605
|
+
channelId: channelReq?.channelId,
|
|
5606
|
+
addressed: addressedReq !== undefined,
|
|
5607
|
+
// A context-budget restart continues the same logical turn: it keeps
|
|
5608
|
+
// the channel for routing but names no author — the restart is its
|
|
5609
|
+
// own cause, and borrowing another request's author would be false
|
|
5610
|
+
// provenance.
|
|
5611
|
+
counterparty: budgetRestart ? undefined : channelReq?.counterparty,
|
|
5190
5612
|
});
|
|
5191
5613
|
}
|
|
5192
5614
|
}
|
|
@@ -5686,12 +6108,14 @@ export class AgentFramework {
|
|
|
5686
6108
|
// no-ops instead of clobbering a successor's marker.
|
|
5687
6109
|
const turnToken = this.nextTurnToken++;
|
|
5688
6110
|
this.activeTurnTokens.set(agent.name, turnToken);
|
|
6111
|
+
this.activeTurnTriggers.set(agent.name, trigger);
|
|
5689
6112
|
let tokenHandedOff = false;
|
|
5690
6113
|
try {
|
|
5691
6114
|
tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate);
|
|
5692
6115
|
} finally {
|
|
5693
6116
|
if (!tokenHandedOff && this.activeTurnTokens.get(agent.name) === turnToken) {
|
|
5694
6117
|
this.activeTurnTokens.delete(agent.name);
|
|
6118
|
+
this.activeTurnTriggers.delete(agent.name);
|
|
5695
6119
|
}
|
|
5696
6120
|
if (!tokenHandedOff && ownsProviderGate) this.releasePrimaryProviderGate(agent.name);
|
|
5697
6121
|
}
|
|
@@ -5728,9 +6152,8 @@ export class AgentFramework {
|
|
|
5728
6152
|
trigger?.reason !== 'context_budget_restart' &&
|
|
5729
6153
|
this.deferredMessages.length > 0
|
|
5730
6154
|
) {
|
|
5731
|
-
|
|
5732
|
-
|
|
5733
|
-
const deferred = this.deferredMessages.splice(0);
|
|
6155
|
+
{
|
|
6156
|
+
const deferred = this.drainDeferredFor(agent.name);
|
|
5734
6157
|
for (const msg of deferred) {
|
|
5735
6158
|
// Per-message try/catch (mirrors announceLocusIfChanged): one
|
|
5736
6159
|
// poison message — or a transient store-write failure — must not
|
|
@@ -5788,8 +6211,21 @@ export class AgentFramework {
|
|
|
5788
6211
|
// BEFORE this turn compiles, so the agent always knows where its voice
|
|
5789
6212
|
// goes (announce-on-change only — no per-turn chatter, append-only for
|
|
5790
6213
|
// KV stability).
|
|
6214
|
+
if (trigger?.reason === 'context_budget_restart') {
|
|
6215
|
+
const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
6216
|
+
this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
|
|
6217
|
+
}
|
|
6218
|
+
|
|
5791
6219
|
if (trigger?.reason !== 'context_budget_restart') {
|
|
5792
6220
|
if (attempt === 0) this.maybePrimeProseMode(agent);
|
|
6221
|
+
const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
6222
|
+
if (attempt === 0) {
|
|
6223
|
+
// A true new turn gets a fresh generation and count.
|
|
6224
|
+
this.logicalTurnToolCalls.set(agent, { turnToken, count: 0 });
|
|
6225
|
+
} else {
|
|
6226
|
+
// A framework retry is a new physical stream in the same logical turn.
|
|
6227
|
+
this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
|
|
6228
|
+
}
|
|
5793
6229
|
// Fresh turn: forget the previous turn's explicit-send engagements and
|
|
5794
6230
|
// prose deliveries — both are strictly turn-scoped (a restart continues
|
|
5795
6231
|
// the same logical turn and keeps them).
|
|
@@ -5823,7 +6259,11 @@ export class AgentFramework {
|
|
|
5823
6259
|
// context-budget restart, which skips the re-pin but re-emits this).
|
|
5824
6260
|
channelId: this.turnLocusPins.get(agent.name),
|
|
5825
6261
|
});
|
|
5826
|
-
|
|
6262
|
+
// A budget restart continues the same logical inference window. The
|
|
6263
|
+
// predecessor keeps EventGate liveness until its successor terminates.
|
|
6264
|
+
if (trigger?.reason !== 'context_budget_restart') {
|
|
6265
|
+
this.eventGate?.onInferenceStarted(agent.name);
|
|
6266
|
+
}
|
|
5827
6267
|
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
|
|
5828
6268
|
|
|
5829
6269
|
// Typing indicator, started at TURN START. It says "attending", which is
|
|
@@ -5886,7 +6326,38 @@ export class AgentFramework {
|
|
|
5886
6326
|
}
|
|
5887
6327
|
}
|
|
5888
6328
|
|
|
5889
|
-
|
|
6329
|
+
// An ephemeral watchdog may dispose this Agent while hooks/compile await.
|
|
6330
|
+
// Do not create a provider stream after its generation lost ownership.
|
|
6331
|
+
if (this.agents.get(agent.name) !== agent) {
|
|
6332
|
+
this.channelRegistry?.stopTyping();
|
|
6333
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
6334
|
+
return false;
|
|
6335
|
+
}
|
|
6336
|
+
|
|
6337
|
+
// The subconscious's standing dispositions ride a fixed system-position
|
|
6338
|
+
// injection (antra, #tuneout-talk): never repeated in its timeline, and
|
|
6339
|
+
// — since it has no long-term memory — structurally unable to wash out
|
|
6340
|
+
// of its window. Bytes change only when a disposition changes.
|
|
6341
|
+
if (agent.name === this.subconsciousAgentName && this.tuneOutCoordinator) {
|
|
6342
|
+
const dispositionInjection = this.tuneOutCoordinator.getDispositionsInjection();
|
|
6343
|
+
if (dispositionInjection) {
|
|
6344
|
+
injections = injections ? [...injections, dispositionInjection] : [dispositionInjection];
|
|
6345
|
+
}
|
|
6346
|
+
}
|
|
6347
|
+
|
|
6348
|
+
const {
|
|
6349
|
+
stream,
|
|
6350
|
+
request: compiledRequest,
|
|
6351
|
+
takeKvSubmission,
|
|
6352
|
+
drainKvSubmissionIds,
|
|
6353
|
+
} = await agent.startStreamWithInjections(tools, injections);
|
|
6354
|
+
if (this.agents.get(agent.name) !== agent) {
|
|
6355
|
+
stream.cancel();
|
|
6356
|
+
agent.cancelStream();
|
|
6357
|
+
this.channelRegistry?.stopTyping();
|
|
6358
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
6359
|
+
return false;
|
|
6360
|
+
}
|
|
5890
6361
|
|
|
5891
6362
|
const handle = this.driveStream(
|
|
5892
6363
|
agent,
|
|
@@ -5896,6 +6367,9 @@ export class AgentFramework {
|
|
|
5896
6367
|
attempt,
|
|
5897
6368
|
compiledRequest,
|
|
5898
6369
|
ownsProviderGate,
|
|
6370
|
+
takeKvSubmission,
|
|
6371
|
+
drainKvSubmissionIds,
|
|
6372
|
+
new Set(tools.map((tool) => tool.name)),
|
|
5899
6373
|
);
|
|
5900
6374
|
this.activeStreams.set(agent.name, handle);
|
|
5901
6375
|
// Handoff: driveStream captured the token in its synchronous prefix;
|
|
@@ -5936,6 +6410,7 @@ export class AgentFramework {
|
|
|
5936
6410
|
// the eventGate `inferring` leak) — every exit path must clear it.
|
|
5937
6411
|
if (this.activeTurnTokens.get(agent.name) === turnToken) {
|
|
5938
6412
|
this.activeTurnTokens.delete(agent.name);
|
|
6413
|
+
this.activeTurnTriggers.delete(agent.name);
|
|
5939
6414
|
}
|
|
5940
6415
|
this.settleAgent(agent.name, {
|
|
5941
6416
|
stopReason: 'exhausted',
|
|
@@ -5977,15 +6452,29 @@ export class AgentFramework {
|
|
|
5977
6452
|
attempt = 0,
|
|
5978
6453
|
compiledRequest?: NormalizedRequest,
|
|
5979
6454
|
ownsProviderGate = false,
|
|
6455
|
+
takeKvSubmission?: () => { submissionId: string; wireReceipt: CacheWireReceipt } | undefined,
|
|
6456
|
+
drainKvSubmissionIds?: () => string[],
|
|
6457
|
+
knownToolNames: ReadonlySet<string> = new Set(),
|
|
5980
6458
|
): Promise<void> {
|
|
5981
6459
|
const startTime = Date.now();
|
|
5982
6460
|
const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
|
|
5983
6461
|
const myStreamId = agent.streamId;
|
|
6462
|
+
// Membrane usage events are cumulative across the native/XML tool loop.
|
|
6463
|
+
// Keep the previous cumulative sample so consumers that operate at the
|
|
6464
|
+
// physical provider-call boundary (estimator calibration and kv receipt
|
|
6465
|
+
// acceptance) see this call only, not an ever-growing turn total.
|
|
6466
|
+
let previousUsage = {
|
|
6467
|
+
inputTokens: 0,
|
|
6468
|
+
outputTokens: 0,
|
|
6469
|
+
cacheCreationTokens: 0,
|
|
6470
|
+
cacheReadTokens: 0,
|
|
6471
|
+
};
|
|
5984
6472
|
// This turn's alive-marker, set at startAgentStream entry. Safe to read
|
|
5985
6473
|
// from the map here: the turn-alive busy check in processInferenceRequests
|
|
5986
6474
|
// means no successor turn can have replaced it while we compiled.
|
|
5987
6475
|
const myTurnToken = this.activeTurnTokens.get(agent.name);
|
|
5988
6476
|
let hadToolCalls = false;
|
|
6477
|
+
let generationLost = false;
|
|
5989
6478
|
|
|
5990
6479
|
// ---- Present-while-acting turn state ---------------------------------
|
|
5991
6480
|
// Output locus for the WHOLE logical turn: frozen in startAgentStream
|
|
@@ -6069,13 +6558,20 @@ export class AgentFramework {
|
|
|
6069
6558
|
// failed, a framework-cancelled stream sets aborted. Best-effort
|
|
6070
6559
|
// notifications — consumers dedupe by inferenceId and keep a timeout.
|
|
6071
6560
|
let lifecyclePhase: 'completed' | 'aborted' | 'failed' = 'completed';
|
|
6561
|
+
let preserveEventGateForSuccessor = false;
|
|
6072
6562
|
this.hookOrchestrator?.emitLifecycle({
|
|
6073
6563
|
inferenceId: outgoingInferenceId,
|
|
6074
6564
|
conversationId: agent.name,
|
|
6075
6565
|
turnIndex: 0,
|
|
6076
6566
|
phase: 'started',
|
|
6077
6567
|
});
|
|
6078
|
-
|
|
6568
|
+
// The wrapper guard needs the complete response before it can distinguish
|
|
6569
|
+
// an exact invocation-shaped body from ordinary XML/prose. Buffer guarded
|
|
6570
|
+
// turns until completion: otherwise speculative outgoing chunks could
|
|
6571
|
+
// expose the wrapper before the fail-closed classifier runs.
|
|
6572
|
+
const proseStream = this.channelRegistry &&
|
|
6573
|
+
agent.proseRouting !== 'disabled' &&
|
|
6574
|
+
!agent.toolWrapperProseGuard
|
|
6079
6575
|
? new ProseStreamRouter({
|
|
6080
6576
|
mode: agent.proseRouting === 'explicit' ? 'explicit' : agent.proseRouting === 'hybrid' ? 'hybrid' : 'locus',
|
|
6081
6577
|
initialTarget: typingChannel,
|
|
@@ -6105,6 +6601,14 @@ export class AgentFramework {
|
|
|
6105
6601
|
|
|
6106
6602
|
try {
|
|
6107
6603
|
for await (const event of stream) {
|
|
6604
|
+
// Ignore every late event from an Agent generation that no longer owns
|
|
6605
|
+
// this name (ephemeral watchdog/disposal, conversation replacement).
|
|
6606
|
+
if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
|
|
6607
|
+
generationLost = true;
|
|
6608
|
+
lifecyclePhase = 'aborted';
|
|
6609
|
+
stream.cancel();
|
|
6610
|
+
break;
|
|
6611
|
+
}
|
|
6108
6612
|
this.touchEphemeralRun(agent.name, true);
|
|
6109
6613
|
switch (event.type) {
|
|
6110
6614
|
case 'tokens':
|
|
@@ -6172,6 +6676,7 @@ export class AgentFramework {
|
|
|
6172
6676
|
case 'tool-calls': {
|
|
6173
6677
|
adoptInjectedRound();
|
|
6174
6678
|
hadToolCalls = true;
|
|
6679
|
+
this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length);
|
|
6175
6680
|
this.recordEphemeralToolCalls(agent.name, event.calls.length);
|
|
6176
6681
|
this.emitTrace({
|
|
6177
6682
|
type: 'inference:tool_calls_yielded',
|
|
@@ -6257,10 +6762,7 @@ export class AgentFramework {
|
|
|
6257
6762
|
const hasSameRoundPrivateThink =
|
|
6258
6763
|
roundToolNames.includes('think') &&
|
|
6259
6764
|
requestSnapshot.sameRoundThinkTextPolicy === 'private';
|
|
6260
|
-
if (roundToolNames.some(
|
|
6261
|
-
SILENCING_TOOLS.has(bareToolName(n)) ||
|
|
6262
|
-
(agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n)))
|
|
6263
|
-
)) {
|
|
6765
|
+
if (roundToolNames.some(isSilencingTool)) {
|
|
6264
6766
|
turnSilenced = true;
|
|
6265
6767
|
}
|
|
6266
6768
|
if (roundContent && roundContent.length > 0) {
|
|
@@ -6380,7 +6882,30 @@ export class AgentFramework {
|
|
|
6380
6882
|
const terminalContent = lastToolIdx >= 0
|
|
6381
6883
|
? response.content.slice(lastToolIdx + 1)
|
|
6382
6884
|
: response.content;
|
|
6383
|
-
|
|
6885
|
+
// This is a whole-response boundary, not a trailing-prose
|
|
6886
|
+
// classifier. If the turn executed any genuine structured tool
|
|
6887
|
+
// call, preserve its later prose exactly as ordinary history.
|
|
6888
|
+
const logicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
6889
|
+
const logicalTurnHadToolCalls = logicalToolState?.turnToken === myTurnToken && (logicalToolState?.count ?? 0) > 0;
|
|
6890
|
+
const guardedWrapperTool = agent.toolWrapperProseGuard && !logicalTurnHadToolCalls
|
|
6891
|
+
? detectKnownToolWrapperProse(response.content, knownToolNames)
|
|
6892
|
+
: null;
|
|
6893
|
+
if (guardedWrapperTool) {
|
|
6894
|
+
// AF #133: containment only. Never execute wrapper text, never
|
|
6895
|
+
// publish it, and never retain it as the freshest assistant
|
|
6896
|
+
// self-seed. Exact raw output remains in the provider ledger.
|
|
6897
|
+
agent.getContextManager().addMessage('user', [{
|
|
6898
|
+
type: 'text',
|
|
6899
|
+
text: '[tool-boundary] No tool was called.',
|
|
6900
|
+
}], {
|
|
6901
|
+
system: true,
|
|
6902
|
+
kind: 'tool-wrapper-prose-contained',
|
|
6903
|
+
toolName: guardedWrapperTool,
|
|
6904
|
+
});
|
|
6905
|
+
console.error(
|
|
6906
|
+
`[tool-boundary] ${agent.name}: contained whole-response prose wrapper for registered tool ${guardedWrapperTool}; no tool called`,
|
|
6907
|
+
);
|
|
6908
|
+
} else if (lastToolIdx >= 0) {
|
|
6384
6909
|
if (terminalContent.length > 0) {
|
|
6385
6910
|
agent.addAssistantResponse(terminalContent);
|
|
6386
6911
|
}
|
|
@@ -6406,7 +6931,9 @@ export class AgentFramework {
|
|
|
6406
6931
|
// text is speech.
|
|
6407
6932
|
const isTextBlock = (block: ContentBlock): block is ContentBlock & { type: 'text' } =>
|
|
6408
6933
|
block.type === 'text';
|
|
6409
|
-
const allText =
|
|
6934
|
+
const allText = guardedWrapperTool
|
|
6935
|
+
? []
|
|
6936
|
+
: response.content.filter(isTextBlock);
|
|
6410
6937
|
|
|
6411
6938
|
const speechContent = hadToolCalls ? [] : allText;
|
|
6412
6939
|
const thoughts = hadToolCalls ? allText : [];
|
|
@@ -6421,19 +6948,30 @@ export class AgentFramework {
|
|
|
6421
6948
|
}
|
|
6422
6949
|
: undefined;
|
|
6423
6950
|
|
|
6951
|
+
// Async completion work may overlap a replacement stream. Recheck
|
|
6952
|
+
// physical ownership before any terminal state/settlement side effect.
|
|
6953
|
+
if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
|
|
6954
|
+
generationLost = true;
|
|
6955
|
+
lifecyclePhase = 'aborted';
|
|
6956
|
+
stream.cancel();
|
|
6957
|
+
return;
|
|
6958
|
+
}
|
|
6959
|
+
|
|
6424
6960
|
// Reset agent state before emitting inference:completed. Traces are
|
|
6425
6961
|
// observability-only, but external synchronous listeners should
|
|
6426
6962
|
// still see the terminal state at the terminal trace boundary.
|
|
6427
6963
|
// Speech dispatch happens after but doesn't depend on the status
|
|
6428
6964
|
// field.
|
|
6429
6965
|
agent.reset();
|
|
6430
|
-
this.eventGate?.onInferenceEnded(agent.name);
|
|
6966
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
|
|
6431
6967
|
this.settleAgent(agent.name, {
|
|
6432
6968
|
stopReason: 'completed',
|
|
6433
|
-
speech:
|
|
6434
|
-
|
|
6435
|
-
|
|
6436
|
-
|
|
6969
|
+
speech: guardedWrapperTool
|
|
6970
|
+
? ''
|
|
6971
|
+
: terminalContent
|
|
6972
|
+
.filter((block: ContentBlock): block is ContentBlock & { type: 'text' } => block.type === 'text')
|
|
6973
|
+
.map((block) => block.text)
|
|
6974
|
+
.join('\n'),
|
|
6437
6975
|
});
|
|
6438
6976
|
|
|
6439
6977
|
this.emitTrace({
|
|
@@ -6675,10 +7213,7 @@ export class AgentFramework {
|
|
|
6675
7213
|
.filter((n): n is string => typeof n === 'string');
|
|
6676
7214
|
const silenced = liveProseRouting
|
|
6677
7215
|
? turnSilenced
|
|
6678
|
-
: turnSilenced || toolNames.some(
|
|
6679
|
-
SILENCING_TOOLS.has(bareToolName(n)) ||
|
|
6680
|
-
(agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n)))
|
|
6681
|
-
);
|
|
7216
|
+
: turnSilenced || toolNames.some(isSilencingTool);
|
|
6682
7217
|
|
|
6683
7218
|
const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content);
|
|
6684
7219
|
|
|
@@ -6810,7 +7345,7 @@ export class AgentFramework {
|
|
|
6810
7345
|
|
|
6811
7346
|
if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
|
|
6812
7347
|
lifecyclePhase = 'failed';
|
|
6813
|
-
this.eventGate?.onInferenceEnded(agent.name);
|
|
7348
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
|
|
6814
7349
|
break;
|
|
6815
7350
|
}
|
|
6816
7351
|
|
|
@@ -6834,7 +7369,7 @@ export class AgentFramework {
|
|
|
6834
7369
|
// itself) means retrying the same context can never succeed.
|
|
6835
7370
|
...this.classifyInferenceError(err),
|
|
6836
7371
|
});
|
|
6837
|
-
this.eventGate?.onInferenceEnded(agent.name);
|
|
7372
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
|
|
6838
7373
|
if (action.emit) {
|
|
6839
7374
|
this.pushEvent(action.emit);
|
|
6840
7375
|
}
|
|
@@ -6856,7 +7391,13 @@ export class AgentFramework {
|
|
|
6856
7391
|
if (cancelKind !== undefined) {
|
|
6857
7392
|
this.frameworkCancelledStreams.delete(cancelKey);
|
|
6858
7393
|
lifecyclePhase = 'aborted'; // §10.5 — terminal emitted in finally
|
|
6859
|
-
|
|
7394
|
+
// A budget restart continues the same EventGate window. Do not
|
|
7395
|
+
// end it here or in finally; the replacement owns final release.
|
|
7396
|
+
if (cancelKind === 'budget_restart') {
|
|
7397
|
+
preserveEventGateForSuccessor = true;
|
|
7398
|
+
} else if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) {
|
|
7399
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
7400
|
+
}
|
|
6860
7401
|
// endTurn IS a logical turn end — earlier rounds may have
|
|
6861
7402
|
// live-routed prose (narrate → skip_reply is a real shape),
|
|
6862
7403
|
// so settle the delivery chain and drop the receipt. A
|
|
@@ -6903,7 +7444,7 @@ export class AgentFramework {
|
|
|
6903
7444
|
request: compiledRequest ?? { note: 'streaming request aborted' },
|
|
6904
7445
|
durationMs,
|
|
6905
7446
|
});
|
|
6906
|
-
this.eventGate?.onInferenceEnded(agent.name);
|
|
7447
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
|
|
6907
7448
|
}
|
|
6908
7449
|
break;
|
|
6909
7450
|
}
|
|
@@ -6925,19 +7466,62 @@ export class AgentFramework {
|
|
|
6925
7466
|
// loop (5 calls x ~160k reported as 884k), which is not a
|
|
6926
7467
|
// window-shaped number and drove the multiplier to 2.37 before
|
|
6927
7468
|
// the guards caught it.
|
|
7469
|
+
const cumulativeUsage = {
|
|
7470
|
+
inputTokens: event.usage.inputTokens ?? 0,
|
|
7471
|
+
outputTokens: event.usage.outputTokens ?? 0,
|
|
7472
|
+
cacheCreationTokens: event.usage.cacheCreationTokens ?? 0,
|
|
7473
|
+
cacheReadTokens: event.usage.cacheReadTokens ?? 0,
|
|
7474
|
+
};
|
|
7475
|
+
const perCallUsage = {
|
|
7476
|
+
inputTokens: cumulativeDelta(cumulativeUsage.inputTokens, previousUsage.inputTokens),
|
|
7477
|
+
outputTokens: cumulativeDelta(cumulativeUsage.outputTokens, previousUsage.outputTokens),
|
|
7478
|
+
cacheCreationTokens: cumulativeDelta(
|
|
7479
|
+
cumulativeUsage.cacheCreationTokens,
|
|
7480
|
+
previousUsage.cacheCreationTokens,
|
|
7481
|
+
),
|
|
7482
|
+
cacheReadTokens: cumulativeDelta(
|
|
7483
|
+
cumulativeUsage.cacheReadTokens,
|
|
7484
|
+
previousUsage.cacheReadTokens,
|
|
7485
|
+
),
|
|
7486
|
+
};
|
|
7487
|
+
previousUsage = cumulativeUsage;
|
|
7488
|
+
const strat = (agent as unknown as {
|
|
7489
|
+
getContextManager?: () => { getStrategy?: () => unknown };
|
|
7490
|
+
}).getContextManager?.()?.getStrategy?.() as
|
|
7491
|
+
| {
|
|
7492
|
+
reportRealInputTokens?: (n: number) => void;
|
|
7493
|
+
reportKvUnifiedAccepted?: (args: {
|
|
7494
|
+
submissionId: string;
|
|
7495
|
+
acceptedAt?: number;
|
|
7496
|
+
wireReceipt?: CacheWireReceipt;
|
|
7497
|
+
}) => void;
|
|
7498
|
+
reportKvUnifiedFailed?: (submissionId: string) => void;
|
|
7499
|
+
}
|
|
7500
|
+
| undefined;
|
|
6928
7501
|
try {
|
|
6929
7502
|
const realTotal =
|
|
6930
|
-
|
|
6931
|
-
|
|
6932
|
-
|
|
6933
|
-
const strat = (agent as unknown as {
|
|
6934
|
-
getContextManager?: () => { getStrategy?: () => unknown };
|
|
6935
|
-
}).getContextManager?.()?.getStrategy?.() as
|
|
6936
|
-
| { reportRealInputTokens?: (n: number) => void }
|
|
6937
|
-
| undefined;
|
|
7503
|
+
perCallUsage.inputTokens +
|
|
7504
|
+
perCallUsage.cacheCreationTokens +
|
|
7505
|
+
perCallUsage.cacheReadTokens;
|
|
6938
7506
|
strat?.reportRealInputTokens?.(realTotal);
|
|
6939
7507
|
} catch { /* calibration is best-effort */ }
|
|
6940
7508
|
|
|
7509
|
+
const kvSubmission = takeKvSubmission?.();
|
|
7510
|
+
if (kvSubmission) {
|
|
7511
|
+
try {
|
|
7512
|
+
strat?.reportKvUnifiedAccepted?.({
|
|
7513
|
+
submissionId: kvSubmission.submissionId,
|
|
7514
|
+
acceptedAt: Date.now(),
|
|
7515
|
+
wireReceipt: kvSubmission.wireReceipt,
|
|
7516
|
+
});
|
|
7517
|
+
} catch {
|
|
7518
|
+
// The queue item has already been consumed. Explicitly close
|
|
7519
|
+
// the receipt flight so one persistence error cannot wedge
|
|
7520
|
+
// every later provider call on this branch.
|
|
7521
|
+
try { strat?.reportKvUnifiedFailed?.(kvSubmission.submissionId); } catch { /* teardown only */ }
|
|
7522
|
+
}
|
|
7523
|
+
}
|
|
7524
|
+
|
|
6941
7525
|
this.emitTrace({
|
|
6942
7526
|
type: 'inference:usage',
|
|
6943
7527
|
agentName: agent.name,
|
|
@@ -6953,6 +7537,14 @@ export class AgentFramework {
|
|
|
6953
7537
|
}
|
|
6954
7538
|
}
|
|
6955
7539
|
} catch (error) {
|
|
7540
|
+
// A superseded physical stream may throw after its replacement starts.
|
|
7541
|
+
// It has no authority to settle, reset, gate-release, or publish failure.
|
|
7542
|
+
if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
|
|
7543
|
+
generationLost = true;
|
|
7544
|
+
lifecyclePhase = 'aborted';
|
|
7545
|
+
stream.cancel();
|
|
7546
|
+
return;
|
|
7547
|
+
}
|
|
6956
7548
|
// Stream itself threw. Organization acceleration is deferred from a
|
|
6957
7549
|
// fresh compile; every other throw keeps the ordinary exhausted path.
|
|
6958
7550
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
@@ -6963,7 +7555,7 @@ export class AgentFramework {
|
|
|
6963
7555
|
error: `Provider acceleration cooldown: ${err.message}`,
|
|
6964
7556
|
request: compiledRequest ?? { note: 'streaming request rate-limited' }, durationMs });
|
|
6965
7557
|
this.abortAgentScript(agent.name, 'provider acceleration cooldown');
|
|
6966
|
-
lifecyclePhase = 'failed'; agent.reset(); this.eventGate?.onInferenceEnded(agent.name);
|
|
7558
|
+
lifecyclePhase = 'failed'; agent.reset(); if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
|
|
6967
7559
|
return;
|
|
6968
7560
|
}
|
|
6969
7561
|
this.emitTrace({
|
|
@@ -6998,8 +7590,35 @@ export class AgentFramework {
|
|
|
6998
7590
|
this.abortAgentScript(agent.name, 'stream threw');
|
|
6999
7591
|
lifecyclePhase = 'failed'; // §10.5 — terminal emitted in finally
|
|
7000
7592
|
agent.reset();
|
|
7001
|
-
this.eventGate?.onInferenceEnded(agent.name);
|
|
7593
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
|
|
7002
7594
|
} finally {
|
|
7595
|
+
const unsettledKvSubmissions = drainKvSubmissionIds?.() ?? [];
|
|
7596
|
+
if (unsettledKvSubmissions.length > 0) {
|
|
7597
|
+
try {
|
|
7598
|
+
const strategy = agent.getContextManager().getStrategy() as unknown as {
|
|
7599
|
+
reportKvUnifiedFailed?: (submissionId: string) => void;
|
|
7600
|
+
};
|
|
7601
|
+
for (const submissionId of unsettledKvSubmissions) {
|
|
7602
|
+
strategy.reportKvUnifiedFailed?.(submissionId);
|
|
7603
|
+
}
|
|
7604
|
+
} catch { /* receipt cleanup must not mask inference teardown */ }
|
|
7605
|
+
}
|
|
7606
|
+
// A framework retry/budget restart can overlap physical streams on the
|
|
7607
|
+
// same Agent object. Name + Agent identity is not enough: only the
|
|
7608
|
+
// current stream generation may mutate name-keyed teardown state.
|
|
7609
|
+
const ownsPhysicalStream =
|
|
7610
|
+
this.agents.get(agent.name) === agent && agent.streamId === myStreamId;
|
|
7611
|
+
// Ephemeral completion resolves its caller before this finally runs, so
|
|
7612
|
+
// runEphemeralToCompletion may already have deregistered the name. The
|
|
7613
|
+
// terminal frame still owns its typing/outgoing completion unless it was
|
|
7614
|
+
// genuinely superseded or handed EventGate liveness to a budget restart.
|
|
7615
|
+
const disposedEphemeralWithoutSuccessor =
|
|
7616
|
+
generationLost &&
|
|
7617
|
+
this.disposedEphemeralFrames.has(agent) &&
|
|
7618
|
+
!this.agents.has(agent.name);
|
|
7619
|
+
const frameReachedTerminal =
|
|
7620
|
+
(!generationLost || disposedEphemeralWithoutSuccessor) &&
|
|
7621
|
+
!preserveEventGateForSuccessor;
|
|
7003
7622
|
// §10.5: exactly one terminal per `started`, on every exit path the
|
|
7004
7623
|
// host controls. Which one was decided by the path taken (default
|
|
7005
7624
|
// completed; catch → failed; framework-cancel → aborted). A host
|
|
@@ -7018,30 +7637,36 @@ export class AgentFramework {
|
|
|
7018
7637
|
// all incoming events → the agent silently stops waking on messages
|
|
7019
7638
|
// (typing still stops, compression still runs — matching the observed
|
|
7020
7639
|
// wedge). onInferenceEnded is idempotent, so a redundant call is safe.
|
|
7021
|
-
|
|
7022
|
-
|
|
7640
|
+
if (ownsPhysicalStream && !preserveEventGateForSuccessor) {
|
|
7641
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
7642
|
+
}
|
|
7643
|
+
if (!generationLost && ownsPhysicalStream) {
|
|
7644
|
+
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
|
|
7645
|
+
}
|
|
7023
7646
|
if (ownsProviderGate) this.releasePrimaryProviderGate(agent.name);
|
|
7024
7647
|
|
|
7025
7648
|
// Stop the typing indicator on every exit path (complete, error,
|
|
7026
7649
|
// exhausted, abort) so it never sticks after the turn ends.
|
|
7027
|
-
this.channelRegistry?.stopTyping();
|
|
7650
|
+
if (frameReachedTerminal) this.channelRegistry?.stopTyping();
|
|
7028
7651
|
|
|
7029
7652
|
// Spec 14.3: flush any held line-start text, then close each streamed
|
|
7030
7653
|
// channel with its final moderated content — the consumer's signal to
|
|
7031
7654
|
// finalize (end the TTS utterance, settle the rendered message).
|
|
7032
|
-
if (proseStream) {
|
|
7655
|
+
if (frameReachedTerminal && proseStream) {
|
|
7033
7656
|
emitOutgoing(proseStream.finish());
|
|
7034
7657
|
for (const [channelId, text] of proseStream.byChannel()) {
|
|
7035
7658
|
this.channelRegistry!.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text);
|
|
7036
7659
|
}
|
|
7037
7660
|
}
|
|
7038
7661
|
this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
|
|
7039
|
-
|
|
7040
|
-
|
|
7662
|
+
if (ownsPhysicalStream) {
|
|
7663
|
+
this.activeStreams.delete(agent.name);
|
|
7664
|
+
this.pendingAssistantBlocks.delete(agent.name);
|
|
7665
|
+
}
|
|
7041
7666
|
|
|
7042
7667
|
// A conversation fork whose TTL closure turn just finished is done for
|
|
7043
7668
|
// good — dispose it so the agent map doesn't grow monotonically.
|
|
7044
|
-
if (this.closingConversationAgents.has(agent.name)) {
|
|
7669
|
+
if (ownsPhysicalStream && this.closingConversationAgents.has(agent.name)) {
|
|
7045
7670
|
this.disposeConversationAgent(agent.name);
|
|
7046
7671
|
}
|
|
7047
7672
|
|
|
@@ -7054,13 +7679,18 @@ export class AgentFramework {
|
|
|
7054
7679
|
// injection / teardown delivers the messages at a correct position.
|
|
7055
7680
|
if (myTurnToken !== undefined && this.activeTurnTokens.get(agent.name) === myTurnToken) {
|
|
7056
7681
|
this.activeTurnTokens.delete(agent.name);
|
|
7682
|
+
this.activeTurnTriggers.delete(agent.name);
|
|
7057
7683
|
}
|
|
7058
7684
|
|
|
7059
|
-
// Flush any deferred messages (e.g. if stream failed while tools were
|
|
7060
|
-
|
|
7061
|
-
|
|
7685
|
+
// Flush any deferred messages (e.g. if stream failed while tools were
|
|
7686
|
+
// pending). Only THIS agent's messages: other targets' entries wait
|
|
7687
|
+
// for their own boundaries (re-adding via addMessage re-defers if the
|
|
7688
|
+
// target has meanwhile started a turn).
|
|
7689
|
+
if (frameReachedTerminal && this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
|
|
7690
|
+
const deferred = this.drainDeferredFor(agent.name);
|
|
7062
7691
|
for (const msg of deferred) {
|
|
7063
|
-
this.addMessage(msg.participant, msg.content, msg.metadata
|
|
7692
|
+
this.addMessage(msg.participant, msg.content, msg.metadata,
|
|
7693
|
+
msg.forAgent ? { forAgent: msg.forAgent } : undefined);
|
|
7064
7694
|
}
|
|
7065
7695
|
}
|
|
7066
7696
|
}
|
|
@@ -7796,6 +8426,21 @@ export class AgentFramework {
|
|
|
7796
8426
|
return `Error: ${result.error ?? 'tool call failed'}`;
|
|
7797
8427
|
}
|
|
7798
8428
|
if (result.data === undefined) return '';
|
|
8429
|
+
// RFC-005 "eagerly lazy": a script receiving a result is the strongest
|
|
8430
|
+
// available signal that the bytes are wanted (scripts process, they do
|
|
8431
|
+
// not browse) — materialize every referenced payload now, so the stub
|
|
8432
|
+
// the script receives carries a workspace path it can open directly.
|
|
8433
|
+
if (Array.isArray(result.data)) {
|
|
8434
|
+
const fetcher = this.getReferenceFetcher();
|
|
8435
|
+
for (const block of result.data) {
|
|
8436
|
+
const c = classifyBlock(block);
|
|
8437
|
+
if (c.kind !== 'reference') continue;
|
|
8438
|
+
const record = referenceRegistry.findByUri(c.testimony.uri)
|
|
8439
|
+
?? referenceRegistry.register(c.testimony);
|
|
8440
|
+
const outcome = await fetcher.fetch(record);
|
|
8441
|
+
if (!outcome.ok) console.error(`[rfc005] script-demand fetch of ${record.refId} failed: ${outcome.error}`);
|
|
8442
|
+
}
|
|
8443
|
+
}
|
|
7799
8444
|
// Scripts get (nearly) full data — the script environment IS the spill:
|
|
7800
8445
|
// filtering big results in code is the whole point. 5MB protocol safety
|
|
7801
8446
|
// cap only; no context-cap truncation, no file spill.
|
|
@@ -7915,6 +8560,11 @@ export class AgentFramework {
|
|
|
7915
8560
|
for (const raw of data) {
|
|
7916
8561
|
if (!raw || typeof raw !== 'object') return null;
|
|
7917
8562
|
const b = raw as { type?: unknown; text?: unknown; data?: unknown; mimeType?: unknown };
|
|
8563
|
+
if (isInlineContradiction(b)) {
|
|
8564
|
+
// RFC-005 vector 2: withhold inline data claiming bulk disposition.
|
|
8565
|
+
blocks.push({ type: 'text', text: INLINE_WITHHELD_TEXT });
|
|
8566
|
+
continue;
|
|
8567
|
+
}
|
|
7918
8568
|
if (b.type === 'text' && typeof b.text === 'string') {
|
|
7919
8569
|
let text = b.text;
|
|
7920
8570
|
if (maxChars && text.length > maxChars) {
|
|
@@ -7930,6 +8580,13 @@ export class AgentFramework {
|
|
|
7930
8580
|
type: 'image',
|
|
7931
8581
|
source: { type: 'base64', data: b.data, mediaType: b.mimeType },
|
|
7932
8582
|
});
|
|
8583
|
+
} else if (b.type === 'resource' || ((b.type === 'image' || b.type === 'audio') && typeof (b as { uri?: unknown }).uri === 'string')) {
|
|
8584
|
+
// RFC-005 reference block → bounded stub. Previously this bailed the
|
|
8585
|
+
// whole array to JSON, which both inlined the reference verbatim and
|
|
8586
|
+
// demoted any adjacent image block to stringified base64.
|
|
8587
|
+
const stub = referenceStubOrNull(b, 'from tool result');
|
|
8588
|
+
if (stub === null) return null;
|
|
8589
|
+
blocks.push({ type: 'text', text: stub });
|
|
7933
8590
|
} else {
|
|
7934
8591
|
return null; // unknown shape — bail to JSON path
|
|
7935
8592
|
}
|
|
@@ -8123,12 +8780,41 @@ export class AgentFramework {
|
|
|
8123
8780
|
return;
|
|
8124
8781
|
}
|
|
8125
8782
|
|
|
8783
|
+
if (enrichedCall.name === 'fetch_reference') {
|
|
8784
|
+
this.dispatchFetchReferenceToolCall(agentName, enrichedCall);
|
|
8785
|
+
return;
|
|
8786
|
+
}
|
|
8787
|
+
|
|
8126
8788
|
// Route gate_status tool
|
|
8127
8789
|
if (enrichedCall.name === 'gate_status' && this.eventGate) {
|
|
8128
8790
|
this.dispatchGateToolCall(agentName, enrichedCall);
|
|
8129
8791
|
return;
|
|
8130
8792
|
}
|
|
8131
8793
|
|
|
8794
|
+
// Route the tune-out tool (residents) and the subconscious's surface
|
|
8795
|
+
if (enrichedCall.name === 'tune_out' && this.tuneOutCoordinator) {
|
|
8796
|
+
this.dispatchTuneOutToolCall(agentName, enrichedCall);
|
|
8797
|
+
return;
|
|
8798
|
+
}
|
|
8799
|
+
if (
|
|
8800
|
+
this.tuneOutCoordinator &&
|
|
8801
|
+
agentName === this.subconsciousAgentName &&
|
|
8802
|
+
(SUBCONSCIOUS_TOOL_NAMES as readonly string[]).includes(enrichedCall.name)
|
|
8803
|
+
) {
|
|
8804
|
+
void this.tuneOutCoordinator
|
|
8805
|
+
.handleSubconsciousTool(enrichedCall.name, enrichedCall.input as Record<string, unknown>)
|
|
8806
|
+
.then((result) => {
|
|
8807
|
+
this.queue.push({
|
|
8808
|
+
type: 'tool-result',
|
|
8809
|
+
callId: enrichedCall.id,
|
|
8810
|
+
agentName,
|
|
8811
|
+
moduleName: 'tune-out',
|
|
8812
|
+
result,
|
|
8813
|
+
});
|
|
8814
|
+
});
|
|
8815
|
+
return;
|
|
8816
|
+
}
|
|
8817
|
+
|
|
8132
8818
|
// Route sleep / wake tools
|
|
8133
8819
|
if ((enrichedCall.name === 'sleep' || enrichedCall.name === 'wake') && this.eventGate) {
|
|
8134
8820
|
this.dispatchSleepToolCall(agentName, enrichedCall);
|
|
@@ -8273,13 +8959,32 @@ export class AgentFramework {
|
|
|
8273
8959
|
private addMessage(
|
|
8274
8960
|
participant: string,
|
|
8275
8961
|
content: ContentBlock[],
|
|
8276
|
-
metadata?: MessageMetadata
|
|
8962
|
+
metadata?: MessageMetadata,
|
|
8963
|
+
opts?: {
|
|
8964
|
+
/**
|
|
8965
|
+
* Deliver into this registry agent's window instead of the primary's,
|
|
8966
|
+
* with the deferral/turn-alive guard evaluated against THAT agent's
|
|
8967
|
+
* turn state. Default (absent) is byte-identical to the historical
|
|
8968
|
+
* primary-only behavior. Unknown names log and drop (loud, non-fatal:
|
|
8969
|
+
* this is reachable from timer callbacks that may outlive an agent).
|
|
8970
|
+
*/
|
|
8971
|
+
forAgent?: string;
|
|
8972
|
+
}
|
|
8277
8973
|
): MessageId {
|
|
8278
|
-
// Route to the
|
|
8279
|
-
const agent =
|
|
8280
|
-
? this.agents.get(
|
|
8281
|
-
: this.
|
|
8974
|
+
// Route to the named agent, else the primary (not ephemeral subagents).
|
|
8975
|
+
const agent = opts?.forAgent
|
|
8976
|
+
? this.agents.get(opts.forAgent)
|
|
8977
|
+
: this.primaryAgentName
|
|
8978
|
+
? this.agents.get(this.primaryAgentName)
|
|
8979
|
+
: this.agents.values().next().value;
|
|
8282
8980
|
if (!agent) {
|
|
8981
|
+
if (opts?.forAgent) {
|
|
8982
|
+
console.error(
|
|
8983
|
+
`[addMessage] delivery target "${opts.forAgent}" is not a registered agent — message dropped ` +
|
|
8984
|
+
`(participant=${participant})`,
|
|
8985
|
+
);
|
|
8986
|
+
return '' as MessageId;
|
|
8987
|
+
}
|
|
8283
8988
|
throw new Error('No agents configured');
|
|
8284
8989
|
}
|
|
8285
8990
|
|
|
@@ -8300,19 +9005,48 @@ export class AgentFramework {
|
|
|
8300
9005
|
// ALSO injected into the live stream — hear-while-acting), or in
|
|
8301
9006
|
// driveStream's finally when the turn ends.
|
|
8302
9007
|
const hasToolResult = content.some(b => b.type === 'tool_result');
|
|
9008
|
+
// Explicitly-targeted deliveries scope the tool-cycle check to the
|
|
9009
|
+
// target (pendingAssistantBlocks is keyed by agent); the default path
|
|
9010
|
+
// keeps the historical GLOBAL check byte-for-byte (conservative: any
|
|
9011
|
+
// agent's pending cycle defers primary-bound messages).
|
|
9012
|
+
const midToolCycle = opts?.forAgent
|
|
9013
|
+
? this.pendingAssistantBlocks.has(agent.name)
|
|
9014
|
+
: this.pendingAssistantBlocks.size > 0;
|
|
8303
9015
|
if (
|
|
8304
9016
|
!hasToolResult &&
|
|
8305
|
-
(
|
|
9017
|
+
(midToolCycle ||
|
|
8306
9018
|
this.activeTurnTokens.has(agent.name) ||
|
|
8307
9019
|
this.activeStreams.has(agent.name))
|
|
8308
9020
|
) {
|
|
8309
|
-
this.deferredMessages.push({ participant, content, metadata });
|
|
8310
|
-
return '' as MessageId; // Deferred — flushed at the next boundary
|
|
9021
|
+
this.deferredMessages.push({ participant, content, metadata, forAgent: opts?.forAgent });
|
|
9022
|
+
return '' as MessageId; // Deferred — flushed at the target's next boundary
|
|
8311
9023
|
}
|
|
8312
9024
|
|
|
8313
9025
|
return agent.getContextManager().addMessage(participant, content, metadata);
|
|
8314
9026
|
}
|
|
8315
9027
|
|
|
9028
|
+
/**
|
|
9029
|
+
* Splice out the deferred messages addressed to `agentName` (explicitly
|
|
9030
|
+
* via forAgent, or implicitly when it is the primary), leaving other
|
|
9031
|
+
* targets' messages queued for their own boundaries.
|
|
9032
|
+
*/
|
|
9033
|
+
private drainDeferredFor(agentName: string): Array<{
|
|
9034
|
+
participant: string;
|
|
9035
|
+
content: ContentBlock[];
|
|
9036
|
+
metadata?: MessageMetadata;
|
|
9037
|
+
forAgent?: string;
|
|
9038
|
+
}> {
|
|
9039
|
+
if (this.deferredMessages.length === 0) return [];
|
|
9040
|
+
const mine: typeof this.deferredMessages = [];
|
|
9041
|
+
const rest: typeof this.deferredMessages = [];
|
|
9042
|
+
for (const msg of this.deferredMessages) {
|
|
9043
|
+
const target = msg.forAgent ?? this.primaryAgentName;
|
|
9044
|
+
(target === agentName ? mine : rest).push(msg);
|
|
9045
|
+
}
|
|
9046
|
+
this.deferredMessages = rest;
|
|
9047
|
+
return mine;
|
|
9048
|
+
}
|
|
9049
|
+
|
|
8316
9050
|
private editMessage(id: MessageId, content: ContentBlock[]): void {
|
|
8317
9051
|
const agent = this.primaryAgentName
|
|
8318
9052
|
? this.agents.get(this.primaryAgentName)
|
|
@@ -10329,6 +11063,13 @@ export class AgentFramework {
|
|
|
10329
11063
|
}
|
|
10330
11064
|
|
|
10331
11065
|
server.sendToolsCall(toolName, args, stateParams)
|
|
11066
|
+
.then(async (result) => {
|
|
11067
|
+
// RFC-005: register references and eagerly fetch the small ones
|
|
11068
|
+
// before serialization, so their stubs carry a saved path.
|
|
11069
|
+
await this.autoFetchReferences(result.content, serverId, true)
|
|
11070
|
+
.catch((e) => console.error('[rfc005] autofetch error:', (e as Error).message));
|
|
11071
|
+
return result;
|
|
11072
|
+
})
|
|
10332
11073
|
.then((result) => {
|
|
10333
11074
|
const durationMs = Date.now() - startTime;
|
|
10334
11075
|
this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, durationMs });
|
|
@@ -10696,6 +11437,53 @@ export class AgentFramework {
|
|
|
10696
11437
|
* suppression window, optionally announces in the sticky channel, and ends
|
|
10697
11438
|
* the turn (the agent goes idle immediately). `wake` clears sleep.
|
|
10698
11439
|
*/
|
|
11440
|
+
/** tune_out tool (residents): enter or cancel a channel tune-out. */
|
|
11441
|
+
private dispatchTuneOutToolCall(agentName: string, call: ToolCall): void {
|
|
11442
|
+
const coordinator = this.tuneOutCoordinator!;
|
|
11443
|
+
const input = (call.input ?? {}) as {
|
|
11444
|
+
channelId?: string; mode?: string;
|
|
11445
|
+
cadenceSeconds?: number; backlogCap?: number; maxWakes?: number;
|
|
11446
|
+
durationSeconds?: number;
|
|
11447
|
+
};
|
|
11448
|
+
const channelId = String(input.channelId ?? '');
|
|
11449
|
+
const entry = this.channelRegistry?.listChannelsRaw()
|
|
11450
|
+
.find((e) => e.descriptor.id === channelId);
|
|
11451
|
+
let result: { success: boolean; data?: unknown; error?: string; isError?: boolean };
|
|
11452
|
+
if (!entry) {
|
|
11453
|
+
result = { success: false, error: `unknown channel: ${channelId}`, isError: false };
|
|
11454
|
+
} else if (input.mode === 'cancel') {
|
|
11455
|
+
const r = coordinator.cancel(entry.serverId, channelId, 'agent-tool', 'agent request');
|
|
11456
|
+
result = r.ok
|
|
11457
|
+
? { success: true, data: { cancelled: channelId } }
|
|
11458
|
+
: { success: false, error: r.error, isError: false };
|
|
11459
|
+
} else {
|
|
11460
|
+
const r = coordinator.enter(entry.serverId, channelId, {
|
|
11461
|
+
cadenceSeconds: input.cadenceSeconds,
|
|
11462
|
+
backlogCap: input.backlogCap,
|
|
11463
|
+
maxWakes: input.maxWakes,
|
|
11464
|
+
durationSeconds: input.durationSeconds,
|
|
11465
|
+
}, 'agent-tool');
|
|
11466
|
+
result = r.ok
|
|
11467
|
+
? {
|
|
11468
|
+
success: true,
|
|
11469
|
+
data: {
|
|
11470
|
+
tunedOut: channelId,
|
|
11471
|
+
cadenceSeconds: r.params.cadenceSeconds,
|
|
11472
|
+
backlogCap: r.params.backlogCap,
|
|
11473
|
+
maxWakes: r.params.maxWakes,
|
|
11474
|
+
},
|
|
11475
|
+
}
|
|
11476
|
+
: { success: false, error: r.error, isError: false };
|
|
11477
|
+
}
|
|
11478
|
+
this.queue.push({
|
|
11479
|
+
type: 'tool-result',
|
|
11480
|
+
callId: call.id,
|
|
11481
|
+
agentName,
|
|
11482
|
+
moduleName: 'tune-out',
|
|
11483
|
+
result,
|
|
11484
|
+
});
|
|
11485
|
+
}
|
|
11486
|
+
|
|
10699
11487
|
private dispatchSleepToolCall(agentName: string, call: ToolCall): void {
|
|
10700
11488
|
this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
|
|
10701
11489
|
const gate = this.eventGate!;
|
|
@@ -10774,6 +11562,43 @@ export class AgentFramework {
|
|
|
10774
11562
|
* blocks) delivers the actual image to the model; history storage keeps
|
|
10775
11563
|
* the standard compact `[image: …]` placeholder.
|
|
10776
11564
|
*/
|
|
11565
|
+
/** RFC-005 on-demand dereference: [ref id] → workspace file, via the
|
|
11566
|
+
* origin-bound, ceiling-bounded, digest-verified host fetcher. */
|
|
11567
|
+
private dispatchFetchReferenceToolCall(agentName: string, call: ToolCall): void {
|
|
11568
|
+
this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
|
|
11569
|
+
const startTime = Date.now();
|
|
11570
|
+
void (async () => {
|
|
11571
|
+
const args = (call.input ?? {}) as Record<string, unknown>;
|
|
11572
|
+
const refId = String(args.ref_id ?? '');
|
|
11573
|
+
const record = referenceRegistry.get(refId);
|
|
11574
|
+
let result: { success: boolean; data?: string; error?: string; isError?: boolean };
|
|
11575
|
+
if (!record) {
|
|
11576
|
+
// Vector 20: a stale or unknown id is a defined miss, never a
|
|
11577
|
+
// different record.
|
|
11578
|
+
result = { success: false, isError: true,
|
|
11579
|
+
error: `unknown reference: ${refId || '(missing ref_id)'} — it may have been evicted; ids are never reused` };
|
|
11580
|
+
} else {
|
|
11581
|
+
const maxBytes = typeof args.max_bytes === 'number'
|
|
11582
|
+
? Math.min(args.max_bytes, DEFAULT_FETCH_MAX_BYTES) : undefined;
|
|
11583
|
+
const outcome = await this.getReferenceFetcher().fetch(record, { maxBytes });
|
|
11584
|
+
result = outcome.ok
|
|
11585
|
+
? { success: true, data: JSON.stringify({ path: outcome.path, bytes: outcome.bytes,
|
|
11586
|
+
mime_type: outcome.mimeType, digest_verified: outcome.digestVerified ?? 'no digest claimed' }) }
|
|
11587
|
+
: { success: false, isError: true, error: outcome.error };
|
|
11588
|
+
}
|
|
11589
|
+
this.emitTrace({
|
|
11590
|
+
type: result.isError ? 'tool:failed' : 'tool:completed',
|
|
11591
|
+
module: 'workspace', tool: call.name, callId: call.id,
|
|
11592
|
+
durationMs: Date.now() - startTime,
|
|
11593
|
+
...(result.isError ? { error: result.error } : {}),
|
|
11594
|
+
});
|
|
11595
|
+
this.pushEvent({
|
|
11596
|
+
type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace',
|
|
11597
|
+
result: { ...result, isError: result.isError ?? false },
|
|
11598
|
+
});
|
|
11599
|
+
})();
|
|
11600
|
+
}
|
|
11601
|
+
|
|
10777
11602
|
private dispatchReadImageToolCall(agentName: string, call: ToolCall): void {
|
|
10778
11603
|
this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
|
|
10779
11604
|
const finish = (result: ToolResult): void => {
|