@animalabs/agent-framework 0.12.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 +2 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +3 -0
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +45 -0
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +606 -102
- 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/mcpl/channel-registry.d.ts +84 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +160 -4
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +5 -1
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/tool-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 +7 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +6 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/test/channel-incoming-targeting.test.d.ts +2 -0
- package/dist/test/channel-incoming-targeting.test.d.ts.map +1 -0
- package/dist/test/channel-incoming-targeting.test.js +81 -0
- package/dist/test/channel-incoming-targeting.test.js.map +1 -0
- package/dist/test/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/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/workspace-read-image.test.js +8 -0
- package/dist/test/workspace-read-image.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +3 -0
- package/src/framework.ts +662 -100
- package/src/gate/event-gate.ts +28 -3
- package/src/mcpl/channel-registry.ts +235 -7
- package/src/modules/workspace/index.ts +5 -1
- 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 +8 -0
- package/src/types/framework.ts +7 -0
package/src/framework.ts
CHANGED
|
@@ -5,8 +5,10 @@ import { appendFileSync, mkdirSync } from 'node:fs';
|
|
|
5
5
|
import { JsStore } from '@animalabs/chronicle';
|
|
6
6
|
import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane';
|
|
7
7
|
import { MembraneError } from '@animalabs/membrane';
|
|
8
|
-
import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager';
|
|
8
|
+
import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy } from '@animalabs/context-manager';
|
|
9
9
|
import type { CacheWireReceipt } from './kv-unified-wire.js';
|
|
10
|
+
import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES, type SubconsciousConfig } from './tune-out/tools.js';
|
|
11
|
+
import { TuneOutCoordinator, TUNE_OUT_DEFAULTS } from './tune-out/coordinator.js';
|
|
10
12
|
import type {
|
|
11
13
|
MessageId,
|
|
12
14
|
MessageMetadata,
|
|
@@ -67,6 +69,7 @@ import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
|
|
|
67
69
|
import { PushHandler, type McplPushEvent } from './mcpl/push-handler.js';
|
|
68
70
|
import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
|
|
69
71
|
import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
|
|
72
|
+
import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js';
|
|
70
73
|
import { InferenceRouter } from './mcpl/inference-router.js';
|
|
71
74
|
import { ChannelRegistry, type ChannelToolOrigin } from './mcpl/channel-registry.js';
|
|
72
75
|
import { ConversationRouter } from './mcpl/conversation-router.js';
|
|
@@ -788,6 +791,16 @@ export class AgentFramework {
|
|
|
788
791
|
private frameworkCancelledStreams: Map<string, 'turn_ended' | 'budget_restart'> = new Map();
|
|
789
792
|
/** Active runEphemeralToCompletion runs, keyed by agent name. */
|
|
790
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();
|
|
791
804
|
/** Per-agent count of consecutive exhausted inferences (reset on any success).
|
|
792
805
|
* Drives hard-down escalation — see noteInferenceExhausted. */
|
|
793
806
|
private consecutiveInferenceFailures: Map<string, number> = new Map();
|
|
@@ -841,7 +854,23 @@ export class AgentFramework {
|
|
|
841
854
|
|
|
842
855
|
// Messages deferred while an agent is waiting_for_tools (to preserve
|
|
843
856
|
// tool_use → tool_result adjacency required by the Anthropic API).
|
|
844
|
-
|
|
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
|
+
}> = [];
|
|
845
874
|
|
|
846
875
|
// Turn-alive markers (2026-07-31 Mythos phantom-skip incident): agentName →
|
|
847
876
|
// token of the turn currently in progress. Set at startAgentStream ENTRY —
|
|
@@ -873,6 +902,10 @@ export class AgentFramework {
|
|
|
873
902
|
getActiveTurnTrigger(agentName: string): InferenceRequest | undefined {
|
|
874
903
|
return this.activeTurnTriggers.get(agentName);
|
|
875
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();
|
|
876
909
|
private nextTurnToken = 1;
|
|
877
910
|
|
|
878
911
|
// Undo/redo state
|
|
@@ -1131,6 +1164,13 @@ export class AgentFramework {
|
|
|
1131
1164
|
await framework.createAgent(agentConfig);
|
|
1132
1165
|
}
|
|
1133
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
|
+
|
|
1134
1174
|
// Finish any branch-local suppression interrupted after Chronicle switched
|
|
1135
1175
|
// branches. This runs before modules, MCPL connections, or inbound traffic.
|
|
1136
1176
|
await framework.resumePreparedDiscordSuppressions();
|
|
@@ -1180,11 +1220,12 @@ export class AgentFramework {
|
|
|
1180
1220
|
initialConfig: config.gate.config,
|
|
1181
1221
|
privilegedUsersPath: config.gate.privilegedUsersPath,
|
|
1182
1222
|
emitTrace: (e) => framework.emitTrace(e as { type: TraceEvent['type']; [key: string]: unknown }),
|
|
1183
|
-
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),
|
|
1184
1224
|
requestInference: (agentName, reason, source) => {
|
|
1185
1225
|
framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
|
|
1186
1226
|
},
|
|
1187
|
-
getAgentNames: () => [...framework.agents.keys()]
|
|
1227
|
+
getAgentNames: () => [...framework.agents.keys()].filter(
|
|
1228
|
+
(n) => n !== framework.subconsciousAgentName),
|
|
1188
1229
|
});
|
|
1189
1230
|
}
|
|
1190
1231
|
|
|
@@ -1247,6 +1288,40 @@ export class AgentFramework {
|
|
|
1247
1288
|
await framework.initializeMcpl(config.mcplServers, config.inferenceRouting);
|
|
1248
1289
|
}
|
|
1249
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
|
+
|
|
1250
1325
|
// Diagnostics: `kill -USR2 <pid>` dumps live wake/inference state to stderr
|
|
1251
1326
|
// (journal) without a restart — for catching the wake-wedge on the running
|
|
1252
1327
|
// process. Shows the gate's `inferring` set + buffered-event count (the
|
|
@@ -1316,6 +1391,7 @@ export class AgentFramework {
|
|
|
1316
1391
|
this.running = false;
|
|
1317
1392
|
this.providerAdmissionClosed = true;
|
|
1318
1393
|
this.queue.close();
|
|
1394
|
+
this.tuneOutCoordinator?.stop();
|
|
1319
1395
|
|
|
1320
1396
|
// Kill running code_execution scripts before cancelling streams: a
|
|
1321
1397
|
// zombie script must not keep firing side-effectful tool calls into a
|
|
@@ -1751,7 +1827,12 @@ export class AgentFramework {
|
|
|
1751
1827
|
*/
|
|
1752
1828
|
getAllTools(): import('./types/index.js').ToolDefinition[] {
|
|
1753
1829
|
const moduleTools = this.moduleRegistry.getAllTools();
|
|
1754
|
-
|
|
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
|
+
}
|
|
1755
1836
|
const gateTools = this.eventGate
|
|
1756
1837
|
? [
|
|
1757
1838
|
this.eventGate.getToolDefinition(),
|
|
@@ -2001,6 +2082,20 @@ export class AgentFramework {
|
|
|
2001
2082
|
agentName: string,
|
|
2002
2083
|
snapshot?: InferenceToolSnapshot,
|
|
2003
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
|
+
}
|
|
2004
2099
|
return this.getAllTools().map((tool) => {
|
|
2005
2100
|
if (tool.name === 'think') {
|
|
2006
2101
|
return this.buildThinkTool(
|
|
@@ -2421,25 +2516,38 @@ export class AgentFramework {
|
|
|
2421
2516
|
contextManager: ContextManager;
|
|
2422
2517
|
cleanup: () => void;
|
|
2423
2518
|
}> {
|
|
2424
|
-
|
|
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}`;
|
|
2425
2528
|
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
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
|
+
});
|
|
2434
2537
|
|
|
2435
|
-
|
|
2538
|
+
const agent = new Agent(config, contextManager, this.membrane);
|
|
2539
|
+
this.ephemeralCandidates.set(agent, contextManager);
|
|
2436
2540
|
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
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
|
+
};
|
|
2441
2545
|
|
|
2442
|
-
|
|
2546
|
+
return { agent, contextManager, cleanup };
|
|
2547
|
+
} catch (error) {
|
|
2548
|
+
this.usedEphemeralAgentNames.delete(config.name);
|
|
2549
|
+
throw error;
|
|
2550
|
+
}
|
|
2443
2551
|
}
|
|
2444
2552
|
|
|
2445
2553
|
private createDeferred<T>(): Deferred<T> {
|
|
@@ -2462,6 +2570,17 @@ export class AgentFramework {
|
|
|
2462
2570
|
run.lastActivity = Date.now();
|
|
2463
2571
|
}
|
|
2464
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
|
+
|
|
2465
2584
|
private recordEphemeralToolCalls(agentName: string, count: number): void {
|
|
2466
2585
|
const run = this.ephemeralRuns.get(agentName);
|
|
2467
2586
|
if (!run) return;
|
|
@@ -2505,7 +2624,18 @@ export class AgentFramework {
|
|
|
2505
2624
|
contextManager: ContextManager,
|
|
2506
2625
|
watchdogs?: { startupTimeoutMs?: number; idleTimeoutMs?: number; idlePollMs?: number },
|
|
2507
2626
|
): Promise<{ speech: string; toolCallsCount: number }> {
|
|
2508
|
-
//
|
|
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
|
+
}
|
|
2509
2639
|
this.agents.set(agent.name, agent);
|
|
2510
2640
|
const run: EphemeralRun = {
|
|
2511
2641
|
settle: this.createDeferred<AgentSettleResult>(),
|
|
@@ -2570,8 +2700,25 @@ export class AgentFramework {
|
|
|
2570
2700
|
} finally {
|
|
2571
2701
|
if (startupWatchdog) clearTimeout(startupWatchdog);
|
|
2572
2702
|
if (completionWatchdog) clearInterval(completionWatchdog);
|
|
2573
|
-
|
|
2574
|
-
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
|
+
}
|
|
2575
2722
|
// Spawn-and-dispose bookkeeping (main, d453165/fee96a7): without this,
|
|
2576
2723
|
// ephemeral agents leave checkpoint-tree keys and diagnostics map
|
|
2577
2724
|
// entries behind for the life of the store/session.
|
|
@@ -2583,6 +2730,7 @@ export class AgentFramework {
|
|
|
2583
2730
|
// of the map, and a late driveStream finally token-match no-ops.
|
|
2584
2731
|
this.activeTurnTokens.delete(agent.name);
|
|
2585
2732
|
this.activeTurnTriggers.delete(agent.name);
|
|
2733
|
+
this.logicalTurnToolCalls.delete(agent);
|
|
2586
2734
|
}
|
|
2587
2735
|
}
|
|
2588
2736
|
|
|
@@ -2597,6 +2745,48 @@ export class AgentFramework {
|
|
|
2597
2745
|
* Query inference logs.
|
|
2598
2746
|
* Returns entries with summary info (doesn't resolve blobs).
|
|
2599
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
|
+
|
|
2600
2790
|
/** Synthesized sleep/wake tool definitions (present when a gate is wired). */
|
|
2601
2791
|
private static readonly SLEEP_TOOLS: import('./types/index.js').ToolDefinition[] = [
|
|
2602
2792
|
{
|
|
@@ -4040,6 +4230,15 @@ export class AgentFramework {
|
|
|
4040
4230
|
// streaming agent keeps the original Membrane.
|
|
4041
4231
|
membrane: this.auxiliaryMembraneFor(config.name),
|
|
4042
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,
|
|
4043
4242
|
});
|
|
4044
4243
|
|
|
4045
4244
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
@@ -4060,6 +4259,71 @@ export class AgentFramework {
|
|
|
4060
4259
|
return agent;
|
|
4061
4260
|
}
|
|
4062
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
|
+
|
|
4063
4327
|
private async runLoop(): Promise<void> {
|
|
4064
4328
|
while (this.running) {
|
|
4065
4329
|
try {
|
|
@@ -4198,12 +4462,11 @@ export class AgentFramework {
|
|
|
4198
4462
|
// queued, they flush at the target's own next boundary — with
|
|
4199
4463
|
// injection — or in driveStream's finally when its turn ends.
|
|
4200
4464
|
const midTurnInjections: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata }> = [];
|
|
4201
|
-
|
|
4202
|
-
const
|
|
4203
|
-
if (
|
|
4204
|
-
const deferred = this.deferredMessages.splice(0);
|
|
4465
|
+
{
|
|
4466
|
+
const deferred = this.drainDeferredFor(agent.name);
|
|
4467
|
+
if (deferred.length > 0) {
|
|
4205
4468
|
for (const msg of deferred) {
|
|
4206
|
-
|
|
4469
|
+
agent.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
|
|
4207
4470
|
// Injection guards: tool blocks would corrupt the tool-cycle
|
|
4208
4471
|
// structure the membrane enforces, and a message named as the
|
|
4209
4472
|
// agent itself would render as an ASSISTANT turn on the wire
|
|
@@ -4538,7 +4801,8 @@ export class AgentFramework {
|
|
|
4538
4801
|
// their own channel's messages, not by framework-wide events.
|
|
4539
4802
|
const targetAgents =
|
|
4540
4803
|
response.requestInference === true
|
|
4541
|
-
? Array.from(this.agents.keys()).filter(
|
|
4804
|
+
? Array.from(this.agents.keys()).filter(
|
|
4805
|
+
(n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName)
|
|
4542
4806
|
: response.requestInference;
|
|
4543
4807
|
|
|
4544
4808
|
for (const agentName of targetAgents) {
|
|
@@ -4575,6 +4839,7 @@ export class AgentFramework {
|
|
|
4575
4839
|
metadata?: Record<string, unknown>;
|
|
4576
4840
|
tags?: string[];
|
|
4577
4841
|
triggerInference?: boolean;
|
|
4842
|
+
targetAgents?: string[];
|
|
4578
4843
|
}): Promise<void> {
|
|
4579
4844
|
const metadata: Record<string, unknown> = {
|
|
4580
4845
|
...event.metadata,
|
|
@@ -4621,12 +4886,38 @@ export class AgentFramework {
|
|
|
4621
4886
|
}
|
|
4622
4887
|
}
|
|
4623
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
|
+
|
|
4624
4907
|
const id = this.addMessage('user', incomingContent, metadata);
|
|
4625
4908
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
|
|
4626
4909
|
|
|
4627
|
-
if (event.triggerInference) {
|
|
4910
|
+
if (event.triggerInference && !divert) {
|
|
4628
4911
|
const addressed = isAddressedMessage(event.tags, event.metadata);
|
|
4629
|
-
|
|
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;
|
|
4630
4921
|
this.pendingRequests.push({
|
|
4631
4922
|
agentName,
|
|
4632
4923
|
reason: 'mcpl:channel-incoming',
|
|
@@ -4757,41 +5048,50 @@ export class AgentFramework {
|
|
|
4757
5048
|
if (!template || !templateConfig) {
|
|
4758
5049
|
throw new Error(`conversation template agent "${router.templateAgent}" not found`);
|
|
4759
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 {
|
|
4760
5056
|
|
|
4761
|
-
|
|
4762
|
-
|
|
4763
|
-
|
|
4764
|
-
|
|
4765
|
-
|
|
4766
|
-
|
|
4767
|
-
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
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
|
+
});
|
|
4773
5069
|
|
|
4774
|
-
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
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
|
+
}
|
|
4786
5083
|
}
|
|
4787
|
-
}
|
|
4788
5084
|
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
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
|
+
}
|
|
4795
5095
|
}
|
|
4796
5096
|
|
|
4797
5097
|
/** Persist the router's generation counters (see hydration in create()). */
|
|
@@ -4815,10 +5115,12 @@ export class AgentFramework {
|
|
|
4815
5115
|
private disposeConversationAgent(agentName: string): void {
|
|
4816
5116
|
this.closingConversationAgents.delete(agentName);
|
|
4817
5117
|
const channelId = this.conversationAgentHomes.get(agentName);
|
|
5118
|
+
const agent = this.agents.get(agentName);
|
|
4818
5119
|
this.agents.delete(agentName);
|
|
4819
5120
|
this.agentConfigs.delete(agentName);
|
|
4820
5121
|
this.conversationAgentHomes.delete(agentName);
|
|
4821
5122
|
this.evictTurnCheckpoints(agentName);
|
|
5123
|
+
if (agent) this.logicalTurnToolCalls.delete(agent);
|
|
4822
5124
|
this.emitTrace({
|
|
4823
5125
|
type: 'mcpl:conversation-disposed',
|
|
4824
5126
|
agentName,
|
|
@@ -5054,7 +5356,8 @@ export class AgentFramework {
|
|
|
5054
5356
|
if (event.triggerInference) {
|
|
5055
5357
|
// Default broadcast excludes conversation forks (channel-driven).
|
|
5056
5358
|
const targetAgents = event.targetAgents
|
|
5057
|
-
?? [...this.agents.keys()].filter(
|
|
5359
|
+
?? [...this.agents.keys()].filter(
|
|
5360
|
+
(n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
|
|
5058
5361
|
for (const agentName of targetAgents) {
|
|
5059
5362
|
this.pendingRequests.push({
|
|
5060
5363
|
agentName,
|
|
@@ -5255,6 +5558,9 @@ export class AgentFramework {
|
|
|
5255
5558
|
`[inference-dropped] agent=${agentName} reason=policy-skip ` +
|
|
5256
5559
|
`requests=${requests.length} triggers=${requests.map((r) => r.reason).join(',')}`,
|
|
5257
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);
|
|
5258
5564
|
const gate = this.providerGates.get(agentName);
|
|
5259
5565
|
if (gate?.primaryPending && this.providerAccelerationRecoveries.has(agentName)) {
|
|
5260
5566
|
gate.primaryPending = false;
|
|
@@ -5846,9 +6152,8 @@ export class AgentFramework {
|
|
|
5846
6152
|
trigger?.reason !== 'context_budget_restart' &&
|
|
5847
6153
|
this.deferredMessages.length > 0
|
|
5848
6154
|
) {
|
|
5849
|
-
|
|
5850
|
-
|
|
5851
|
-
const deferred = this.deferredMessages.splice(0);
|
|
6155
|
+
{
|
|
6156
|
+
const deferred = this.drainDeferredFor(agent.name);
|
|
5852
6157
|
for (const msg of deferred) {
|
|
5853
6158
|
// Per-message try/catch (mirrors announceLocusIfChanged): one
|
|
5854
6159
|
// poison message — or a transient store-write failure — must not
|
|
@@ -5906,8 +6211,21 @@ export class AgentFramework {
|
|
|
5906
6211
|
// BEFORE this turn compiles, so the agent always knows where its voice
|
|
5907
6212
|
// goes (announce-on-change only — no per-turn chatter, append-only for
|
|
5908
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
|
+
|
|
5909
6219
|
if (trigger?.reason !== 'context_budget_restart') {
|
|
5910
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
|
+
}
|
|
5911
6229
|
// Fresh turn: forget the previous turn's explicit-send engagements and
|
|
5912
6230
|
// prose deliveries — both are strictly turn-scoped (a restart continues
|
|
5913
6231
|
// the same logical turn and keeps them).
|
|
@@ -5941,7 +6259,11 @@ export class AgentFramework {
|
|
|
5941
6259
|
// context-budget restart, which skips the re-pin but re-emits this).
|
|
5942
6260
|
channelId: this.turnLocusPins.get(agent.name),
|
|
5943
6261
|
});
|
|
5944
|
-
|
|
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
|
+
}
|
|
5945
6267
|
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
|
|
5946
6268
|
|
|
5947
6269
|
// Typing indicator, started at TURN START. It says "attending", which is
|
|
@@ -6004,12 +6326,38 @@ export class AgentFramework {
|
|
|
6004
6326
|
}
|
|
6005
6327
|
}
|
|
6006
6328
|
|
|
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
|
+
|
|
6007
6348
|
const {
|
|
6008
6349
|
stream,
|
|
6009
6350
|
request: compiledRequest,
|
|
6010
6351
|
takeKvSubmission,
|
|
6011
6352
|
drainKvSubmissionIds,
|
|
6012
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
|
+
}
|
|
6013
6361
|
|
|
6014
6362
|
const handle = this.driveStream(
|
|
6015
6363
|
agent,
|
|
@@ -6021,6 +6369,7 @@ export class AgentFramework {
|
|
|
6021
6369
|
ownsProviderGate,
|
|
6022
6370
|
takeKvSubmission,
|
|
6023
6371
|
drainKvSubmissionIds,
|
|
6372
|
+
new Set(tools.map((tool) => tool.name)),
|
|
6024
6373
|
);
|
|
6025
6374
|
this.activeStreams.set(agent.name, handle);
|
|
6026
6375
|
// Handoff: driveStream captured the token in its synchronous prefix;
|
|
@@ -6105,6 +6454,7 @@ export class AgentFramework {
|
|
|
6105
6454
|
ownsProviderGate = false,
|
|
6106
6455
|
takeKvSubmission?: () => { submissionId: string; wireReceipt: CacheWireReceipt } | undefined,
|
|
6107
6456
|
drainKvSubmissionIds?: () => string[],
|
|
6457
|
+
knownToolNames: ReadonlySet<string> = new Set(),
|
|
6108
6458
|
): Promise<void> {
|
|
6109
6459
|
const startTime = Date.now();
|
|
6110
6460
|
const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -6124,6 +6474,7 @@ export class AgentFramework {
|
|
|
6124
6474
|
// means no successor turn can have replaced it while we compiled.
|
|
6125
6475
|
const myTurnToken = this.activeTurnTokens.get(agent.name);
|
|
6126
6476
|
let hadToolCalls = false;
|
|
6477
|
+
let generationLost = false;
|
|
6127
6478
|
|
|
6128
6479
|
// ---- Present-while-acting turn state ---------------------------------
|
|
6129
6480
|
// Output locus for the WHOLE logical turn: frozen in startAgentStream
|
|
@@ -6207,13 +6558,20 @@ export class AgentFramework {
|
|
|
6207
6558
|
// failed, a framework-cancelled stream sets aborted. Best-effort
|
|
6208
6559
|
// notifications — consumers dedupe by inferenceId and keep a timeout.
|
|
6209
6560
|
let lifecyclePhase: 'completed' | 'aborted' | 'failed' = 'completed';
|
|
6561
|
+
let preserveEventGateForSuccessor = false;
|
|
6210
6562
|
this.hookOrchestrator?.emitLifecycle({
|
|
6211
6563
|
inferenceId: outgoingInferenceId,
|
|
6212
6564
|
conversationId: agent.name,
|
|
6213
6565
|
turnIndex: 0,
|
|
6214
6566
|
phase: 'started',
|
|
6215
6567
|
});
|
|
6216
|
-
|
|
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
|
|
6217
6575
|
? new ProseStreamRouter({
|
|
6218
6576
|
mode: agent.proseRouting === 'explicit' ? 'explicit' : agent.proseRouting === 'hybrid' ? 'hybrid' : 'locus',
|
|
6219
6577
|
initialTarget: typingChannel,
|
|
@@ -6243,6 +6601,14 @@ export class AgentFramework {
|
|
|
6243
6601
|
|
|
6244
6602
|
try {
|
|
6245
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
|
+
}
|
|
6246
6612
|
this.touchEphemeralRun(agent.name, true);
|
|
6247
6613
|
switch (event.type) {
|
|
6248
6614
|
case 'tokens':
|
|
@@ -6310,6 +6676,7 @@ export class AgentFramework {
|
|
|
6310
6676
|
case 'tool-calls': {
|
|
6311
6677
|
adoptInjectedRound();
|
|
6312
6678
|
hadToolCalls = true;
|
|
6679
|
+
this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length);
|
|
6313
6680
|
this.recordEphemeralToolCalls(agent.name, event.calls.length);
|
|
6314
6681
|
this.emitTrace({
|
|
6315
6682
|
type: 'inference:tool_calls_yielded',
|
|
@@ -6515,7 +6882,30 @@ export class AgentFramework {
|
|
|
6515
6882
|
const terminalContent = lastToolIdx >= 0
|
|
6516
6883
|
? response.content.slice(lastToolIdx + 1)
|
|
6517
6884
|
: response.content;
|
|
6518
|
-
|
|
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) {
|
|
6519
6909
|
if (terminalContent.length > 0) {
|
|
6520
6910
|
agent.addAssistantResponse(terminalContent);
|
|
6521
6911
|
}
|
|
@@ -6541,7 +6931,9 @@ export class AgentFramework {
|
|
|
6541
6931
|
// text is speech.
|
|
6542
6932
|
const isTextBlock = (block: ContentBlock): block is ContentBlock & { type: 'text' } =>
|
|
6543
6933
|
block.type === 'text';
|
|
6544
|
-
const allText =
|
|
6934
|
+
const allText = guardedWrapperTool
|
|
6935
|
+
? []
|
|
6936
|
+
: response.content.filter(isTextBlock);
|
|
6545
6937
|
|
|
6546
6938
|
const speechContent = hadToolCalls ? [] : allText;
|
|
6547
6939
|
const thoughts = hadToolCalls ? allText : [];
|
|
@@ -6556,19 +6948,30 @@ export class AgentFramework {
|
|
|
6556
6948
|
}
|
|
6557
6949
|
: undefined;
|
|
6558
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
|
+
|
|
6559
6960
|
// Reset agent state before emitting inference:completed. Traces are
|
|
6560
6961
|
// observability-only, but external synchronous listeners should
|
|
6561
6962
|
// still see the terminal state at the terminal trace boundary.
|
|
6562
6963
|
// Speech dispatch happens after but doesn't depend on the status
|
|
6563
6964
|
// field.
|
|
6564
6965
|
agent.reset();
|
|
6565
|
-
this.eventGate?.onInferenceEnded(agent.name);
|
|
6966
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
|
|
6566
6967
|
this.settleAgent(agent.name, {
|
|
6567
6968
|
stopReason: 'completed',
|
|
6568
|
-
speech:
|
|
6569
|
-
|
|
6570
|
-
|
|
6571
|
-
|
|
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'),
|
|
6572
6975
|
});
|
|
6573
6976
|
|
|
6574
6977
|
this.emitTrace({
|
|
@@ -6942,7 +7345,7 @@ export class AgentFramework {
|
|
|
6942
7345
|
|
|
6943
7346
|
if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
|
|
6944
7347
|
lifecyclePhase = 'failed';
|
|
6945
|
-
this.eventGate?.onInferenceEnded(agent.name);
|
|
7348
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
|
|
6946
7349
|
break;
|
|
6947
7350
|
}
|
|
6948
7351
|
|
|
@@ -6966,7 +7369,7 @@ export class AgentFramework {
|
|
|
6966
7369
|
// itself) means retrying the same context can never succeed.
|
|
6967
7370
|
...this.classifyInferenceError(err),
|
|
6968
7371
|
});
|
|
6969
|
-
this.eventGate?.onInferenceEnded(agent.name);
|
|
7372
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
|
|
6970
7373
|
if (action.emit) {
|
|
6971
7374
|
this.pushEvent(action.emit);
|
|
6972
7375
|
}
|
|
@@ -6988,7 +7391,13 @@ export class AgentFramework {
|
|
|
6988
7391
|
if (cancelKind !== undefined) {
|
|
6989
7392
|
this.frameworkCancelledStreams.delete(cancelKey);
|
|
6990
7393
|
lifecyclePhase = 'aborted'; // §10.5 — terminal emitted in finally
|
|
6991
|
-
|
|
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
|
+
}
|
|
6992
7401
|
// endTurn IS a logical turn end — earlier rounds may have
|
|
6993
7402
|
// live-routed prose (narrate → skip_reply is a real shape),
|
|
6994
7403
|
// so settle the delivery chain and drop the receipt. A
|
|
@@ -7035,7 +7444,7 @@ export class AgentFramework {
|
|
|
7035
7444
|
request: compiledRequest ?? { note: 'streaming request aborted' },
|
|
7036
7445
|
durationMs,
|
|
7037
7446
|
});
|
|
7038
|
-
this.eventGate?.onInferenceEnded(agent.name);
|
|
7447
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
|
|
7039
7448
|
}
|
|
7040
7449
|
break;
|
|
7041
7450
|
}
|
|
@@ -7128,6 +7537,14 @@ export class AgentFramework {
|
|
|
7128
7537
|
}
|
|
7129
7538
|
}
|
|
7130
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
|
+
}
|
|
7131
7548
|
// Stream itself threw. Organization acceleration is deferred from a
|
|
7132
7549
|
// fresh compile; every other throw keeps the ordinary exhausted path.
|
|
7133
7550
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
@@ -7138,7 +7555,7 @@ export class AgentFramework {
|
|
|
7138
7555
|
error: `Provider acceleration cooldown: ${err.message}`,
|
|
7139
7556
|
request: compiledRequest ?? { note: 'streaming request rate-limited' }, durationMs });
|
|
7140
7557
|
this.abortAgentScript(agent.name, 'provider acceleration cooldown');
|
|
7141
|
-
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);
|
|
7142
7559
|
return;
|
|
7143
7560
|
}
|
|
7144
7561
|
this.emitTrace({
|
|
@@ -7173,7 +7590,7 @@ export class AgentFramework {
|
|
|
7173
7590
|
this.abortAgentScript(agent.name, 'stream threw');
|
|
7174
7591
|
lifecyclePhase = 'failed'; // §10.5 — terminal emitted in finally
|
|
7175
7592
|
agent.reset();
|
|
7176
|
-
this.eventGate?.onInferenceEnded(agent.name);
|
|
7593
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
|
|
7177
7594
|
} finally {
|
|
7178
7595
|
const unsettledKvSubmissions = drainKvSubmissionIds?.() ?? [];
|
|
7179
7596
|
if (unsettledKvSubmissions.length > 0) {
|
|
@@ -7186,6 +7603,22 @@ export class AgentFramework {
|
|
|
7186
7603
|
}
|
|
7187
7604
|
} catch { /* receipt cleanup must not mask inference teardown */ }
|
|
7188
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;
|
|
7189
7622
|
// §10.5: exactly one terminal per `started`, on every exit path the
|
|
7190
7623
|
// host controls. Which one was decided by the path taken (default
|
|
7191
7624
|
// completed; catch → failed; framework-cancel → aborted). A host
|
|
@@ -7204,30 +7637,36 @@ export class AgentFramework {
|
|
|
7204
7637
|
// all incoming events → the agent silently stops waking on messages
|
|
7205
7638
|
// (typing still stops, compression still runs — matching the observed
|
|
7206
7639
|
// wedge). onInferenceEnded is idempotent, so a redundant call is safe.
|
|
7207
|
-
|
|
7208
|
-
|
|
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
|
+
}
|
|
7209
7646
|
if (ownsProviderGate) this.releasePrimaryProviderGate(agent.name);
|
|
7210
7647
|
|
|
7211
7648
|
// Stop the typing indicator on every exit path (complete, error,
|
|
7212
7649
|
// exhausted, abort) so it never sticks after the turn ends.
|
|
7213
|
-
this.channelRegistry?.stopTyping();
|
|
7650
|
+
if (frameReachedTerminal) this.channelRegistry?.stopTyping();
|
|
7214
7651
|
|
|
7215
7652
|
// Spec 14.3: flush any held line-start text, then close each streamed
|
|
7216
7653
|
// channel with its final moderated content — the consumer's signal to
|
|
7217
7654
|
// finalize (end the TTS utterance, settle the rendered message).
|
|
7218
|
-
if (proseStream) {
|
|
7655
|
+
if (frameReachedTerminal && proseStream) {
|
|
7219
7656
|
emitOutgoing(proseStream.finish());
|
|
7220
7657
|
for (const [channelId, text] of proseStream.byChannel()) {
|
|
7221
7658
|
this.channelRegistry!.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text);
|
|
7222
7659
|
}
|
|
7223
7660
|
}
|
|
7224
7661
|
this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
|
|
7225
|
-
|
|
7226
|
-
|
|
7662
|
+
if (ownsPhysicalStream) {
|
|
7663
|
+
this.activeStreams.delete(agent.name);
|
|
7664
|
+
this.pendingAssistantBlocks.delete(agent.name);
|
|
7665
|
+
}
|
|
7227
7666
|
|
|
7228
7667
|
// A conversation fork whose TTL closure turn just finished is done for
|
|
7229
7668
|
// good — dispose it so the agent map doesn't grow monotonically.
|
|
7230
|
-
if (this.closingConversationAgents.has(agent.name)) {
|
|
7669
|
+
if (ownsPhysicalStream && this.closingConversationAgents.has(agent.name)) {
|
|
7231
7670
|
this.disposeConversationAgent(agent.name);
|
|
7232
7671
|
}
|
|
7233
7672
|
|
|
@@ -7243,11 +7682,15 @@ export class AgentFramework {
|
|
|
7243
7682
|
this.activeTurnTriggers.delete(agent.name);
|
|
7244
7683
|
}
|
|
7245
7684
|
|
|
7246
|
-
// Flush any deferred messages (e.g. if stream failed while tools were
|
|
7247
|
-
|
|
7248
|
-
|
|
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);
|
|
7249
7691
|
for (const msg of deferred) {
|
|
7250
|
-
this.addMessage(msg.participant, msg.content, msg.metadata
|
|
7692
|
+
this.addMessage(msg.participant, msg.content, msg.metadata,
|
|
7693
|
+
msg.forAgent ? { forAgent: msg.forAgent } : undefined);
|
|
7251
7694
|
}
|
|
7252
7695
|
}
|
|
7253
7696
|
}
|
|
@@ -8348,6 +8791,30 @@ export class AgentFramework {
|
|
|
8348
8791
|
return;
|
|
8349
8792
|
}
|
|
8350
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
|
+
|
|
8351
8818
|
// Route sleep / wake tools
|
|
8352
8819
|
if ((enrichedCall.name === 'sleep' || enrichedCall.name === 'wake') && this.eventGate) {
|
|
8353
8820
|
this.dispatchSleepToolCall(agentName, enrichedCall);
|
|
@@ -8492,13 +8959,32 @@ export class AgentFramework {
|
|
|
8492
8959
|
private addMessage(
|
|
8493
8960
|
participant: string,
|
|
8494
8961
|
content: ContentBlock[],
|
|
8495
|
-
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
|
+
}
|
|
8496
8973
|
): MessageId {
|
|
8497
|
-
// Route to the
|
|
8498
|
-
const agent =
|
|
8499
|
-
? this.agents.get(
|
|
8500
|
-
: 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;
|
|
8501
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
|
+
}
|
|
8502
8988
|
throw new Error('No agents configured');
|
|
8503
8989
|
}
|
|
8504
8990
|
|
|
@@ -8519,19 +9005,48 @@ export class AgentFramework {
|
|
|
8519
9005
|
// ALSO injected into the live stream — hear-while-acting), or in
|
|
8520
9006
|
// driveStream's finally when the turn ends.
|
|
8521
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;
|
|
8522
9015
|
if (
|
|
8523
9016
|
!hasToolResult &&
|
|
8524
|
-
(
|
|
9017
|
+
(midToolCycle ||
|
|
8525
9018
|
this.activeTurnTokens.has(agent.name) ||
|
|
8526
9019
|
this.activeStreams.has(agent.name))
|
|
8527
9020
|
) {
|
|
8528
|
-
this.deferredMessages.push({ participant, content, metadata });
|
|
8529
|
-
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
|
|
8530
9023
|
}
|
|
8531
9024
|
|
|
8532
9025
|
return agent.getContextManager().addMessage(participant, content, metadata);
|
|
8533
9026
|
}
|
|
8534
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
|
+
|
|
8535
9050
|
private editMessage(id: MessageId, content: ContentBlock[]): void {
|
|
8536
9051
|
const agent = this.primaryAgentName
|
|
8537
9052
|
? this.agents.get(this.primaryAgentName)
|
|
@@ -10922,6 +11437,53 @@ export class AgentFramework {
|
|
|
10922
11437
|
* suppression window, optionally announces in the sticky channel, and ends
|
|
10923
11438
|
* the turn (the agent goes idle immediately). `wake` clears sleep.
|
|
10924
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
|
+
|
|
10925
11487
|
private dispatchSleepToolCall(agentName: string, call: ToolCall): void {
|
|
10926
11488
|
this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
|
|
10927
11489
|
const gate = this.eventGate!;
|