@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/dist/src/framework.js
CHANGED
|
@@ -4,7 +4,9 @@ import { ReferenceFetcher, DEFAULT_FETCH_MAX_BYTES, EAGER_FETCH_TIMEOUT_MS } fro
|
|
|
4
4
|
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
5
5
|
import { JsStore } from '@animalabs/chronicle';
|
|
6
6
|
import { MembraneError } from '@animalabs/membrane';
|
|
7
|
-
import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager';
|
|
7
|
+
import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy } from '@animalabs/context-manager';
|
|
8
|
+
import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES } from './tune-out/tools.js';
|
|
9
|
+
import { TuneOutCoordinator, TUNE_OUT_DEFAULTS } from './tune-out/coordinator.js';
|
|
8
10
|
import { ProcessQueueImpl } from './queue.js';
|
|
9
11
|
import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK } from './refusal-reactions.js';
|
|
10
12
|
import { Agent } from './agent.js';
|
|
@@ -17,6 +19,7 @@ import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
|
|
|
17
19
|
import { PushHandler } from './mcpl/push-handler.js';
|
|
18
20
|
import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
|
|
19
21
|
import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
|
|
22
|
+
import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js';
|
|
20
23
|
import { InferenceRouter } from './mcpl/inference-router.js';
|
|
21
24
|
import { ChannelRegistry } from './mcpl/channel-registry.js';
|
|
22
25
|
import { ConversationRouter } from './mcpl/conversation-router.js';
|
|
@@ -522,6 +525,16 @@ export class AgentFramework {
|
|
|
522
525
|
frameworkCancelledStreams = new Map();
|
|
523
526
|
/** Active runEphemeralToCompletion runs, keyed by agent name. */
|
|
524
527
|
ephemeralRuns = new Map();
|
|
528
|
+
/** Ephemeral namespaces/names are single-generation for this framework
|
|
529
|
+
* lifetime. Reusing one while an old stream drains would let name-keyed
|
|
530
|
+
* liveness/settlement state cross generations. */
|
|
531
|
+
usedEphemeralAgentNames = new Set();
|
|
532
|
+
/** One-shot generation tickets minted by createEphemeralAgent. */
|
|
533
|
+
ephemeralCandidates = new WeakMap();
|
|
534
|
+
/** Physical ephemeral frames deliberately disposed by their run watchdog/caller.
|
|
535
|
+
* The name is single-generation, so these frames still own their terminal
|
|
536
|
+
* typing/outgoing close even after runEphemeralToCompletion deregisters them. */
|
|
537
|
+
disposedEphemeralFrames = new WeakSet();
|
|
525
538
|
/** Per-agent count of consecutive exhausted inferences (reset on any success).
|
|
526
539
|
* Drives hard-down escalation — see noteInferenceExhausted. */
|
|
527
540
|
consecutiveInferenceFailures = new Map();
|
|
@@ -569,6 +582,14 @@ export class AgentFramework {
|
|
|
569
582
|
primaryAgentName = null;
|
|
570
583
|
// Messages deferred while an agent is waiting_for_tools (to preserve
|
|
571
584
|
// tool_use → tool_result adjacency required by the Anthropic API).
|
|
585
|
+
/** The subconscious resident's registry name (issue #77), or null. */
|
|
586
|
+
subconsciousAgentName = null;
|
|
587
|
+
/** Its windowed strategy — the coordinator moves the anchor on tune-out entry. */
|
|
588
|
+
subconsciousStrategy = null;
|
|
589
|
+
/** Its config (speak_in_channel gate, voice block provenance). */
|
|
590
|
+
subconsciousConfig = null;
|
|
591
|
+
/** Tune-out coordinator (issue #77); non-null iff subconscious + channels. */
|
|
592
|
+
tuneOutCoordinator = null;
|
|
572
593
|
deferredMessages = [];
|
|
573
594
|
// Turn-alive markers (2026-07-31 Mythos phantom-skip incident): agentName →
|
|
574
595
|
// token of the turn currently in progress. Set at startAgentStream ENTRY —
|
|
@@ -599,6 +620,9 @@ export class AgentFramework {
|
|
|
599
620
|
getActiveTurnTrigger(agentName) {
|
|
600
621
|
return this.activeTurnTriggers.get(agentName);
|
|
601
622
|
}
|
|
623
|
+
/** Structured calls executed in the current logical turn. Unlike the
|
|
624
|
+
* per-stream local this survives framework retries and budget restarts. */
|
|
625
|
+
logicalTurnToolCalls = new WeakMap();
|
|
602
626
|
nextTurnToken = 1;
|
|
603
627
|
// Undo/redo state
|
|
604
628
|
turnCounters = new Map(); // agentName → next turnIndex
|
|
@@ -810,6 +834,12 @@ export class AgentFramework {
|
|
|
810
834
|
for (const agentConfig of config.agents) {
|
|
811
835
|
await framework.createAgent(agentConfig);
|
|
812
836
|
}
|
|
837
|
+
// The subconscious resident (issue #77) registers after the residents so
|
|
838
|
+
// it can never become primary; it is excluded from every broadcast
|
|
839
|
+
// fan-out and woken only by the tune-out coordinator's explicit pushes.
|
|
840
|
+
if (config.subconscious?.enabled) {
|
|
841
|
+
await framework.createSubconsciousAgent(config.subconscious);
|
|
842
|
+
}
|
|
813
843
|
// Finish any branch-local suppression interrupted after Chronicle switched
|
|
814
844
|
// branches. This runs before modules, MCPL connections, or inbound traffic.
|
|
815
845
|
await framework.resumePreparedDiscordSuppressions();
|
|
@@ -854,11 +884,11 @@ export class AgentFramework {
|
|
|
854
884
|
initialConfig: config.gate.config,
|
|
855
885
|
privilegedUsersPath: config.gate.privilegedUsersPath,
|
|
856
886
|
emitTrace: (e) => framework.emitTrace(e),
|
|
857
|
-
addMessage: (p, c, m) => framework.addMessage(p, c, m),
|
|
887
|
+
addMessage: (p, c, m, forAgent) => framework.addMessage(p, c, m, forAgent ? { forAgent } : undefined),
|
|
858
888
|
requestInference: (agentName, reason, source) => {
|
|
859
889
|
framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
|
|
860
890
|
},
|
|
861
|
-
getAgentNames: () => [...framework.agents.keys()],
|
|
891
|
+
getAgentNames: () => [...framework.agents.keys()].filter((n) => n !== framework.subconsciousAgentName),
|
|
862
892
|
});
|
|
863
893
|
}
|
|
864
894
|
// Liveness watchdog: fail hard if the main thread wedges (opt-in).
|
|
@@ -909,6 +939,30 @@ export class AgentFramework {
|
|
|
909
939
|
}
|
|
910
940
|
await framework.initializeMcpl(config.mcplServers, config.inferenceRouting);
|
|
911
941
|
}
|
|
942
|
+
// Tune-out coordinator (issue #77): needs both the subconscious resident
|
|
943
|
+
// and the MCPL channel subsystem; created after both exist.
|
|
944
|
+
if (framework.subconsciousAgentName && framework.channelRegistry) {
|
|
945
|
+
framework.tuneOutCoordinator = new TuneOutCoordinator(framework.channelRegistry, framework.mcplServerRegistry, {
|
|
946
|
+
addMessage: (participant, content, metadata, forAgent) => framework.addMessage(participant, content, metadata, forAgent ? { forAgent } : undefined),
|
|
947
|
+
requestInference: (agentName, reason, source) => {
|
|
948
|
+
framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
|
|
949
|
+
},
|
|
950
|
+
subconsciousName: () => framework.subconsciousAgentName,
|
|
951
|
+
primaryName: () => framework.primaryAgentName,
|
|
952
|
+
getStoredMessages: () => {
|
|
953
|
+
const primary = framework.primaryAgentName
|
|
954
|
+
? framework.agents.get(framework.primaryAgentName) : undefined;
|
|
955
|
+
return primary ? primary.getContextManager().getAllMessages() : [];
|
|
956
|
+
},
|
|
957
|
+
currentSequence: () => framework.store.currentSequence(),
|
|
958
|
+
setSubconsciousAnchor: (sequence) => framework.subconsciousStrategy?.setAnchor(sequence),
|
|
959
|
+
isForkBound: (channelId) => [...framework.conversationAgentHomes.values()].includes(channelId),
|
|
960
|
+
isPrivilegedAuthor: (authorId) => framework.eventGate?.isPrivilegedUser(authorId) ?? false,
|
|
961
|
+
allowChannelSpeech: () => !!framework.subconsciousConfig?.allowChannelSpeech,
|
|
962
|
+
emitTrace: (e) => framework.emitTrace(e),
|
|
963
|
+
});
|
|
964
|
+
framework.tuneOutCoordinator.resumeActiveEpochs();
|
|
965
|
+
}
|
|
912
966
|
// Diagnostics: `kill -USR2 <pid>` dumps live wake/inference state to stderr
|
|
913
967
|
// (journal) without a restart — for catching the wake-wedge on the running
|
|
914
968
|
// process. Shows the gate's `inferring` set + buffered-event count (the
|
|
@@ -973,6 +1027,7 @@ export class AgentFramework {
|
|
|
973
1027
|
this.running = false;
|
|
974
1028
|
this.providerAdmissionClosed = true;
|
|
975
1029
|
this.queue.close();
|
|
1030
|
+
this.tuneOutCoordinator?.stop();
|
|
976
1031
|
// Kill running code_execution scripts before cancelling streams: a
|
|
977
1032
|
// zombie script must not keep firing side-effectful tool calls into a
|
|
978
1033
|
// framework that is shutting down.
|
|
@@ -1404,7 +1459,12 @@ export class AgentFramework {
|
|
|
1404
1459
|
*/
|
|
1405
1460
|
getAllTools() {
|
|
1406
1461
|
const moduleTools = this.moduleRegistry.getAllTools();
|
|
1407
|
-
|
|
1462
|
+
// Copy: getChannelTools() returns the registry's shared definitions
|
|
1463
|
+
// array; pushing onto it would append another tune_out on every call.
|
|
1464
|
+
const channelTools = [...(this.channelRegistry?.getChannelTools() ?? [])];
|
|
1465
|
+
if (this.tuneOutCoordinator) {
|
|
1466
|
+
channelTools.push(AgentFramework.TUNE_OUT_TOOL);
|
|
1467
|
+
}
|
|
1408
1468
|
const gateTools = this.eventGate
|
|
1409
1469
|
? [
|
|
1410
1470
|
this.eventGate.getToolDefinition(),
|
|
@@ -1634,6 +1694,18 @@ export class AgentFramework {
|
|
|
1634
1694
|
};
|
|
1635
1695
|
}
|
|
1636
1696
|
getToolsForAgent(agentName, snapshot) {
|
|
1697
|
+
// The subconscious gets its own small surface (plus think/skip_reply/
|
|
1698
|
+
// end_turn), not the residents' tool board: no channel lifecycle, no
|
|
1699
|
+
// gate rules, no workspace — it observes, judges, and reports.
|
|
1700
|
+
if (agentName === this.subconsciousAgentName) {
|
|
1701
|
+
const basics = this.getAllTools()
|
|
1702
|
+
.filter((t) => t.name === 'think' || t.name === 'skip_reply' || t.name === 'end_turn')
|
|
1703
|
+
.map((t) => t.name === 'think'
|
|
1704
|
+
? this.buildThinkTool(snapshot?.sameRoundThinkTextPolicy
|
|
1705
|
+
?? this.getAgentRuntimeSettings(agentName).sameRoundThinkTextPolicy)
|
|
1706
|
+
: t);
|
|
1707
|
+
return [...SUBCONSCIOUS_TOOLS, ...basics];
|
|
1708
|
+
}
|
|
1637
1709
|
return this.getAllTools().map((tool) => {
|
|
1638
1710
|
if (tool.name === 'think') {
|
|
1639
1711
|
return this.buildThinkTool(snapshot?.sameRoundThinkTextPolicy
|
|
@@ -1989,21 +2061,35 @@ export class AgentFramework {
|
|
|
1989
2061
|
* Call cleanup() when done to release the ContextManager.
|
|
1990
2062
|
*/
|
|
1991
2063
|
async createEphemeralAgent(config) {
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2064
|
+
// Names are Chronicle namespaces and generation identities. Reserve before
|
|
2065
|
+
// opening the namespace so a second creation cannot append to an earlier
|
|
2066
|
+
// generation before runEphemeralToCompletion has a chance to reject it.
|
|
2067
|
+
if (this.usedEphemeralAgentNames.has(config.name) || this.agents.has(config.name)) {
|
|
2068
|
+
throw new Error(`Ephemeral agent name \"${config.name}\" is already registered or has been used in this framework`);
|
|
2069
|
+
}
|
|
2070
|
+
this.usedEphemeralAgentNames.add(config.name);
|
|
2071
|
+
try {
|
|
2072
|
+
const namespace = `subagent/${config.name}`;
|
|
2073
|
+
const contextManager = await ContextManager.open({
|
|
2074
|
+
store: this.store,
|
|
2075
|
+
namespace,
|
|
2076
|
+
isolate: true,
|
|
2077
|
+
strategy: config.strategy ?? new PassthroughStrategy(),
|
|
2078
|
+
membrane: this.membrane,
|
|
2079
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
2080
|
+
});
|
|
2081
|
+
const agent = new Agent(config, contextManager, this.membrane);
|
|
2082
|
+
this.ephemeralCandidates.set(agent, contextManager);
|
|
2083
|
+
const cleanup = () => {
|
|
2084
|
+
// Don't close the store — it's shared. Just release the CM.
|
|
2085
|
+
// Data persists in the store under the namespace for investigation.
|
|
2086
|
+
};
|
|
2087
|
+
return { agent, contextManager, cleanup };
|
|
2088
|
+
}
|
|
2089
|
+
catch (error) {
|
|
2090
|
+
this.usedEphemeralAgentNames.delete(config.name);
|
|
2091
|
+
throw error;
|
|
2092
|
+
}
|
|
2007
2093
|
}
|
|
2008
2094
|
createDeferred() {
|
|
2009
2095
|
let resolve;
|
|
@@ -2024,6 +2110,18 @@ export class AgentFramework {
|
|
|
2024
2110
|
}
|
|
2025
2111
|
run.lastActivity = Date.now();
|
|
2026
2112
|
}
|
|
2113
|
+
/** Record structured calls for the current logical turn. Ignore late stream
|
|
2114
|
+
* events after an ephemeral/conversation agent has been disposed. */
|
|
2115
|
+
recordLogicalTurnToolCalls(agent, turnToken, count) {
|
|
2116
|
+
if (count <= 0 || this.agents.get(agent.name) !== agent)
|
|
2117
|
+
return;
|
|
2118
|
+
const state = this.logicalTurnToolCalls.get(agent);
|
|
2119
|
+
// The Agent object is the generation: a late stream from a disposed
|
|
2120
|
+
// ephemeral/conversation fork cannot contaminate a same-name successor.
|
|
2121
|
+
if (!state || state.turnToken !== turnToken)
|
|
2122
|
+
return;
|
|
2123
|
+
this.logicalTurnToolCalls.set(agent, { turnToken, count: state.count + count });
|
|
2124
|
+
}
|
|
2027
2125
|
recordEphemeralToolCalls(agentName, count) {
|
|
2028
2126
|
const run = this.ephemeralRuns.get(agentName);
|
|
2029
2127
|
if (!run)
|
|
@@ -2060,7 +2158,18 @@ export class AgentFramework {
|
|
|
2060
2158
|
* The task message should already be in the context manager.
|
|
2061
2159
|
*/
|
|
2062
2160
|
async runEphemeralToCompletion(agent, contextManager, watchdogs) {
|
|
2063
|
-
//
|
|
2161
|
+
// Only a fresh object returned by createEphemeralAgent may enter this path.
|
|
2162
|
+
// Never overwrite a resident/conversation owner or a concurrent run: their
|
|
2163
|
+
// cleanup is name-keyed and could cancel/deregister the legitimate owner.
|
|
2164
|
+
if (this.ephemeralCandidates.get(agent) !== contextManager) {
|
|
2165
|
+
throw new Error(`Ephemeral agent "${agent.name}" has no fresh generation ticket from this framework`);
|
|
2166
|
+
}
|
|
2167
|
+
// Consume before any await/registration: the exact (Agent, ContextManager)
|
|
2168
|
+
// generation is one-shot even if startup later fails.
|
|
2169
|
+
this.ephemeralCandidates.delete(agent);
|
|
2170
|
+
if (this.agents.has(agent.name) || this.ephemeralRuns.has(agent.name)) {
|
|
2171
|
+
throw new Error(`Ephemeral agent "${agent.name}" is already registered or running`);
|
|
2172
|
+
}
|
|
2064
2173
|
this.agents.set(agent.name, agent);
|
|
2065
2174
|
const run = {
|
|
2066
2175
|
settle: this.createDeferred(),
|
|
@@ -2119,8 +2228,26 @@ export class AgentFramework {
|
|
|
2119
2228
|
clearTimeout(startupWatchdog);
|
|
2120
2229
|
if (completionWatchdog)
|
|
2121
2230
|
clearInterval(completionWatchdog);
|
|
2122
|
-
|
|
2123
|
-
this
|
|
2231
|
+
// Mark before cancellation: an adapter may synchronously settle its
|
|
2232
|
+
// iterator, and this physical frame still owns terminal typing/outgoing
|
|
2233
|
+
// closure even though the ephemeral name is about to be deregistered.
|
|
2234
|
+
this.disposedEphemeralFrames.add(agent);
|
|
2235
|
+
// Cancel before deregistration. Adapters may still yield a queued event;
|
|
2236
|
+
// driveStream's Agent-identity check above discards it before dispatch.
|
|
2237
|
+
agent.cancelStream();
|
|
2238
|
+
if (this.ephemeralRuns.get(agent.name) === run)
|
|
2239
|
+
this.ephemeralRuns.delete(agent.name);
|
|
2240
|
+
if (this.agents.get(agent.name) === agent) {
|
|
2241
|
+
// Exact-generation disposal owns these name-keyed entries. Remove them
|
|
2242
|
+
// before deregistration; the late driveStream finally will see the
|
|
2243
|
+
// generation mismatch and leave any future owner untouched.
|
|
2244
|
+
this.activeStreams.delete(agent.name);
|
|
2245
|
+
this.pendingAssistantBlocks.delete(agent.name);
|
|
2246
|
+
// The late stream finalizer intentionally refuses name-keyed cleanup once
|
|
2247
|
+
// deregistered, so disposal must release EventGate liveness itself.
|
|
2248
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
2249
|
+
this.agents.delete(agent.name);
|
|
2250
|
+
}
|
|
2124
2251
|
// Spawn-and-dispose bookkeeping (main, d453165/fee96a7): without this,
|
|
2125
2252
|
// ephemeral agents leave checkpoint-tree keys and diagnostics map
|
|
2126
2253
|
// entries behind for the life of the store/session.
|
|
@@ -2132,6 +2259,7 @@ export class AgentFramework {
|
|
|
2132
2259
|
// of the map, and a late driveStream finally token-match no-ops.
|
|
2133
2260
|
this.activeTurnTokens.delete(agent.name);
|
|
2134
2261
|
this.activeTurnTriggers.delete(agent.name);
|
|
2262
|
+
this.logicalTurnToolCalls.delete(agent);
|
|
2135
2263
|
}
|
|
2136
2264
|
}
|
|
2137
2265
|
/**
|
|
@@ -2144,6 +2272,44 @@ export class AgentFramework {
|
|
|
2144
2272
|
* Query inference logs.
|
|
2145
2273
|
* Returns entries with summary info (doesn't resolve blobs).
|
|
2146
2274
|
*/
|
|
2275
|
+
/** Tune-out (issue #77): divert a channel to your subconscious. Present
|
|
2276
|
+
* only when the subconscious resident is configured. */
|
|
2277
|
+
static TUNE_OUT_TOOL = {
|
|
2278
|
+
name: 'tune_out',
|
|
2279
|
+
description: 'Divert a channel to your subconscious for a while: its traffic stops ' +
|
|
2280
|
+
'entering your context and stops waking you. Your subconscious watches ' +
|
|
2281
|
+
'it instead — summarizing on a cadence in its own voice, judging ' +
|
|
2282
|
+
'whether mentions merit interrupting, and able to cancel. People who ' +
|
|
2283
|
+
'mention you get an automatic reaction so they know you saw nothing. ' +
|
|
2284
|
+
'Cancelling (mode "cancel") delivers the diverted backlog. The ' +
|
|
2285
|
+
'tune-out auto-cancels after maxWakes mention-bursts.',
|
|
2286
|
+
inputSchema: {
|
|
2287
|
+
type: 'object',
|
|
2288
|
+
properties: {
|
|
2289
|
+
channelId: { type: 'string' },
|
|
2290
|
+
mode: { type: 'string', enum: ['enter', 'cancel'], description: 'Default: enter.' },
|
|
2291
|
+
cadenceSeconds: {
|
|
2292
|
+
type: 'number',
|
|
2293
|
+
description: `Summary cadence in seconds (default ${TUNE_OUT_DEFAULTS.cadenceSeconds}; floors at 30).`,
|
|
2294
|
+
},
|
|
2295
|
+
backlogCap: {
|
|
2296
|
+
type: 'number',
|
|
2297
|
+
description: `Max raw messages delivered at cancel (default ${TUNE_OUT_DEFAULTS.backlogCap}). ` +
|
|
2298
|
+
'0 = deliver no raw backlog at all: your subconscious\'s report is the whole delivery.',
|
|
2299
|
+
},
|
|
2300
|
+
maxWakes: {
|
|
2301
|
+
type: 'number',
|
|
2302
|
+
description: `Wake budget before auto-cancel (default ${TUNE_OUT_DEFAULTS.maxWakes}; floors at 1).`,
|
|
2303
|
+
},
|
|
2304
|
+
durationSeconds: {
|
|
2305
|
+
type: 'number',
|
|
2306
|
+
description: 'Optional bounded duration: the tune-out auto-cancels (backlog ' +
|
|
2307
|
+
'delivered) after this many seconds; floors at 60. Unset = until cancelled.',
|
|
2308
|
+
},
|
|
2309
|
+
},
|
|
2310
|
+
required: ['channelId'],
|
|
2311
|
+
},
|
|
2312
|
+
};
|
|
2147
2313
|
/** Synthesized sleep/wake tool definitions (present when a gate is wired). */
|
|
2148
2314
|
static SLEEP_TOOLS = [
|
|
2149
2315
|
{
|
|
@@ -3395,6 +3561,15 @@ export class AgentFramework {
|
|
|
3395
3561
|
// streaming agent keeps the original Membrane.
|
|
3396
3562
|
membrane: this.auxiliaryMembraneFor(config.name),
|
|
3397
3563
|
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
3564
|
+
// Tune-out (issue #77): messages stamped at ingestion as diverted
|
|
3565
|
+
// (metadata.tuneOut = { epochId }) never enter a resident's compiled
|
|
3566
|
+
// view — not at emission, not via chunking into the memory pyramid.
|
|
3567
|
+
// The stamp is permanent: after cancel, the backlog arrives as one
|
|
3568
|
+
// appended <tuned-out-backlog> dump instead of retro-inserting into
|
|
3569
|
+
// the timeline (KV-prefix-stable delivery). The stored originals
|
|
3570
|
+
// remain in the shared slot for the subconscious's merged view,
|
|
3571
|
+
// fetch_history, and audit.
|
|
3572
|
+
viewFilter: (message) => !message.metadata?.tuneOut,
|
|
3398
3573
|
});
|
|
3399
3574
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
3400
3575
|
const restoredSettings = this.readAgentRuntimeSettings(config.name);
|
|
@@ -3409,6 +3584,68 @@ export class AgentFramework {
|
|
|
3409
3584
|
}
|
|
3410
3585
|
return agent;
|
|
3411
3586
|
}
|
|
3587
|
+
/**
|
|
3588
|
+
* Create and register the subconscious resident (issue #77): a persistent
|
|
3589
|
+
* side-agent with an isolated slot (`subconscious/<primary>`) whose
|
|
3590
|
+
* strategy view merges the residents' shared timeline with its own —
|
|
3591
|
+
* including the tuned-out-stamped messages the residents' viewFilter
|
|
3592
|
+
* excludes; the backlog is precisely what it exists to see. Windowed
|
|
3593
|
+
* passthrough, no memory pyramid: its durable output is what it delivers
|
|
3594
|
+
* into the resident's window, which accumulates there.
|
|
3595
|
+
*/
|
|
3596
|
+
async createSubconsciousAgent(cfg) {
|
|
3597
|
+
const primaryName = this.primaryAgentName;
|
|
3598
|
+
const primaryConfig = primaryName ? this.agentConfigs.get(primaryName) : undefined;
|
|
3599
|
+
if (!primaryName || !primaryConfig) {
|
|
3600
|
+
throw new Error('subconscious requires a primary agent to attend to');
|
|
3601
|
+
}
|
|
3602
|
+
const name = cfg.name ?? 'Subconscious';
|
|
3603
|
+
if (this.agents.has(name)) {
|
|
3604
|
+
throw new Error(`subconscious name "${name}" collides with a registered agent`);
|
|
3605
|
+
}
|
|
3606
|
+
const strategy = new WindowedPassthroughStrategy({
|
|
3607
|
+
reAnchorFraction: cfg.reAnchorFraction,
|
|
3608
|
+
// Mirror the principal's per-message truncation ceiling so both see
|
|
3609
|
+
// oversized tool results / attachments under the same policy.
|
|
3610
|
+
maxMessageTokens: primaryConfig.strategy?.maxMessageTokens,
|
|
3611
|
+
});
|
|
3612
|
+
const contextManager = await ContextManager.open({
|
|
3613
|
+
store: this.store,
|
|
3614
|
+
namespace: `subconscious/${primaryName}`,
|
|
3615
|
+
isolate: true,
|
|
3616
|
+
// The residents' shared un-namespaced slot, merged read-only and
|
|
3617
|
+
// UNfiltered (no viewFilter here — stamped messages included).
|
|
3618
|
+
auxiliaryMessageViews: [{}],
|
|
3619
|
+
strategy,
|
|
3620
|
+
membrane: this.membrane,
|
|
3621
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
3622
|
+
});
|
|
3623
|
+
// Inherit the principal's inference settings wholesale (fork-template
|
|
3624
|
+
// idiom): reasoning effort (thinking), provider params, token ceilings,
|
|
3625
|
+
// caching, refusal handling, timezone — a same-model side-process must
|
|
3626
|
+
// not silently run under different inference conditions than the
|
|
3627
|
+
// resident it serves. Only identity- and surface-defining fields are
|
|
3628
|
+
// overridden below.
|
|
3629
|
+
const agentConfig = {
|
|
3630
|
+
...primaryConfig,
|
|
3631
|
+
name,
|
|
3632
|
+
model: cfg.model ?? primaryConfig.model,
|
|
3633
|
+
systemPrompt: cfg.systemPrompt,
|
|
3634
|
+
strategy: undefined, // its CM owns the windowed strategy instance
|
|
3635
|
+
// Prose is never auto-routed: a cadence-triggered turn carries no
|
|
3636
|
+
// locus, and bare prose must not fall through to the default channel.
|
|
3637
|
+
// Speech happens only via speak_in_channel (its own marked voice).
|
|
3638
|
+
proseRouting: 'explicit',
|
|
3639
|
+
allowedTools: [...SUBCONSCIOUS_TOOL_NAMES, 'think', 'skip_reply', 'end_turn'],
|
|
3640
|
+
};
|
|
3641
|
+
const agent = new Agent(agentConfig, contextManager, this.membrane);
|
|
3642
|
+
this.agents.set(name, agent);
|
|
3643
|
+
this.agentConfigs.set(name, agentConfig);
|
|
3644
|
+
this.subconsciousAgentName = name;
|
|
3645
|
+
this.subconsciousStrategy = strategy;
|
|
3646
|
+
this.subconsciousConfig = cfg;
|
|
3647
|
+
return agent;
|
|
3648
|
+
}
|
|
3412
3649
|
async runLoop() {
|
|
3413
3650
|
while (this.running) {
|
|
3414
3651
|
try {
|
|
@@ -3533,12 +3770,11 @@ export class AgentFramework {
|
|
|
3533
3770
|
// queued, they flush at the target's own next boundary — with
|
|
3534
3771
|
// injection — or in driveStream's finally when its turn ends.
|
|
3535
3772
|
const midTurnInjections = [];
|
|
3536
|
-
|
|
3537
|
-
const
|
|
3538
|
-
if (
|
|
3539
|
-
const deferred = this.deferredMessages.splice(0);
|
|
3773
|
+
{
|
|
3774
|
+
const deferred = this.drainDeferredFor(agent.name);
|
|
3775
|
+
if (deferred.length > 0) {
|
|
3540
3776
|
for (const msg of deferred) {
|
|
3541
|
-
|
|
3777
|
+
agent.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
|
|
3542
3778
|
// Injection guards: tool blocks would corrupt the tool-cycle
|
|
3543
3779
|
// structure the membrane enforces, and a message named as the
|
|
3544
3780
|
// agent itself would render as an ASSISTANT turn on the wire
|
|
@@ -3835,7 +4071,7 @@ export class AgentFramework {
|
|
|
3835
4071
|
// Broadcast requests exclude conversation forks — they are driven by
|
|
3836
4072
|
// their own channel's messages, not by framework-wide events.
|
|
3837
4073
|
const targetAgents = response.requestInference === true
|
|
3838
|
-
? Array.from(this.agents.keys()).filter((n) => !this.conversationAgentHomes.has(n))
|
|
4074
|
+
? Array.from(this.agents.keys()).filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName)
|
|
3839
4075
|
: response.requestInference;
|
|
3840
4076
|
for (const agentName of targetAgents) {
|
|
3841
4077
|
const agent = this.agents.get(agentName);
|
|
@@ -3902,11 +4138,31 @@ export class AgentFramework {
|
|
|
3902
4138
|
Object.assign(metadata, invitation.metadataPatch);
|
|
3903
4139
|
}
|
|
3904
4140
|
}
|
|
4141
|
+
// Tune-out divert (issue #77): stamped, stored, no resident wake. The
|
|
4142
|
+
// coordinator has already handled wake bookkeeping (coalesced
|
|
4143
|
+
// subconscious invocation, durable count, suppression ack) before we
|
|
4144
|
+
// stamp; the subconscious reads the message through its merged view.
|
|
4145
|
+
const divert = this.tuneOutCoordinator?.onIncoming(event.serverId, event.channelId, event.messageId, event.tags, event.author?.id,
|
|
4146
|
+
// The resident's gate verdict rides the event; it preconditions
|
|
4147
|
+
// subconscious wakes (gate composes, it is not replaced).
|
|
4148
|
+
event.triggerInference !== false) ?? null;
|
|
4149
|
+
if (divert) {
|
|
4150
|
+
metadata.tuneOut = { epochId: divert.epochId };
|
|
4151
|
+
}
|
|
3905
4152
|
const id = this.addMessage('user', incomingContent, metadata);
|
|
3906
4153
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
|
|
3907
|
-
if (event.triggerInference) {
|
|
4154
|
+
if (event.triggerInference && !divert) {
|
|
3908
4155
|
const addressed = isAddressedMessage(event.tags, event.metadata);
|
|
3909
|
-
|
|
4156
|
+
// Honor explicit targeting, mirroring the push-event path: an event
|
|
4157
|
+
// that names its agents wakes exactly those; an untargeted event
|
|
4158
|
+
// keeps the historical broadcast. (targetAgents was declared on
|
|
4159
|
+
// McplChannelIncomingEvent from the start but never honored here —
|
|
4160
|
+
// tune-out's wake routing is the first setter.)
|
|
4161
|
+
const targetAgents = event.targetAgents
|
|
4162
|
+
?? [...this.agents.keys()].filter((n) => n !== this.subconsciousAgentName);
|
|
4163
|
+
for (const agentName of targetAgents) {
|
|
4164
|
+
if (!this.agents.has(agentName))
|
|
4165
|
+
continue;
|
|
3910
4166
|
this.pendingRequests.push({
|
|
3911
4167
|
agentName,
|
|
3912
4168
|
reason: 'mcpl:channel-incoming',
|
|
@@ -4019,38 +4275,48 @@ export class AgentFramework {
|
|
|
4019
4275
|
if (!template || !templateConfig) {
|
|
4020
4276
|
throw new Error(`conversation template agent "${router.templateAgent}" not found`);
|
|
4021
4277
|
}
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4278
|
+
if (this.usedEphemeralAgentNames.has(name) || this.agents.has(name)) {
|
|
4279
|
+
throw new Error(`Conversation agent name "${name}" is already registered or reserved`);
|
|
4280
|
+
}
|
|
4281
|
+
this.usedEphemeralAgentNames.add(name);
|
|
4282
|
+
try {
|
|
4283
|
+
const contextManager = await ContextManager.open({
|
|
4284
|
+
store: this.store,
|
|
4285
|
+
namespace: `conversations/${name}`,
|
|
4286
|
+
isolate: true,
|
|
4287
|
+
// Strategy instances are stateful — never share the template's.
|
|
4288
|
+
strategy: router.strategyFactory?.() ?? new PassthroughStrategy(),
|
|
4289
|
+
// Dynamic conversation forks retain the established provider policy in
|
|
4290
|
+
// this bounded first slice. Provider cooldown ownership belongs to the
|
|
4291
|
+
// persistent resident only; generation-unique forks must not leak gates.
|
|
4292
|
+
membrane: this.membrane,
|
|
4293
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
4294
|
+
});
|
|
4295
|
+
// Seed with the template's compiled context, renaming the template
|
|
4296
|
+
// participant so the fork reads its inheritance as its own history.
|
|
4297
|
+
// Guard: only seed a genuinely fresh namespace. Generation counters are
|
|
4298
|
+
// persisted precisely so names aren't reused, but if this namespace has
|
|
4299
|
+
// history anyway (counter state lost, crash between spawn and persist),
|
|
4300
|
+
// seeding again would stack another template copy on top of it.
|
|
4301
|
+
const { messages: existing } = await contextManager.compile();
|
|
4302
|
+
if (existing.length === 0) {
|
|
4303
|
+
const { messages: compiled } = await template.getContextManager().compile();
|
|
4304
|
+
for (const msg of compiled) {
|
|
4305
|
+
const participant = msg.participant === template.name ? name : msg.participant;
|
|
4306
|
+
contextManager.addMessage(participant, msg.content);
|
|
4307
|
+
}
|
|
4308
|
+
}
|
|
4309
|
+
const config = { ...templateConfig, name, strategy: undefined };
|
|
4310
|
+
const agent = new Agent(config, contextManager, this.membrane);
|
|
4311
|
+
this.agents.set(name, agent);
|
|
4312
|
+
this.agentConfigs.set(name, config);
|
|
4313
|
+
this.conversationAgentHomes.set(name, channelId);
|
|
4314
|
+
return agent;
|
|
4315
|
+
}
|
|
4316
|
+
catch (error) {
|
|
4317
|
+
this.usedEphemeralAgentNames.delete(name);
|
|
4318
|
+
throw error;
|
|
4319
|
+
}
|
|
4054
4320
|
}
|
|
4055
4321
|
/** Persist the router's generation counters (see hydration in create()). */
|
|
4056
4322
|
persistConversationRouterState() {
|
|
@@ -4074,10 +4340,13 @@ export class AgentFramework {
|
|
|
4074
4340
|
disposeConversationAgent(agentName) {
|
|
4075
4341
|
this.closingConversationAgents.delete(agentName);
|
|
4076
4342
|
const channelId = this.conversationAgentHomes.get(agentName);
|
|
4343
|
+
const agent = this.agents.get(agentName);
|
|
4077
4344
|
this.agents.delete(agentName);
|
|
4078
4345
|
this.agentConfigs.delete(agentName);
|
|
4079
4346
|
this.conversationAgentHomes.delete(agentName);
|
|
4080
4347
|
this.evictTurnCheckpoints(agentName);
|
|
4348
|
+
if (agent)
|
|
4349
|
+
this.logicalTurnToolCalls.delete(agent);
|
|
4081
4350
|
this.emitTrace({
|
|
4082
4351
|
type: 'mcpl:conversation-disposed',
|
|
4083
4352
|
agentName,
|
|
@@ -4277,7 +4546,7 @@ export class AgentFramework {
|
|
|
4277
4546
|
if (event.triggerInference) {
|
|
4278
4547
|
// Default broadcast excludes conversation forks (channel-driven).
|
|
4279
4548
|
const targetAgents = event.targetAgents
|
|
4280
|
-
?? [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n));
|
|
4549
|
+
?? [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
|
|
4281
4550
|
for (const agentName of targetAgents) {
|
|
4282
4551
|
this.pendingRequests.push({
|
|
4283
4552
|
agentName,
|
|
@@ -4459,6 +4728,10 @@ export class AgentFramework {
|
|
|
4459
4728
|
// exactly this class of silent drop. One stderr line per drop.
|
|
4460
4729
|
console.error(`[inference-dropped] agent=${agentName} reason=policy-skip ` +
|
|
4461
4730
|
`requests=${requests.length} triggers=${requests.map((r) => r.reason).join(',')}`);
|
|
4731
|
+
// A context-budget restart inherits the predecessor's EventGate liveness.
|
|
4732
|
+
// If policy drops the queued successor, no driveStream remains to release it.
|
|
4733
|
+
if (budgetRestart)
|
|
4734
|
+
this.eventGate?.onInferenceEnded(agentName);
|
|
4462
4735
|
const gate = this.providerGates.get(agentName);
|
|
4463
4736
|
if (gate?.primaryPending && this.providerAccelerationRecoveries.has(agentName)) {
|
|
4464
4737
|
gate.primaryPending = false;
|
|
@@ -5012,9 +5285,8 @@ export class AgentFramework {
|
|
|
5012
5285
|
if (attempt === 0 &&
|
|
5013
5286
|
trigger?.reason !== 'context_budget_restart' &&
|
|
5014
5287
|
this.deferredMessages.length > 0) {
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
const deferred = this.deferredMessages.splice(0);
|
|
5288
|
+
{
|
|
5289
|
+
const deferred = this.drainDeferredFor(agent.name);
|
|
5018
5290
|
for (const msg of deferred) {
|
|
5019
5291
|
// Per-message try/catch (mirrors announceLocusIfChanged): one
|
|
5020
5292
|
// poison message — or a transient store-write failure — must not
|
|
@@ -5068,9 +5340,22 @@ export class AgentFramework {
|
|
|
5068
5340
|
// BEFORE this turn compiles, so the agent always knows where its voice
|
|
5069
5341
|
// goes (announce-on-change only — no per-turn chatter, append-only for
|
|
5070
5342
|
// KV stability).
|
|
5343
|
+
if (trigger?.reason === 'context_budget_restart') {
|
|
5344
|
+
const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
5345
|
+
this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
|
|
5346
|
+
}
|
|
5071
5347
|
if (trigger?.reason !== 'context_budget_restart') {
|
|
5072
5348
|
if (attempt === 0)
|
|
5073
5349
|
this.maybePrimeProseMode(agent);
|
|
5350
|
+
const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
5351
|
+
if (attempt === 0) {
|
|
5352
|
+
// A true new turn gets a fresh generation and count.
|
|
5353
|
+
this.logicalTurnToolCalls.set(agent, { turnToken, count: 0 });
|
|
5354
|
+
}
|
|
5355
|
+
else {
|
|
5356
|
+
// A framework retry is a new physical stream in the same logical turn.
|
|
5357
|
+
this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
|
|
5358
|
+
}
|
|
5074
5359
|
// Fresh turn: forget the previous turn's explicit-send engagements and
|
|
5075
5360
|
// prose deliveries — both are strictly turn-scoped (a restart continues
|
|
5076
5361
|
// the same logical turn and keeps them).
|
|
@@ -5108,7 +5393,11 @@ export class AgentFramework {
|
|
|
5108
5393
|
// context-budget restart, which skips the re-pin but re-emits this).
|
|
5109
5394
|
channelId: this.turnLocusPins.get(agent.name),
|
|
5110
5395
|
});
|
|
5111
|
-
|
|
5396
|
+
// A budget restart continues the same logical inference window. The
|
|
5397
|
+
// predecessor keeps EventGate liveness until its successor terminates.
|
|
5398
|
+
if (trigger?.reason !== 'context_budget_restart') {
|
|
5399
|
+
this.eventGate?.onInferenceStarted(agent.name);
|
|
5400
|
+
}
|
|
5112
5401
|
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
|
|
5113
5402
|
// Typing indicator, started at TURN START. It says "attending", which is
|
|
5114
5403
|
// true from the moment the turn is accepted — but it used to start inside
|
|
@@ -5162,8 +5451,32 @@ export class AgentFramework {
|
|
|
5162
5451
|
console.error('beforeInference hook error:', error);
|
|
5163
5452
|
}
|
|
5164
5453
|
}
|
|
5454
|
+
// An ephemeral watchdog may dispose this Agent while hooks/compile await.
|
|
5455
|
+
// Do not create a provider stream after its generation lost ownership.
|
|
5456
|
+
if (this.agents.get(agent.name) !== agent) {
|
|
5457
|
+
this.channelRegistry?.stopTyping();
|
|
5458
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
5459
|
+
return false;
|
|
5460
|
+
}
|
|
5461
|
+
// The subconscious's standing dispositions ride a fixed system-position
|
|
5462
|
+
// injection (antra, #tuneout-talk): never repeated in its timeline, and
|
|
5463
|
+
// — since it has no long-term memory — structurally unable to wash out
|
|
5464
|
+
// of its window. Bytes change only when a disposition changes.
|
|
5465
|
+
if (agent.name === this.subconsciousAgentName && this.tuneOutCoordinator) {
|
|
5466
|
+
const dispositionInjection = this.tuneOutCoordinator.getDispositionsInjection();
|
|
5467
|
+
if (dispositionInjection) {
|
|
5468
|
+
injections = injections ? [...injections, dispositionInjection] : [dispositionInjection];
|
|
5469
|
+
}
|
|
5470
|
+
}
|
|
5165
5471
|
const { stream, request: compiledRequest, takeKvSubmission, drainKvSubmissionIds, } = await agent.startStreamWithInjections(tools, injections);
|
|
5166
|
-
|
|
5472
|
+
if (this.agents.get(agent.name) !== agent) {
|
|
5473
|
+
stream.cancel();
|
|
5474
|
+
agent.cancelStream();
|
|
5475
|
+
this.channelRegistry?.stopTyping();
|
|
5476
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
5477
|
+
return false;
|
|
5478
|
+
}
|
|
5479
|
+
const handle = this.driveStream(agent, stream, requestSnapshot, trigger, attempt, compiledRequest, ownsProviderGate, takeKvSubmission, drainKvSubmissionIds, new Set(tools.map((tool) => tool.name)));
|
|
5167
5480
|
this.activeStreams.set(agent.name, handle);
|
|
5168
5481
|
// Handoff: driveStream captured the token in its synchronous prefix;
|
|
5169
5482
|
// its finally now owns clearing it. The caller's finally must not.
|
|
@@ -5236,7 +5549,7 @@ export class AgentFramework {
|
|
|
5236
5549
|
return false;
|
|
5237
5550
|
}
|
|
5238
5551
|
}
|
|
5239
|
-
async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest, ownsProviderGate = false, takeKvSubmission, drainKvSubmissionIds) {
|
|
5552
|
+
async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest, ownsProviderGate = false, takeKvSubmission, drainKvSubmissionIds, knownToolNames = new Set()) {
|
|
5240
5553
|
const startTime = Date.now();
|
|
5241
5554
|
const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
|
|
5242
5555
|
const myStreamId = agent.streamId;
|
|
@@ -5255,6 +5568,7 @@ export class AgentFramework {
|
|
|
5255
5568
|
// means no successor turn can have replaced it while we compiled.
|
|
5256
5569
|
const myTurnToken = this.activeTurnTokens.get(agent.name);
|
|
5257
5570
|
let hadToolCalls = false;
|
|
5571
|
+
let generationLost = false;
|
|
5258
5572
|
// ---- Present-while-acting turn state ---------------------------------
|
|
5259
5573
|
// Output locus for the WHOLE logical turn: frozen in startAgentStream
|
|
5260
5574
|
// (home → addressed trigger → global default) before this stream began,
|
|
@@ -5329,13 +5643,20 @@ export class AgentFramework {
|
|
|
5329
5643
|
// failed, a framework-cancelled stream sets aborted. Best-effort
|
|
5330
5644
|
// notifications — consumers dedupe by inferenceId and keep a timeout.
|
|
5331
5645
|
let lifecyclePhase = 'completed';
|
|
5646
|
+
let preserveEventGateForSuccessor = false;
|
|
5332
5647
|
this.hookOrchestrator?.emitLifecycle({
|
|
5333
5648
|
inferenceId: outgoingInferenceId,
|
|
5334
5649
|
conversationId: agent.name,
|
|
5335
5650
|
turnIndex: 0,
|
|
5336
5651
|
phase: 'started',
|
|
5337
5652
|
});
|
|
5338
|
-
|
|
5653
|
+
// The wrapper guard needs the complete response before it can distinguish
|
|
5654
|
+
// an exact invocation-shaped body from ordinary XML/prose. Buffer guarded
|
|
5655
|
+
// turns until completion: otherwise speculative outgoing chunks could
|
|
5656
|
+
// expose the wrapper before the fail-closed classifier runs.
|
|
5657
|
+
const proseStream = this.channelRegistry &&
|
|
5658
|
+
agent.proseRouting !== 'disabled' &&
|
|
5659
|
+
!agent.toolWrapperProseGuard
|
|
5339
5660
|
? new ProseStreamRouter({
|
|
5340
5661
|
mode: agent.proseRouting === 'explicit' ? 'explicit' : agent.proseRouting === 'hybrid' ? 'hybrid' : 'locus',
|
|
5341
5662
|
initialTarget: typingChannel,
|
|
@@ -5361,6 +5682,14 @@ export class AgentFramework {
|
|
|
5361
5682
|
};
|
|
5362
5683
|
try {
|
|
5363
5684
|
for await (const event of stream) {
|
|
5685
|
+
// Ignore every late event from an Agent generation that no longer owns
|
|
5686
|
+
// this name (ephemeral watchdog/disposal, conversation replacement).
|
|
5687
|
+
if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
|
|
5688
|
+
generationLost = true;
|
|
5689
|
+
lifecyclePhase = 'aborted';
|
|
5690
|
+
stream.cancel();
|
|
5691
|
+
break;
|
|
5692
|
+
}
|
|
5364
5693
|
this.touchEphemeralRun(agent.name, true);
|
|
5365
5694
|
switch (event.type) {
|
|
5366
5695
|
case 'tokens':
|
|
@@ -5423,6 +5752,7 @@ export class AgentFramework {
|
|
|
5423
5752
|
case 'tool-calls': {
|
|
5424
5753
|
adoptInjectedRound();
|
|
5425
5754
|
hadToolCalls = true;
|
|
5755
|
+
this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length);
|
|
5426
5756
|
this.recordEphemeralToolCalls(agent.name, event.calls.length);
|
|
5427
5757
|
this.emitTrace({
|
|
5428
5758
|
type: 'inference:tool_calls_yielded',
|
|
@@ -5604,7 +5934,29 @@ export class AgentFramework {
|
|
|
5604
5934
|
const terminalContent = lastToolIdx >= 0
|
|
5605
5935
|
? response.content.slice(lastToolIdx + 1)
|
|
5606
5936
|
: response.content;
|
|
5607
|
-
|
|
5937
|
+
// This is a whole-response boundary, not a trailing-prose
|
|
5938
|
+
// classifier. If the turn executed any genuine structured tool
|
|
5939
|
+
// call, preserve its later prose exactly as ordinary history.
|
|
5940
|
+
const logicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
5941
|
+
const logicalTurnHadToolCalls = logicalToolState?.turnToken === myTurnToken && (logicalToolState?.count ?? 0) > 0;
|
|
5942
|
+
const guardedWrapperTool = agent.toolWrapperProseGuard && !logicalTurnHadToolCalls
|
|
5943
|
+
? detectKnownToolWrapperProse(response.content, knownToolNames)
|
|
5944
|
+
: null;
|
|
5945
|
+
if (guardedWrapperTool) {
|
|
5946
|
+
// AF #133: containment only. Never execute wrapper text, never
|
|
5947
|
+
// publish it, and never retain it as the freshest assistant
|
|
5948
|
+
// self-seed. Exact raw output remains in the provider ledger.
|
|
5949
|
+
agent.getContextManager().addMessage('user', [{
|
|
5950
|
+
type: 'text',
|
|
5951
|
+
text: '[tool-boundary] No tool was called.',
|
|
5952
|
+
}], {
|
|
5953
|
+
system: true,
|
|
5954
|
+
kind: 'tool-wrapper-prose-contained',
|
|
5955
|
+
toolName: guardedWrapperTool,
|
|
5956
|
+
});
|
|
5957
|
+
console.error(`[tool-boundary] ${agent.name}: contained whole-response prose wrapper for registered tool ${guardedWrapperTool}; no tool called`);
|
|
5958
|
+
}
|
|
5959
|
+
else if (lastToolIdx >= 0) {
|
|
5608
5960
|
if (terminalContent.length > 0) {
|
|
5609
5961
|
agent.addAssistantResponse(terminalContent);
|
|
5610
5962
|
}
|
|
@@ -5627,7 +5979,9 @@ export class AgentFramework {
|
|
|
5627
5979
|
// convention remains "text is thoughts"; on a text-only turn all
|
|
5628
5980
|
// text is speech.
|
|
5629
5981
|
const isTextBlock = (block) => block.type === 'text';
|
|
5630
|
-
const allText =
|
|
5982
|
+
const allText = guardedWrapperTool
|
|
5983
|
+
? []
|
|
5984
|
+
: response.content.filter(isTextBlock);
|
|
5631
5985
|
const speechContent = hadToolCalls ? [] : allText;
|
|
5632
5986
|
const thoughts = hadToolCalls ? allText : [];
|
|
5633
5987
|
const du = response.details?.usage;
|
|
@@ -5639,19 +5993,30 @@ export class AgentFramework {
|
|
|
5639
5993
|
cacheRead: du.cacheReadTokens,
|
|
5640
5994
|
}
|
|
5641
5995
|
: undefined;
|
|
5996
|
+
// Async completion work may overlap a replacement stream. Recheck
|
|
5997
|
+
// physical ownership before any terminal state/settlement side effect.
|
|
5998
|
+
if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
|
|
5999
|
+
generationLost = true;
|
|
6000
|
+
lifecyclePhase = 'aborted';
|
|
6001
|
+
stream.cancel();
|
|
6002
|
+
return;
|
|
6003
|
+
}
|
|
5642
6004
|
// Reset agent state before emitting inference:completed. Traces are
|
|
5643
6005
|
// observability-only, but external synchronous listeners should
|
|
5644
6006
|
// still see the terminal state at the terminal trace boundary.
|
|
5645
6007
|
// Speech dispatch happens after but doesn't depend on the status
|
|
5646
6008
|
// field.
|
|
5647
6009
|
agent.reset();
|
|
5648
|
-
this.
|
|
6010
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
|
|
6011
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
5649
6012
|
this.settleAgent(agent.name, {
|
|
5650
6013
|
stopReason: 'completed',
|
|
5651
|
-
speech:
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
6014
|
+
speech: guardedWrapperTool
|
|
6015
|
+
? ''
|
|
6016
|
+
: terminalContent
|
|
6017
|
+
.filter((block) => block.type === 'text')
|
|
6018
|
+
.map((block) => block.text)
|
|
6019
|
+
.join('\n'),
|
|
5655
6020
|
});
|
|
5656
6021
|
this.emitTrace({
|
|
5657
6022
|
type: 'inference:completed',
|
|
@@ -5992,7 +6357,8 @@ export class AgentFramework {
|
|
|
5992
6357
|
agent.reset();
|
|
5993
6358
|
if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
|
|
5994
6359
|
lifecyclePhase = 'failed';
|
|
5995
|
-
this.
|
|
6360
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
|
|
6361
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
5996
6362
|
break;
|
|
5997
6363
|
}
|
|
5998
6364
|
const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
|
|
@@ -6016,7 +6382,8 @@ export class AgentFramework {
|
|
|
6016
6382
|
// itself) means retrying the same context can never succeed.
|
|
6017
6383
|
...this.classifyInferenceError(err),
|
|
6018
6384
|
});
|
|
6019
|
-
this.
|
|
6385
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
|
|
6386
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
6020
6387
|
if (action.emit) {
|
|
6021
6388
|
this.pushEvent(action.emit);
|
|
6022
6389
|
}
|
|
@@ -6037,7 +6404,14 @@ export class AgentFramework {
|
|
|
6037
6404
|
if (cancelKind !== undefined) {
|
|
6038
6405
|
this.frameworkCancelledStreams.delete(cancelKey);
|
|
6039
6406
|
lifecyclePhase = 'aborted'; // §10.5 — terminal emitted in finally
|
|
6040
|
-
|
|
6407
|
+
// A budget restart continues the same EventGate window. Do not
|
|
6408
|
+
// end it here or in finally; the replacement owns final release.
|
|
6409
|
+
if (cancelKind === 'budget_restart') {
|
|
6410
|
+
preserveEventGateForSuccessor = true;
|
|
6411
|
+
}
|
|
6412
|
+
else if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) {
|
|
6413
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
6414
|
+
}
|
|
6041
6415
|
// endTurn IS a logical turn end — earlier rounds may have
|
|
6042
6416
|
// live-routed prose (narrate → skip_reply is a real shape),
|
|
6043
6417
|
// so settle the delivery chain and drop the receipt. A
|
|
@@ -6084,7 +6458,8 @@ export class AgentFramework {
|
|
|
6084
6458
|
request: compiledRequest ?? { note: 'streaming request aborted' },
|
|
6085
6459
|
durationMs,
|
|
6086
6460
|
});
|
|
6087
|
-
this.
|
|
6461
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
|
|
6462
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
6088
6463
|
}
|
|
6089
6464
|
break;
|
|
6090
6465
|
}
|
|
@@ -6160,6 +6535,14 @@ export class AgentFramework {
|
|
|
6160
6535
|
}
|
|
6161
6536
|
}
|
|
6162
6537
|
catch (error) {
|
|
6538
|
+
// A superseded physical stream may throw after its replacement starts.
|
|
6539
|
+
// It has no authority to settle, reset, gate-release, or publish failure.
|
|
6540
|
+
if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
|
|
6541
|
+
generationLost = true;
|
|
6542
|
+
lifecyclePhase = 'aborted';
|
|
6543
|
+
stream.cancel();
|
|
6544
|
+
return;
|
|
6545
|
+
}
|
|
6163
6546
|
// Stream itself threw. Organization acceleration is deferred from a
|
|
6164
6547
|
// fresh compile; every other throw keeps the ordinary exhausted path.
|
|
6165
6548
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
@@ -6172,7 +6555,8 @@ export class AgentFramework {
|
|
|
6172
6555
|
this.abortAgentScript(agent.name, 'provider acceleration cooldown');
|
|
6173
6556
|
lifecyclePhase = 'failed';
|
|
6174
6557
|
agent.reset();
|
|
6175
|
-
this.
|
|
6558
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
|
|
6559
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
6176
6560
|
return;
|
|
6177
6561
|
}
|
|
6178
6562
|
this.emitTrace({
|
|
@@ -6207,7 +6591,8 @@ export class AgentFramework {
|
|
|
6207
6591
|
this.abortAgentScript(agent.name, 'stream threw');
|
|
6208
6592
|
lifecyclePhase = 'failed'; // §10.5 — terminal emitted in finally
|
|
6209
6593
|
agent.reset();
|
|
6210
|
-
this.
|
|
6594
|
+
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
|
|
6595
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
6211
6596
|
}
|
|
6212
6597
|
finally {
|
|
6213
6598
|
const unsettledKvSubmissions = drainKvSubmissionIds?.() ?? [];
|
|
@@ -6220,6 +6605,19 @@ export class AgentFramework {
|
|
|
6220
6605
|
}
|
|
6221
6606
|
catch { /* receipt cleanup must not mask inference teardown */ }
|
|
6222
6607
|
}
|
|
6608
|
+
// A framework retry/budget restart can overlap physical streams on the
|
|
6609
|
+
// same Agent object. Name + Agent identity is not enough: only the
|
|
6610
|
+
// current stream generation may mutate name-keyed teardown state.
|
|
6611
|
+
const ownsPhysicalStream = this.agents.get(agent.name) === agent && agent.streamId === myStreamId;
|
|
6612
|
+
// Ephemeral completion resolves its caller before this finally runs, so
|
|
6613
|
+
// runEphemeralToCompletion may already have deregistered the name. The
|
|
6614
|
+
// terminal frame still owns its typing/outgoing completion unless it was
|
|
6615
|
+
// genuinely superseded or handed EventGate liveness to a budget restart.
|
|
6616
|
+
const disposedEphemeralWithoutSuccessor = generationLost &&
|
|
6617
|
+
this.disposedEphemeralFrames.has(agent) &&
|
|
6618
|
+
!this.agents.has(agent.name);
|
|
6619
|
+
const frameReachedTerminal = (!generationLost || disposedEphemeralWithoutSuccessor) &&
|
|
6620
|
+
!preserveEventGateForSuccessor;
|
|
6223
6621
|
// §10.5: exactly one terminal per `started`, on every exit path the
|
|
6224
6622
|
// host controls. Which one was decided by the path taken (default
|
|
6225
6623
|
// completed; catch → failed; framework-cancel → aborted). A host
|
|
@@ -6238,28 +6636,35 @@ export class AgentFramework {
|
|
|
6238
6636
|
// all incoming events → the agent silently stops waking on messages
|
|
6239
6637
|
// (typing still stops, compression still runs — matching the observed
|
|
6240
6638
|
// wedge). onInferenceEnded is idempotent, so a redundant call is safe.
|
|
6241
|
-
|
|
6242
|
-
|
|
6639
|
+
if (ownsPhysicalStream && !preserveEventGateForSuccessor) {
|
|
6640
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
6641
|
+
}
|
|
6642
|
+
if (!generationLost && ownsPhysicalStream) {
|
|
6643
|
+
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
|
|
6644
|
+
}
|
|
6243
6645
|
if (ownsProviderGate)
|
|
6244
6646
|
this.releasePrimaryProviderGate(agent.name);
|
|
6245
6647
|
// Stop the typing indicator on every exit path (complete, error,
|
|
6246
6648
|
// exhausted, abort) so it never sticks after the turn ends.
|
|
6247
|
-
|
|
6649
|
+
if (frameReachedTerminal)
|
|
6650
|
+
this.channelRegistry?.stopTyping();
|
|
6248
6651
|
// Spec 14.3: flush any held line-start text, then close each streamed
|
|
6249
6652
|
// channel with its final moderated content — the consumer's signal to
|
|
6250
6653
|
// finalize (end the TTS utterance, settle the rendered message).
|
|
6251
|
-
if (proseStream) {
|
|
6654
|
+
if (frameReachedTerminal && proseStream) {
|
|
6252
6655
|
emitOutgoing(proseStream.finish());
|
|
6253
6656
|
for (const [channelId, text] of proseStream.byChannel()) {
|
|
6254
6657
|
this.channelRegistry.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text);
|
|
6255
6658
|
}
|
|
6256
6659
|
}
|
|
6257
6660
|
this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
|
|
6258
|
-
|
|
6259
|
-
|
|
6661
|
+
if (ownsPhysicalStream) {
|
|
6662
|
+
this.activeStreams.delete(agent.name);
|
|
6663
|
+
this.pendingAssistantBlocks.delete(agent.name);
|
|
6664
|
+
}
|
|
6260
6665
|
// A conversation fork whose TTL closure turn just finished is done for
|
|
6261
6666
|
// good — dispose it so the agent map doesn't grow monotonically.
|
|
6262
|
-
if (this.closingConversationAgents.has(agent.name)) {
|
|
6667
|
+
if (ownsPhysicalStream && this.closingConversationAgents.has(agent.name)) {
|
|
6263
6668
|
this.disposeConversationAgent(agent.name);
|
|
6264
6669
|
}
|
|
6265
6670
|
// The turn is torn down — release the turn-alive marker BEFORE the
|
|
@@ -6273,11 +6678,14 @@ export class AgentFramework {
|
|
|
6273
6678
|
this.activeTurnTokens.delete(agent.name);
|
|
6274
6679
|
this.activeTurnTriggers.delete(agent.name);
|
|
6275
6680
|
}
|
|
6276
|
-
// Flush any deferred messages (e.g. if stream failed while tools were
|
|
6277
|
-
|
|
6278
|
-
|
|
6681
|
+
// Flush any deferred messages (e.g. if stream failed while tools were
|
|
6682
|
+
// pending). Only THIS agent's messages: other targets' entries wait
|
|
6683
|
+
// for their own boundaries (re-adding via addMessage re-defers if the
|
|
6684
|
+
// target has meanwhile started a turn).
|
|
6685
|
+
if (frameReachedTerminal && this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
|
|
6686
|
+
const deferred = this.drainDeferredFor(agent.name);
|
|
6279
6687
|
for (const msg of deferred) {
|
|
6280
|
-
this.addMessage(msg.participant, msg.content, msg.metadata);
|
|
6688
|
+
this.addMessage(msg.participant, msg.content, msg.metadata, msg.forAgent ? { forAgent: msg.forAgent } : undefined);
|
|
6281
6689
|
}
|
|
6282
6690
|
}
|
|
6283
6691
|
}
|
|
@@ -7255,6 +7663,27 @@ export class AgentFramework {
|
|
|
7255
7663
|
this.dispatchGateToolCall(agentName, enrichedCall);
|
|
7256
7664
|
return;
|
|
7257
7665
|
}
|
|
7666
|
+
// Route the tune-out tool (residents) and the subconscious's surface
|
|
7667
|
+
if (enrichedCall.name === 'tune_out' && this.tuneOutCoordinator) {
|
|
7668
|
+
this.dispatchTuneOutToolCall(agentName, enrichedCall);
|
|
7669
|
+
return;
|
|
7670
|
+
}
|
|
7671
|
+
if (this.tuneOutCoordinator &&
|
|
7672
|
+
agentName === this.subconsciousAgentName &&
|
|
7673
|
+
SUBCONSCIOUS_TOOL_NAMES.includes(enrichedCall.name)) {
|
|
7674
|
+
void this.tuneOutCoordinator
|
|
7675
|
+
.handleSubconsciousTool(enrichedCall.name, enrichedCall.input)
|
|
7676
|
+
.then((result) => {
|
|
7677
|
+
this.queue.push({
|
|
7678
|
+
type: 'tool-result',
|
|
7679
|
+
callId: enrichedCall.id,
|
|
7680
|
+
agentName,
|
|
7681
|
+
moduleName: 'tune-out',
|
|
7682
|
+
result,
|
|
7683
|
+
});
|
|
7684
|
+
});
|
|
7685
|
+
return;
|
|
7686
|
+
}
|
|
7258
7687
|
// Route sleep / wake tools
|
|
7259
7688
|
if ((enrichedCall.name === 'sleep' || enrichedCall.name === 'wake') && this.eventGate) {
|
|
7260
7689
|
this.dispatchSleepToolCall(agentName, enrichedCall);
|
|
@@ -7383,12 +7812,19 @@ export class AgentFramework {
|
|
|
7383
7812
|
});
|
|
7384
7813
|
});
|
|
7385
7814
|
}
|
|
7386
|
-
addMessage(participant, content, metadata) {
|
|
7387
|
-
// Route to the
|
|
7388
|
-
const agent =
|
|
7389
|
-
? this.agents.get(
|
|
7390
|
-
: this.
|
|
7815
|
+
addMessage(participant, content, metadata, opts) {
|
|
7816
|
+
// Route to the named agent, else the primary (not ephemeral subagents).
|
|
7817
|
+
const agent = opts?.forAgent
|
|
7818
|
+
? this.agents.get(opts.forAgent)
|
|
7819
|
+
: this.primaryAgentName
|
|
7820
|
+
? this.agents.get(this.primaryAgentName)
|
|
7821
|
+
: this.agents.values().next().value;
|
|
7391
7822
|
if (!agent) {
|
|
7823
|
+
if (opts?.forAgent) {
|
|
7824
|
+
console.error(`[addMessage] delivery target "${opts.forAgent}" is not a registered agent — message dropped ` +
|
|
7825
|
+
`(participant=${participant})`);
|
|
7826
|
+
return '';
|
|
7827
|
+
}
|
|
7392
7828
|
throw new Error('No agents configured');
|
|
7393
7829
|
}
|
|
7394
7830
|
// Defer non-tool_result messages while a tool cycle is mid-flight
|
|
@@ -7408,15 +7844,39 @@ export class AgentFramework {
|
|
|
7408
7844
|
// ALSO injected into the live stream — hear-while-acting), or in
|
|
7409
7845
|
// driveStream's finally when the turn ends.
|
|
7410
7846
|
const hasToolResult = content.some(b => b.type === 'tool_result');
|
|
7847
|
+
// Explicitly-targeted deliveries scope the tool-cycle check to the
|
|
7848
|
+
// target (pendingAssistantBlocks is keyed by agent); the default path
|
|
7849
|
+
// keeps the historical GLOBAL check byte-for-byte (conservative: any
|
|
7850
|
+
// agent's pending cycle defers primary-bound messages).
|
|
7851
|
+
const midToolCycle = opts?.forAgent
|
|
7852
|
+
? this.pendingAssistantBlocks.has(agent.name)
|
|
7853
|
+
: this.pendingAssistantBlocks.size > 0;
|
|
7411
7854
|
if (!hasToolResult &&
|
|
7412
|
-
(
|
|
7855
|
+
(midToolCycle ||
|
|
7413
7856
|
this.activeTurnTokens.has(agent.name) ||
|
|
7414
7857
|
this.activeStreams.has(agent.name))) {
|
|
7415
|
-
this.deferredMessages.push({ participant, content, metadata });
|
|
7416
|
-
return ''; // Deferred — flushed at the next boundary
|
|
7858
|
+
this.deferredMessages.push({ participant, content, metadata, forAgent: opts?.forAgent });
|
|
7859
|
+
return ''; // Deferred — flushed at the target's next boundary
|
|
7417
7860
|
}
|
|
7418
7861
|
return agent.getContextManager().addMessage(participant, content, metadata);
|
|
7419
7862
|
}
|
|
7863
|
+
/**
|
|
7864
|
+
* Splice out the deferred messages addressed to `agentName` (explicitly
|
|
7865
|
+
* via forAgent, or implicitly when it is the primary), leaving other
|
|
7866
|
+
* targets' messages queued for their own boundaries.
|
|
7867
|
+
*/
|
|
7868
|
+
drainDeferredFor(agentName) {
|
|
7869
|
+
if (this.deferredMessages.length === 0)
|
|
7870
|
+
return [];
|
|
7871
|
+
const mine = [];
|
|
7872
|
+
const rest = [];
|
|
7873
|
+
for (const msg of this.deferredMessages) {
|
|
7874
|
+
const target = msg.forAgent ?? this.primaryAgentName;
|
|
7875
|
+
(target === agentName ? mine : rest).push(msg);
|
|
7876
|
+
}
|
|
7877
|
+
this.deferredMessages = rest;
|
|
7878
|
+
return mine;
|
|
7879
|
+
}
|
|
7420
7880
|
editMessage(id, content) {
|
|
7421
7881
|
const agent = this.primaryAgentName
|
|
7422
7882
|
? this.agents.get(this.primaryAgentName)
|
|
@@ -9400,6 +9860,50 @@ export class AgentFramework {
|
|
|
9400
9860
|
* suppression window, optionally announces in the sticky channel, and ends
|
|
9401
9861
|
* the turn (the agent goes idle immediately). `wake` clears sleep.
|
|
9402
9862
|
*/
|
|
9863
|
+
/** tune_out tool (residents): enter or cancel a channel tune-out. */
|
|
9864
|
+
dispatchTuneOutToolCall(agentName, call) {
|
|
9865
|
+
const coordinator = this.tuneOutCoordinator;
|
|
9866
|
+
const input = (call.input ?? {});
|
|
9867
|
+
const channelId = String(input.channelId ?? '');
|
|
9868
|
+
const entry = this.channelRegistry?.listChannelsRaw()
|
|
9869
|
+
.find((e) => e.descriptor.id === channelId);
|
|
9870
|
+
let result;
|
|
9871
|
+
if (!entry) {
|
|
9872
|
+
result = { success: false, error: `unknown channel: ${channelId}`, isError: false };
|
|
9873
|
+
}
|
|
9874
|
+
else if (input.mode === 'cancel') {
|
|
9875
|
+
const r = coordinator.cancel(entry.serverId, channelId, 'agent-tool', 'agent request');
|
|
9876
|
+
result = r.ok
|
|
9877
|
+
? { success: true, data: { cancelled: channelId } }
|
|
9878
|
+
: { success: false, error: r.error, isError: false };
|
|
9879
|
+
}
|
|
9880
|
+
else {
|
|
9881
|
+
const r = coordinator.enter(entry.serverId, channelId, {
|
|
9882
|
+
cadenceSeconds: input.cadenceSeconds,
|
|
9883
|
+
backlogCap: input.backlogCap,
|
|
9884
|
+
maxWakes: input.maxWakes,
|
|
9885
|
+
durationSeconds: input.durationSeconds,
|
|
9886
|
+
}, 'agent-tool');
|
|
9887
|
+
result = r.ok
|
|
9888
|
+
? {
|
|
9889
|
+
success: true,
|
|
9890
|
+
data: {
|
|
9891
|
+
tunedOut: channelId,
|
|
9892
|
+
cadenceSeconds: r.params.cadenceSeconds,
|
|
9893
|
+
backlogCap: r.params.backlogCap,
|
|
9894
|
+
maxWakes: r.params.maxWakes,
|
|
9895
|
+
},
|
|
9896
|
+
}
|
|
9897
|
+
: { success: false, error: r.error, isError: false };
|
|
9898
|
+
}
|
|
9899
|
+
this.queue.push({
|
|
9900
|
+
type: 'tool-result',
|
|
9901
|
+
callId: call.id,
|
|
9902
|
+
agentName,
|
|
9903
|
+
moduleName: 'tune-out',
|
|
9904
|
+
result,
|
|
9905
|
+
});
|
|
9906
|
+
}
|
|
9403
9907
|
dispatchSleepToolCall(agentName, call) {
|
|
9404
9908
|
this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
|
|
9405
9909
|
const gate = this.eventGate;
|