@animalabs/agent-framework 0.1.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/LICENSE +21 -0
- package/README.md +233 -0
- package/dist/src/agent.d.ts +130 -0
- package/dist/src/agent.d.ts.map +1 -0
- package/dist/src/agent.js +406 -0
- package/dist/src/agent.js.map +1 -0
- package/dist/src/api/mcp-server.d.ts +37 -0
- package/dist/src/api/mcp-server.d.ts.map +1 -0
- package/dist/src/api/mcp-server.js +598 -0
- package/dist/src/api/mcp-server.js.map +1 -0
- package/dist/src/api/server.d.ts +83 -0
- package/dist/src/api/server.d.ts.map +1 -0
- package/dist/src/api/server.js +785 -0
- package/dist/src/api/server.js.map +1 -0
- package/dist/src/api/types.d.ts +395 -0
- package/dist/src/api/types.d.ts.map +1 -0
- package/dist/src/api/types.js +5 -0
- package/dist/src/api/types.js.map +1 -0
- package/dist/src/framework.d.ts +309 -0
- package/dist/src/framework.d.ts.map +1 -0
- package/dist/src/framework.js +2309 -0
- package/dist/src/framework.js.map +1 -0
- package/dist/src/gate/event-gate.d.ts +75 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -0
- package/dist/src/gate/event-gate.js +503 -0
- package/dist/src/gate/event-gate.js.map +1 -0
- package/dist/src/gate/index.d.ts +3 -0
- package/dist/src/gate/index.d.ts.map +1 -0
- package/dist/src/gate/index.js +2 -0
- package/dist/src/gate/index.js.map +1 -0
- package/dist/src/gate/types.d.ts +82 -0
- package/dist/src/gate/types.d.ts.map +1 -0
- package/dist/src/gate/types.js +9 -0
- package/dist/src/gate/types.js.map +1 -0
- package/dist/src/index.d.ts +17 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +18 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/mcpl/channel-registry.d.ts +141 -0
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -0
- package/dist/src/mcpl/channel-registry.js +647 -0
- package/dist/src/mcpl/channel-registry.js.map +1 -0
- package/dist/src/mcpl/checkpoint-manager.d.ts +79 -0
- package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -0
- package/dist/src/mcpl/checkpoint-manager.js +430 -0
- package/dist/src/mcpl/checkpoint-manager.js.map +1 -0
- package/dist/src/mcpl/errors.d.ts +52 -0
- package/dist/src/mcpl/errors.d.ts.map +1 -0
- package/dist/src/mcpl/errors.js +93 -0
- package/dist/src/mcpl/errors.js.map +1 -0
- package/dist/src/mcpl/feature-set-manager.d.ts +91 -0
- package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -0
- package/dist/src/mcpl/feature-set-manager.js +250 -0
- package/dist/src/mcpl/feature-set-manager.js.map +1 -0
- package/dist/src/mcpl/hook-orchestrator.d.ts +51 -0
- package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -0
- package/dist/src/mcpl/hook-orchestrator.js +217 -0
- package/dist/src/mcpl/hook-orchestrator.js.map +1 -0
- package/dist/src/mcpl/index.d.ts +14 -0
- package/dist/src/mcpl/index.d.ts.map +1 -0
- package/dist/src/mcpl/index.js +21 -0
- package/dist/src/mcpl/index.js.map +1 -0
- package/dist/src/mcpl/inference-router.d.ts +67 -0
- package/dist/src/mcpl/inference-router.d.ts.map +1 -0
- package/dist/src/mcpl/inference-router.js +249 -0
- package/dist/src/mcpl/inference-router.js.map +1 -0
- package/dist/src/mcpl/push-handler.d.ts +60 -0
- package/dist/src/mcpl/push-handler.d.ts.map +1 -0
- package/dist/src/mcpl/push-handler.js +162 -0
- package/dist/src/mcpl/push-handler.js.map +1 -0
- package/dist/src/mcpl/scope-manager.d.ts +76 -0
- package/dist/src/mcpl/scope-manager.d.ts.map +1 -0
- package/dist/src/mcpl/scope-manager.js +213 -0
- package/dist/src/mcpl/scope-manager.js.map +1 -0
- package/dist/src/mcpl/server-connection.d.ts +174 -0
- package/dist/src/mcpl/server-connection.d.ts.map +1 -0
- package/dist/src/mcpl/server-connection.js +559 -0
- package/dist/src/mcpl/server-connection.js.map +1 -0
- package/dist/src/mcpl/server-registry.d.ts +63 -0
- package/dist/src/mcpl/server-registry.d.ts.map +1 -0
- package/dist/src/mcpl/server-registry.js +119 -0
- package/dist/src/mcpl/server-registry.js.map +1 -0
- package/dist/src/mcpl/types.d.ts +755 -0
- package/dist/src/mcpl/types.d.ts.map +1 -0
- package/dist/src/mcpl/types.js +48 -0
- package/dist/src/mcpl/types.js.map +1 -0
- package/dist/src/module-registry.d.ts +93 -0
- package/dist/src/module-registry.d.ts.map +1 -0
- package/dist/src/module-registry.js +386 -0
- package/dist/src/module-registry.js.map +1 -0
- package/dist/src/modules/api/index.d.ts +22 -0
- package/dist/src/modules/api/index.d.ts.map +1 -0
- package/dist/src/modules/api/index.js +63 -0
- package/dist/src/modules/api/index.js.map +1 -0
- package/dist/src/modules/discord/discord-js-client.d.ts +58 -0
- package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -0
- package/dist/src/modules/discord/discord-js-client.js +273 -0
- package/dist/src/modules/discord/discord-js-client.js.map +1 -0
- package/dist/src/modules/discord/index.d.ts +82 -0
- package/dist/src/modules/discord/index.d.ts.map +1 -0
- package/dist/src/modules/discord/index.js +959 -0
- package/dist/src/modules/discord/index.js.map +1 -0
- package/dist/src/modules/discord/types.d.ts +327 -0
- package/dist/src/modules/discord/types.d.ts.map +1 -0
- package/dist/src/modules/discord/types.js +5 -0
- package/dist/src/modules/discord/types.js.map +1 -0
- package/dist/src/modules/index.d.ts +11 -0
- package/dist/src/modules/index.d.ts.map +1 -0
- package/dist/src/modules/index.js +7 -0
- package/dist/src/modules/index.js.map +1 -0
- package/dist/src/modules/workspace/index.d.ts +59 -0
- package/dist/src/modules/workspace/index.d.ts.map +1 -0
- package/dist/src/modules/workspace/index.js +831 -0
- package/dist/src/modules/workspace/index.js.map +1 -0
- package/dist/src/modules/workspace/sync.d.ts +48 -0
- package/dist/src/modules/workspace/sync.d.ts.map +1 -0
- package/dist/src/modules/workspace/sync.js +253 -0
- package/dist/src/modules/workspace/sync.js.map +1 -0
- package/dist/src/modules/workspace/types.d.ts +166 -0
- package/dist/src/modules/workspace/types.d.ts.map +1 -0
- package/dist/src/modules/workspace/types.js +5 -0
- package/dist/src/modules/workspace/types.js.map +1 -0
- package/dist/src/modules/workspace/watcher.d.ts +43 -0
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -0
- package/dist/src/modules/workspace/watcher.js +120 -0
- package/dist/src/modules/workspace/watcher.js.map +1 -0
- package/dist/src/queue.d.ts +50 -0
- package/dist/src/queue.d.ts.map +1 -0
- package/dist/src/queue.js +95 -0
- package/dist/src/queue.js.map +1 -0
- package/dist/src/safe-slice.d.ts +8 -0
- package/dist/src/safe-slice.d.ts.map +1 -0
- package/dist/src/safe-slice.js +18 -0
- package/dist/src/safe-slice.js.map +1 -0
- package/dist/src/types/agent.d.ts +118 -0
- package/dist/src/types/agent.d.ts.map +1 -0
- package/dist/src/types/agent.js +2 -0
- package/dist/src/types/agent.js.map +1 -0
- package/dist/src/types/events.d.ts +220 -0
- package/dist/src/types/events.d.ts.map +1 -0
- package/dist/src/types/events.js +2 -0
- package/dist/src/types/events.js.map +1 -0
- package/dist/src/types/framework.d.ts +184 -0
- package/dist/src/types/framework.d.ts.map +1 -0
- package/dist/src/types/framework.js +2 -0
- package/dist/src/types/framework.js.map +1 -0
- package/dist/src/types/index.d.ts +7 -0
- package/dist/src/types/index.d.ts.map +1 -0
- package/dist/src/types/index.js +7 -0
- package/dist/src/types/index.js.map +1 -0
- package/dist/src/types/inference-log.d.ts +64 -0
- package/dist/src/types/inference-log.d.ts.map +1 -0
- package/dist/src/types/inference-log.js +5 -0
- package/dist/src/types/inference-log.js.map +1 -0
- package/dist/src/types/module.d.ts +302 -0
- package/dist/src/types/module.d.ts.map +1 -0
- package/dist/src/types/module.js +2 -0
- package/dist/src/types/module.js.map +1 -0
- package/dist/src/types/trace.d.ts +173 -0
- package/dist/src/types/trace.d.ts.map +1 -0
- package/dist/src/types/trace.js +2 -0
- package/dist/src/types/trace.js.map +1 -0
- package/dist/test/event-gate.test.d.ts +2 -0
- package/dist/test/event-gate.test.d.ts.map +1 -0
- package/dist/test/event-gate.test.js +535 -0
- package/dist/test/event-gate.test.js.map +1 -0
- package/dist/test/framework.test.d.ts +5 -0
- package/dist/test/framework.test.d.ts.map +1 -0
- package/dist/test/framework.test.js +738 -0
- package/dist/test/framework.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +58 -0
- package/src/agent.ts +512 -0
- package/src/api/mcp-server.ts +687 -0
- package/src/api/server.ts +1023 -0
- package/src/api/types.ts +542 -0
- package/src/framework.ts +2784 -0
- package/src/gate/event-gate.ts +623 -0
- package/src/gate/index.ts +12 -0
- package/src/gate/types.ts +112 -0
- package/src/index.ts +63 -0
- package/src/mcpl/channel-registry.ts +824 -0
- package/src/mcpl/checkpoint-manager.ts +545 -0
- package/src/mcpl/errors.ts +114 -0
- package/src/mcpl/feature-set-manager.ts +333 -0
- package/src/mcpl/hook-orchestrator.ts +281 -0
- package/src/mcpl/index.ts +134 -0
- package/src/mcpl/inference-router.ts +335 -0
- package/src/mcpl/push-handler.ts +232 -0
- package/src/mcpl/scope-manager.ts +262 -0
- package/src/mcpl/server-connection.ts +694 -0
- package/src/mcpl/server-registry.ts +144 -0
- package/src/mcpl/types.ts +991 -0
- package/src/module-registry.ts +547 -0
- package/src/modules/api/index.ts +94 -0
- package/src/modules/discord/discord-js-client.ts +353 -0
- package/src/modules/discord/index.ts +1146 -0
- package/src/modules/discord/types.ts +465 -0
- package/src/modules/index.ts +43 -0
- package/src/modules/workspace/index.ts +969 -0
- package/src/modules/workspace/sync.ts +314 -0
- package/src/modules/workspace/types.ts +202 -0
- package/src/modules/workspace/watcher.ts +142 -0
- package/src/queue.ts +106 -0
- package/src/safe-slice.ts +18 -0
- package/src/types/agent.ts +119 -0
- package/src/types/events.ts +252 -0
- package/src/types/framework.ts +211 -0
- package/src/types/index.ts +6 -0
- package/src/types/inference-log.ts +64 -0
- package/src/types/module.ts +367 -0
- package/src/types/trace.ts +196 -0
|
@@ -0,0 +1,2309 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { JsStore } from '@animalabs/chronicle';
|
|
3
|
+
import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager';
|
|
4
|
+
import { ProcessQueueImpl } from './queue.js';
|
|
5
|
+
import { Agent } from './agent.js';
|
|
6
|
+
import { ModuleRegistry } from './module-registry.js';
|
|
7
|
+
import { McplServerRegistry } from './mcpl/server-registry.js';
|
|
8
|
+
import { FeatureSetManager } from './mcpl/feature-set-manager.js';
|
|
9
|
+
import { ScopeManager } from './mcpl/scope-manager.js';
|
|
10
|
+
import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
|
|
11
|
+
import { PushHandler } from './mcpl/push-handler.js';
|
|
12
|
+
import { InferenceRouter } from './mcpl/inference-router.js';
|
|
13
|
+
import { ChannelRegistry } from './mcpl/channel-registry.js';
|
|
14
|
+
import { safeSlice } from './safe-slice.js';
|
|
15
|
+
import { CheckpointManager } from './mcpl/checkpoint-manager.js';
|
|
16
|
+
import { EventGate } from './gate/event-gate.js';
|
|
17
|
+
const FRAMEWORK_STATE_ID = 'framework/state';
|
|
18
|
+
const INFERENCE_LOG_ID = 'framework/inference-log';
|
|
19
|
+
const PROCESS_LOG_ID = 'framework/process-log';
|
|
20
|
+
const TURN_CHECKPOINTS_ID = 'framework/turn-checkpoints';
|
|
21
|
+
/** Maximum number of turn checkpoints to keep per agent. */
|
|
22
|
+
const MAX_TURN_CHECKPOINTS = 20;
|
|
23
|
+
/**
|
|
24
|
+
* Default inference policy - infer if any request exists for the agent.
|
|
25
|
+
*/
|
|
26
|
+
class DefaultInferencePolicy {
|
|
27
|
+
shouldInfer(agentName, requests, _state) {
|
|
28
|
+
return requests.some((r) => r.agentName === agentName);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Default error policy - retry with exponential backoff.
|
|
33
|
+
*/
|
|
34
|
+
class DefaultErrorPolicy {
|
|
35
|
+
maxRetries = 3;
|
|
36
|
+
onInferenceError(error, _agentName, attempt) {
|
|
37
|
+
if (attempt < this.maxRetries) {
|
|
38
|
+
return { retry: true, delayMs: Math.pow(2, attempt) * 1000 };
|
|
39
|
+
}
|
|
40
|
+
return { retry: false };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Default sync interval in milliseconds */
|
|
44
|
+
const DEFAULT_SYNC_INTERVAL_MS = 1000;
|
|
45
|
+
/**
|
|
46
|
+
* The main agent framework.
|
|
47
|
+
*/
|
|
48
|
+
export class AgentFramework {
|
|
49
|
+
store;
|
|
50
|
+
ownsStore;
|
|
51
|
+
membrane;
|
|
52
|
+
queue;
|
|
53
|
+
agents = new Map();
|
|
54
|
+
moduleRegistry;
|
|
55
|
+
inferencePolicy;
|
|
56
|
+
errorPolicy;
|
|
57
|
+
pendingRequests = [];
|
|
58
|
+
running = false;
|
|
59
|
+
loopPromise = null;
|
|
60
|
+
traceListeners = [];
|
|
61
|
+
syncIntervalMs;
|
|
62
|
+
syncTimer = null;
|
|
63
|
+
processLoggingPersist;
|
|
64
|
+
processLoggingBroadcast;
|
|
65
|
+
activeStreams = new Map();
|
|
66
|
+
pendingAssistantBlocks = new Map();
|
|
67
|
+
/** Name of the primary (non-ephemeral) agent for routing framework messages. */
|
|
68
|
+
primaryAgentName = null;
|
|
69
|
+
// Messages deferred while an agent is waiting_for_tools (to preserve
|
|
70
|
+
// tool_use → tool_result adjacency required by the Anthropic API).
|
|
71
|
+
deferredMessages = [];
|
|
72
|
+
// Undo/redo state
|
|
73
|
+
turnCounters = new Map(); // agentName → next turnIndex
|
|
74
|
+
redoStacks = new Map(); // agentName → redo entries
|
|
75
|
+
// MCPL subsystems (null when no mcplServers configured)
|
|
76
|
+
mcplServerRegistry = null;
|
|
77
|
+
featureSetManager = null;
|
|
78
|
+
scopeManager = null;
|
|
79
|
+
hookOrchestrator = null;
|
|
80
|
+
pushHandler = null;
|
|
81
|
+
inferenceRouter = null;
|
|
82
|
+
channelRegistry = null;
|
|
83
|
+
checkpointManager = null;
|
|
84
|
+
mcplTools = [];
|
|
85
|
+
mcplToolRefreshInFlight = false;
|
|
86
|
+
mcplToolRefreshPending = false;
|
|
87
|
+
/** Maps tool prefix → serverId for dispatch routing. */
|
|
88
|
+
mcplPrefixMap = new Map();
|
|
89
|
+
/** Maps serverId → McplServerConfig for prefix lookup. */
|
|
90
|
+
mcplServerConfigs = new Map();
|
|
91
|
+
// EventGate (null when FrameworkConfig.gate is omitted)
|
|
92
|
+
eventGate = null;
|
|
93
|
+
constructor(store, ownsStore, membrane, inferencePolicy, errorPolicy, syncIntervalMs, processLoggingPersist, processLoggingBroadcast) {
|
|
94
|
+
this.store = store;
|
|
95
|
+
this.ownsStore = ownsStore;
|
|
96
|
+
this.membrane = membrane;
|
|
97
|
+
this.inferencePolicy = inferencePolicy;
|
|
98
|
+
this.errorPolicy = errorPolicy;
|
|
99
|
+
this.syncIntervalMs = syncIntervalMs;
|
|
100
|
+
this.processLoggingPersist = processLoggingPersist;
|
|
101
|
+
this.processLoggingBroadcast = processLoggingBroadcast;
|
|
102
|
+
this.queue = new ProcessQueueImpl();
|
|
103
|
+
// Initialize module registry with callbacks
|
|
104
|
+
this.moduleRegistry = new ModuleRegistry(store, this.queue, {
|
|
105
|
+
getAgents: () => Array.from(this.agents.values()),
|
|
106
|
+
addMessage: (p, c, m) => this.addMessage(p, c, m),
|
|
107
|
+
editMessage: (id, c) => this.editMessage(id, c),
|
|
108
|
+
removeMessage: (id) => this.removeMessage(id),
|
|
109
|
+
getMessage: (id) => this.getMessage(id),
|
|
110
|
+
queryMessages: (filter) => this.queryMessages(filter),
|
|
111
|
+
pushEvent: (event) => this.pushEvent(event),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Create and start the framework.
|
|
116
|
+
*/
|
|
117
|
+
static async create(config) {
|
|
118
|
+
// Create or use existing store
|
|
119
|
+
let store;
|
|
120
|
+
let ownsStore;
|
|
121
|
+
if (config.store) {
|
|
122
|
+
store = config.store;
|
|
123
|
+
ownsStore = false;
|
|
124
|
+
}
|
|
125
|
+
else if (config.storePath) {
|
|
126
|
+
store = JsStore.openOrCreate({ path: config.storePath });
|
|
127
|
+
ownsStore = true;
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
throw new Error('Either storePath or store must be provided');
|
|
131
|
+
}
|
|
132
|
+
// Register framework states
|
|
133
|
+
try {
|
|
134
|
+
store.registerState({ id: FRAMEWORK_STATE_ID, strategy: 'snapshot' });
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// Already registered
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
store.registerState({
|
|
141
|
+
id: INFERENCE_LOG_ID,
|
|
142
|
+
strategy: 'append_log',
|
|
143
|
+
deltaSnapshotEvery: 100,
|
|
144
|
+
fullSnapshotEvery: 20,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// Already registered
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
store.registerState({ id: TURN_CHECKPOINTS_ID, strategy: 'snapshot' });
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// Already registered
|
|
155
|
+
}
|
|
156
|
+
// Process logging config (default: disabled)
|
|
157
|
+
const processLoggingPersist = config.processLogging?.persist ?? false;
|
|
158
|
+
const processLoggingBroadcast = config.processLogging?.broadcast ?? false;
|
|
159
|
+
// Register process log state only if persistence is enabled
|
|
160
|
+
if (processLoggingPersist) {
|
|
161
|
+
try {
|
|
162
|
+
store.registerState({
|
|
163
|
+
id: PROCESS_LOG_ID,
|
|
164
|
+
strategy: 'append_log',
|
|
165
|
+
deltaSnapshotEvery: 100,
|
|
166
|
+
fullSnapshotEvery: 20,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// Already registered
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const framework = new AgentFramework(store, ownsStore, config.membrane, config.inferencePolicy ?? new DefaultInferencePolicy(), config.errorPolicy ?? new DefaultErrorPolicy(), config.syncIntervalMs ?? DEFAULT_SYNC_INTERVAL_MS, processLoggingPersist, processLoggingBroadcast);
|
|
174
|
+
// Create agents
|
|
175
|
+
for (const agentConfig of config.agents) {
|
|
176
|
+
await framework.createAgent(agentConfig);
|
|
177
|
+
}
|
|
178
|
+
// Add modules
|
|
179
|
+
for (const module of config.modules) {
|
|
180
|
+
await framework.addModule(module);
|
|
181
|
+
}
|
|
182
|
+
// Initialize EventGate if configured (before MCPL so it can be wired as trigger filter)
|
|
183
|
+
if (config.gate) {
|
|
184
|
+
const configPath = config.gate.configPath
|
|
185
|
+
?? (config.storePath
|
|
186
|
+
? join(config.storePath, 'config', 'gate.json')
|
|
187
|
+
: './data/gate.json');
|
|
188
|
+
framework.eventGate = new EventGate({
|
|
189
|
+
configPath,
|
|
190
|
+
initialConfig: config.gate.config,
|
|
191
|
+
emitTrace: (e) => framework.emitTrace(e),
|
|
192
|
+
addMessage: (p, c, m) => framework.addMessage(p, c, m),
|
|
193
|
+
requestInference: (agentName, reason, source) => {
|
|
194
|
+
framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
|
|
195
|
+
},
|
|
196
|
+
getAgentNames: () => [...framework.agents.keys()],
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
// Initialize MCPL subsystems if configured
|
|
200
|
+
if (config.mcplServers && config.mcplServers.length > 0) {
|
|
201
|
+
// Validate tool prefixes: no collisions with module names or between servers
|
|
202
|
+
const moduleNames = new Set(config.modules.map(m => m.name));
|
|
203
|
+
const prefixesSeen = new Map(); // prefix → serverId
|
|
204
|
+
for (const serverConfig of config.mcplServers) {
|
|
205
|
+
const prefix = serverConfig.toolPrefix ?? `mcpl--${serverConfig.id}`;
|
|
206
|
+
if (moduleNames.has(prefix)) {
|
|
207
|
+
throw new Error(`MCPL server "${serverConfig.id}" toolPrefix "${prefix}" collides with module "${prefix}"`);
|
|
208
|
+
}
|
|
209
|
+
const existing = prefixesSeen.get(prefix);
|
|
210
|
+
if (existing) {
|
|
211
|
+
throw new Error(`MCPL server "${serverConfig.id}" toolPrefix "${prefix}" collides with server "${existing}"`);
|
|
212
|
+
}
|
|
213
|
+
prefixesSeen.set(prefix, serverConfig.id);
|
|
214
|
+
}
|
|
215
|
+
await framework.initializeMcpl(config.mcplServers, config.inferenceRouting);
|
|
216
|
+
}
|
|
217
|
+
return framework;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Start the event loop.
|
|
221
|
+
*/
|
|
222
|
+
start() {
|
|
223
|
+
if (this.running) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
this.running = true;
|
|
227
|
+
this.loopPromise = this.runLoop();
|
|
228
|
+
// Start periodic sync timer (if enabled)
|
|
229
|
+
if (this.syncIntervalMs > 0) {
|
|
230
|
+
this.syncTimer = setInterval(() => {
|
|
231
|
+
try {
|
|
232
|
+
this.store.sync();
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
console.error('Periodic sync error:', error);
|
|
236
|
+
}
|
|
237
|
+
}, this.syncIntervalMs);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Stop the event loop.
|
|
242
|
+
*/
|
|
243
|
+
async stop() {
|
|
244
|
+
this.running = false;
|
|
245
|
+
this.queue.close();
|
|
246
|
+
// Cancel all active streams
|
|
247
|
+
for (const agent of this.agents.values()) {
|
|
248
|
+
if (agent.state.status === 'streaming' ||
|
|
249
|
+
(agent.state.status === 'waiting_for_tools' && agent.state.stream)) {
|
|
250
|
+
agent.cancelStream();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Wait for all stream iteration handles to settle
|
|
254
|
+
if (this.activeStreams.size > 0) {
|
|
255
|
+
await Promise.allSettled(this.activeStreams.values());
|
|
256
|
+
}
|
|
257
|
+
// Stop sync timer
|
|
258
|
+
if (this.syncTimer) {
|
|
259
|
+
clearInterval(this.syncTimer);
|
|
260
|
+
this.syncTimer = null;
|
|
261
|
+
}
|
|
262
|
+
if (this.loopPromise) {
|
|
263
|
+
await this.loopPromise;
|
|
264
|
+
}
|
|
265
|
+
// Stop typing indicators
|
|
266
|
+
this.channelRegistry?.stopAll();
|
|
267
|
+
// Dispose EventGate (clear debounce timers)
|
|
268
|
+
this.eventGate?.dispose();
|
|
269
|
+
// Stop modules and MCPL servers in parallel
|
|
270
|
+
const shutdownPromises = [this.moduleRegistry.stopAll()];
|
|
271
|
+
if (this.mcplServerRegistry) {
|
|
272
|
+
shutdownPromises.push(this.mcplServerRegistry.closeAll());
|
|
273
|
+
}
|
|
274
|
+
await Promise.all(shutdownPromises);
|
|
275
|
+
// Final sync before closing
|
|
276
|
+
try {
|
|
277
|
+
this.store.sync();
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
console.error('Final sync error:', error);
|
|
281
|
+
}
|
|
282
|
+
if (this.ownsStore) {
|
|
283
|
+
this.store.close();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Push a process event to the queue.
|
|
288
|
+
*/
|
|
289
|
+
pushEvent(event) {
|
|
290
|
+
this.queue.push(event);
|
|
291
|
+
this.emitTrace({ type: 'process:received', processEvent: event });
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Add a trace event listener for observability.
|
|
295
|
+
*/
|
|
296
|
+
onTrace(listener) {
|
|
297
|
+
this.traceListeners.push(listener);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Remove a trace event listener.
|
|
301
|
+
*/
|
|
302
|
+
offTrace(listener) {
|
|
303
|
+
const index = this.traceListeners.indexOf(listener);
|
|
304
|
+
if (index >= 0) {
|
|
305
|
+
this.traceListeners.splice(index, 1);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Add a module at runtime.
|
|
310
|
+
*/
|
|
311
|
+
async addModule(module) {
|
|
312
|
+
await this.moduleRegistry.addModule(module);
|
|
313
|
+
this.emitTrace({ type: 'module:added', moduleName: module.name });
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Remove a module at runtime.
|
|
317
|
+
*/
|
|
318
|
+
async removeModule(name) {
|
|
319
|
+
await this.moduleRegistry.removeModule(name);
|
|
320
|
+
this.emitTrace({ type: 'module:removed', moduleName: name });
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Get an agent by name.
|
|
324
|
+
*/
|
|
325
|
+
getAgent(name) {
|
|
326
|
+
return this.agents.get(name) ?? null;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Get all agents.
|
|
330
|
+
*/
|
|
331
|
+
getAllAgents() {
|
|
332
|
+
return Array.from(this.agents.values());
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Abort an in-flight inference for an agent.
|
|
336
|
+
*/
|
|
337
|
+
abortInference(agentName, reason) {
|
|
338
|
+
const agent = this.agents.get(agentName);
|
|
339
|
+
if (!agent) {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
const result = agent.abortInference(reason);
|
|
343
|
+
if (result) {
|
|
344
|
+
this.emitTrace({ type: 'inference:aborted', agentName, reason, durationMs: result.durationMs });
|
|
345
|
+
}
|
|
346
|
+
return !!result;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Get all registered modules.
|
|
350
|
+
*/
|
|
351
|
+
getAllModules() {
|
|
352
|
+
return this.moduleRegistry.getAllModules();
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Get all available tools from all modules and MCPL servers.
|
|
356
|
+
*/
|
|
357
|
+
getAllTools() {
|
|
358
|
+
const moduleTools = this.moduleRegistry.getAllTools();
|
|
359
|
+
const channelTools = this.channelRegistry?.getChannelTools() ?? [];
|
|
360
|
+
const gateTools = this.eventGate ? [this.eventGate.getToolDefinition()] : [];
|
|
361
|
+
if (this.mcplTools.length === 0 && channelTools.length === 0 && gateTools.length === 0) {
|
|
362
|
+
return moduleTools;
|
|
363
|
+
}
|
|
364
|
+
return [...moduleTools, ...this.mcplTools, ...channelTools, ...gateTools];
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Check if process logging is enabled.
|
|
368
|
+
*/
|
|
369
|
+
isProcessLoggingEnabled() {
|
|
370
|
+
return {
|
|
371
|
+
persist: this.processLoggingPersist,
|
|
372
|
+
broadcast: this.processLoggingBroadcast,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Get the underlying store.
|
|
377
|
+
*/
|
|
378
|
+
getStore() {
|
|
379
|
+
return this.store;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Get a registered module by name.
|
|
383
|
+
*/
|
|
384
|
+
getModule(name) {
|
|
385
|
+
return this.moduleRegistry.getModule(name);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Get the Membrane instance.
|
|
389
|
+
*/
|
|
390
|
+
getMembrane() {
|
|
391
|
+
return this.membrane;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Create an ephemeral agent that is NOT registered in the main event loop.
|
|
395
|
+
*
|
|
396
|
+
* Used by SubagentModule (and similar) to create short-lived agents
|
|
397
|
+
* that are driven externally (not by the framework's event loop).
|
|
398
|
+
* The returned agent has its own ContextManager on a namespaced state
|
|
399
|
+
* within the framework's shared Chronicle store — messages go to
|
|
400
|
+
* `{namespace}/messages`, context log to `{namespace}/context`.
|
|
401
|
+
*
|
|
402
|
+
* Data persists after cleanup for investigation and cross-revert.
|
|
403
|
+
* Call cleanup() when done to release the ContextManager.
|
|
404
|
+
*/
|
|
405
|
+
async createEphemeralAgent(config) {
|
|
406
|
+
const namespace = `subagent/${config.name}`;
|
|
407
|
+
const contextManager = await ContextManager.open({
|
|
408
|
+
store: this.store,
|
|
409
|
+
namespace,
|
|
410
|
+
isolate: true,
|
|
411
|
+
strategy: config.strategy ?? new PassthroughStrategy(),
|
|
412
|
+
membrane: this.membrane,
|
|
413
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
414
|
+
});
|
|
415
|
+
const agent = new Agent(config, contextManager, this.membrane);
|
|
416
|
+
const cleanup = () => {
|
|
417
|
+
// Don't close the store — it's shared. Just release the CM.
|
|
418
|
+
// Data persists in the store under the namespace for investigation.
|
|
419
|
+
};
|
|
420
|
+
return { agent, contextManager, cleanup };
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Run an ephemeral agent to completion through the framework's event loop.
|
|
424
|
+
*
|
|
425
|
+
* The agent is temporarily registered, inference is triggered, and the
|
|
426
|
+
* framework drives the stream (emitting traces, logging, dispatching tools).
|
|
427
|
+
* Returns the agent's speech output when it finishes (no more tool calls).
|
|
428
|
+
*
|
|
429
|
+
* The caller provides a pre-created agent + contextManager (from createEphemeralAgent).
|
|
430
|
+
* The task message should already be in the context manager.
|
|
431
|
+
*/
|
|
432
|
+
async runEphemeralToCompletion(agent, contextManager) {
|
|
433
|
+
// Register temporarily so the event loop can drive it
|
|
434
|
+
this.agents.set(agent.name, agent);
|
|
435
|
+
return new Promise((resolve, reject) => {
|
|
436
|
+
let speech = '';
|
|
437
|
+
let toolCallsCount = 0;
|
|
438
|
+
let settled = false;
|
|
439
|
+
let inferenceStarted = false;
|
|
440
|
+
const STARTUP_TIMEOUT_MS = 30_000;
|
|
441
|
+
const cleanup = () => {
|
|
442
|
+
if (settled)
|
|
443
|
+
return;
|
|
444
|
+
settled = true;
|
|
445
|
+
clearTimeout(startupWatchdog);
|
|
446
|
+
this.offTrace(traceListener);
|
|
447
|
+
this.agents.delete(agent.name);
|
|
448
|
+
};
|
|
449
|
+
// Watchdog: if inference never starts within 30s, the agent is a zombie.
|
|
450
|
+
// This catches cases where the event loop stalled, the agent was
|
|
451
|
+
// deregistered, or processInferenceRequests() skipped the request.
|
|
452
|
+
const startupWatchdog = setTimeout(() => {
|
|
453
|
+
if (!inferenceStarted && !settled) {
|
|
454
|
+
cleanup();
|
|
455
|
+
reject(new Error(`Ephemeral agent "${agent.name}" failed to start inference within ` +
|
|
456
|
+
`${STARTUP_TIMEOUT_MS}ms — zombie detected. The event loop may have ` +
|
|
457
|
+
`stalled or the inference request was dropped.`));
|
|
458
|
+
}
|
|
459
|
+
}, STARTUP_TIMEOUT_MS);
|
|
460
|
+
const traceListener = (event) => {
|
|
461
|
+
if (settled)
|
|
462
|
+
return;
|
|
463
|
+
// Only track events for our ephemeral agent
|
|
464
|
+
const agentName = 'agentName' in event ? event.agentName : null;
|
|
465
|
+
if (agentName !== agent.name)
|
|
466
|
+
return;
|
|
467
|
+
switch (event.type) {
|
|
468
|
+
case 'inference:started':
|
|
469
|
+
inferenceStarted = true;
|
|
470
|
+
break;
|
|
471
|
+
case 'inference:tokens': {
|
|
472
|
+
inferenceStarted = true;
|
|
473
|
+
const content = event.content;
|
|
474
|
+
if (content)
|
|
475
|
+
speech += content;
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
case 'inference:tool_calls_yielded': {
|
|
479
|
+
const calls = event.calls;
|
|
480
|
+
if (calls)
|
|
481
|
+
toolCallsCount += calls.length;
|
|
482
|
+
// Reset speech buffer — tool calls break the text
|
|
483
|
+
speech = '';
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
case 'inference:stream_resumed':
|
|
487
|
+
speech = '';
|
|
488
|
+
break;
|
|
489
|
+
case 'inference:completed': {
|
|
490
|
+
// Check if agent is idle (no pending tool calls = truly done)
|
|
491
|
+
// If tools were called, the agent will cycle through waiting_for_tools → ready → streaming
|
|
492
|
+
// and eventually complete again. We only resolve when there are no more tool calls.
|
|
493
|
+
if (agent.state.status === 'idle') {
|
|
494
|
+
cleanup();
|
|
495
|
+
resolve({ speech, toolCallsCount });
|
|
496
|
+
}
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
case 'inference:turn_ended': {
|
|
500
|
+
// endTurn from a tool result — agent is done
|
|
501
|
+
cleanup();
|
|
502
|
+
resolve({ speech, toolCallsCount });
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
case 'inference:exhausted': {
|
|
506
|
+
// Retries exhausted — reject only after the error policy gives up.
|
|
507
|
+
// Earlier inference:failed events are left alone so the framework
|
|
508
|
+
// retry path in driveStream/startAgentStream can restart the stream
|
|
509
|
+
// while this listener stays alive.
|
|
510
|
+
const error = event.error ?? 'Unknown error';
|
|
511
|
+
cleanup();
|
|
512
|
+
reject(new Error(error));
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
this.onTrace(traceListener);
|
|
518
|
+
// Trigger inference
|
|
519
|
+
this.pendingRequests.push({
|
|
520
|
+
agentName: agent.name,
|
|
521
|
+
reason: 'ephemeral',
|
|
522
|
+
source: 'subagent',
|
|
523
|
+
timestamp: Date.now(),
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Get queue depth.
|
|
529
|
+
*/
|
|
530
|
+
getQueueDepth() {
|
|
531
|
+
return this.queue.depth;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Query inference logs.
|
|
535
|
+
* Returns entries with summary info (doesn't resolve blobs).
|
|
536
|
+
*/
|
|
537
|
+
queryInferenceLogs(query) {
|
|
538
|
+
const limit = query?.limit ?? 50;
|
|
539
|
+
const offset = query?.offset ?? 0;
|
|
540
|
+
const pattern = query?.pattern ? new RegExp(query.pattern, 'i') : null;
|
|
541
|
+
// Get all entries from the append log
|
|
542
|
+
const allEntries = [];
|
|
543
|
+
const stateInfo = this.store.listStates().find((s) => s.id === INFERENCE_LOG_ID);
|
|
544
|
+
if (stateInfo) {
|
|
545
|
+
// Query the append log - get raw data
|
|
546
|
+
const data = this.store.getStateJson(INFERENCE_LOG_ID);
|
|
547
|
+
if (Array.isArray(data)) {
|
|
548
|
+
for (let i = 0; i < data.length; i++) {
|
|
549
|
+
const entry = data[i];
|
|
550
|
+
// Build summary (without resolving blobs)
|
|
551
|
+
const requestIsBlob = !!(entry.request && typeof entry.request === 'object' && 'blobId' in entry.request);
|
|
552
|
+
const responseIsBlob = !!(entry.response && typeof entry.response === 'object' && 'blobId' in entry.response);
|
|
553
|
+
const summary = {
|
|
554
|
+
timestamp: entry.timestamp,
|
|
555
|
+
agentName: entry.agentName,
|
|
556
|
+
requestId: entry.requestId,
|
|
557
|
+
success: entry.success,
|
|
558
|
+
error: entry.error,
|
|
559
|
+
durationMs: entry.durationMs,
|
|
560
|
+
tokenUsage: entry.tokenUsage,
|
|
561
|
+
stopReason: entry.stopReason,
|
|
562
|
+
requestIsBlob,
|
|
563
|
+
responseIsBlob,
|
|
564
|
+
};
|
|
565
|
+
allEntries.push({ sequence: i, entry, summary });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
// Filter entries
|
|
570
|
+
let filtered = allEntries;
|
|
571
|
+
if (query?.agentName) {
|
|
572
|
+
filtered = filtered.filter((e) => e.entry.agentName === query.agentName);
|
|
573
|
+
}
|
|
574
|
+
if (query?.errorsOnly) {
|
|
575
|
+
filtered = filtered.filter((e) => !e.entry.success);
|
|
576
|
+
}
|
|
577
|
+
if (pattern) {
|
|
578
|
+
filtered = filtered.filter((e) => {
|
|
579
|
+
// Search in summary fields only (not blob content)
|
|
580
|
+
const content = JSON.stringify(e.summary);
|
|
581
|
+
return pattern.test(content);
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
// Reverse to get most recent first
|
|
585
|
+
filtered = filtered.reverse();
|
|
586
|
+
// Paginate
|
|
587
|
+
const total = filtered.length;
|
|
588
|
+
const paged = filtered.slice(offset, offset + limit);
|
|
589
|
+
return {
|
|
590
|
+
entries: paged,
|
|
591
|
+
total,
|
|
592
|
+
hasMore: offset + limit < total,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Get a specific inference log entry by sequence number.
|
|
597
|
+
* Resolves blob references to full content.
|
|
598
|
+
*/
|
|
599
|
+
getInferenceLog(sequence, resolveBlobs = true) {
|
|
600
|
+
const data = this.store.getStateJson(INFERENCE_LOG_ID);
|
|
601
|
+
if (Array.isArray(data) && sequence >= 0 && sequence < data.length) {
|
|
602
|
+
const entry = data[sequence];
|
|
603
|
+
if (resolveBlobs) {
|
|
604
|
+
// Resolve blob references
|
|
605
|
+
const resolved = { ...entry };
|
|
606
|
+
if (entry.request && typeof entry.request === 'object' && 'blobId' in entry.request) {
|
|
607
|
+
const blob = this.store.getBlob(entry.request.blobId);
|
|
608
|
+
if (blob) {
|
|
609
|
+
try {
|
|
610
|
+
resolved.request = JSON.parse(blob.toString());
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
resolved.request = { error: 'Failed to parse blob', blobId: entry.request.blobId };
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (entry.response && typeof entry.response === 'object' && 'blobId' in entry.response) {
|
|
618
|
+
const blob = this.store.getBlob(entry.response.blobId);
|
|
619
|
+
if (blob) {
|
|
620
|
+
try {
|
|
621
|
+
resolved.response = JSON.parse(blob.toString());
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
resolved.response = { error: 'Failed to parse blob', blobId: entry.response.blobId };
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return { sequence, entry: resolved };
|
|
629
|
+
}
|
|
630
|
+
return { sequence, entry };
|
|
631
|
+
}
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Get the most recent inference logs (tail).
|
|
636
|
+
*/
|
|
637
|
+
tailInferenceLogs(count = 10, agentName) {
|
|
638
|
+
const result = this.queryInferenceLogs({
|
|
639
|
+
limit: count,
|
|
640
|
+
agentName,
|
|
641
|
+
});
|
|
642
|
+
return result.entries;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Query process logs.
|
|
646
|
+
* Returns entries with summary info (doesn't resolve blobs).
|
|
647
|
+
*/
|
|
648
|
+
queryProcessLogs(query) {
|
|
649
|
+
const limit = query?.limit ?? 50;
|
|
650
|
+
const offset = query?.offset ?? 0;
|
|
651
|
+
const pattern = query?.pattern ? new RegExp(query.pattern, 'i') : null;
|
|
652
|
+
const allEntries = [];
|
|
653
|
+
const stateInfo = this.store.listStates().find((s) => s.id === PROCESS_LOG_ID);
|
|
654
|
+
if (stateInfo) {
|
|
655
|
+
const data = this.store.getStateJson(PROCESS_LOG_ID);
|
|
656
|
+
if (Array.isArray(data)) {
|
|
657
|
+
for (let i = 0; i < data.length; i++) {
|
|
658
|
+
const entry = data[i];
|
|
659
|
+
// Build summary
|
|
660
|
+
const responsesIsBlob = !!(entry.responses &&
|
|
661
|
+
typeof entry.responses === 'object' &&
|
|
662
|
+
'blobId' in entry.responses);
|
|
663
|
+
// Extract summary info from responses if not a blob
|
|
664
|
+
let moduleCount = 0;
|
|
665
|
+
const modulesRequestingInference = [];
|
|
666
|
+
const modulesAddingMessages = [];
|
|
667
|
+
if (!responsesIsBlob && Array.isArray(entry.responses)) {
|
|
668
|
+
moduleCount = entry.responses.length;
|
|
669
|
+
for (const { moduleName, response } of entry.responses) {
|
|
670
|
+
if (response.requestInference) {
|
|
671
|
+
modulesRequestingInference.push(moduleName);
|
|
672
|
+
}
|
|
673
|
+
if (response.addMessages?.length) {
|
|
674
|
+
modulesAddingMessages.push(moduleName);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const summary = {
|
|
679
|
+
timestamp: entry.timestamp,
|
|
680
|
+
eventType: entry.processEvent.type,
|
|
681
|
+
moduleCount,
|
|
682
|
+
modulesRequestingInference,
|
|
683
|
+
modulesAddingMessages,
|
|
684
|
+
responsesIsBlob,
|
|
685
|
+
};
|
|
686
|
+
allEntries.push({ sequence: i, entry, summary });
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
// Filter entries
|
|
691
|
+
let filtered = allEntries;
|
|
692
|
+
if (query?.eventType) {
|
|
693
|
+
filtered = filtered.filter((e) => e.entry.processEvent.type === query.eventType);
|
|
694
|
+
}
|
|
695
|
+
if (query?.moduleName) {
|
|
696
|
+
filtered = filtered.filter((e) => {
|
|
697
|
+
if (e.summary?.responsesIsBlob)
|
|
698
|
+
return false;
|
|
699
|
+
const responses = e.entry.responses;
|
|
700
|
+
return responses.some((r) => r.moduleName === query.moduleName);
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
if (pattern) {
|
|
704
|
+
filtered = filtered.filter((e) => {
|
|
705
|
+
const content = JSON.stringify(e.summary);
|
|
706
|
+
return pattern.test(content);
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
// Reverse to get most recent first
|
|
710
|
+
filtered = filtered.reverse();
|
|
711
|
+
// Paginate
|
|
712
|
+
const total = filtered.length;
|
|
713
|
+
const paged = filtered.slice(offset, offset + limit);
|
|
714
|
+
return {
|
|
715
|
+
entries: paged,
|
|
716
|
+
total,
|
|
717
|
+
hasMore: offset + limit < total,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Get a specific process log entry by sequence number.
|
|
722
|
+
* Resolves blob references to full content.
|
|
723
|
+
*/
|
|
724
|
+
getProcessLog(sequence, resolveBlobs = true) {
|
|
725
|
+
const data = this.store.getStateJson(PROCESS_LOG_ID);
|
|
726
|
+
if (Array.isArray(data) && sequence >= 0 && sequence < data.length) {
|
|
727
|
+
const entry = data[sequence];
|
|
728
|
+
if (resolveBlobs && entry.responses && typeof entry.responses === 'object' && 'blobId' in entry.responses) {
|
|
729
|
+
const resolved = { ...entry };
|
|
730
|
+
const blob = this.store.getBlob(entry.responses.blobId);
|
|
731
|
+
if (blob) {
|
|
732
|
+
try {
|
|
733
|
+
resolved.responses = JSON.parse(blob.toString());
|
|
734
|
+
}
|
|
735
|
+
catch {
|
|
736
|
+
resolved.responses = [];
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
return { sequence, entry: resolved };
|
|
740
|
+
}
|
|
741
|
+
return { sequence, entry };
|
|
742
|
+
}
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Get the most recent process logs (tail).
|
|
747
|
+
*/
|
|
748
|
+
tailProcessLogs(count = 10, eventType) {
|
|
749
|
+
const result = this.queryProcessLogs({
|
|
750
|
+
limit: count,
|
|
751
|
+
eventType,
|
|
752
|
+
});
|
|
753
|
+
return result.entries;
|
|
754
|
+
}
|
|
755
|
+
// ==========================================================================
|
|
756
|
+
// Undo / Redo
|
|
757
|
+
// ==========================================================================
|
|
758
|
+
/**
|
|
759
|
+
* Undo the last inference turn for an agent.
|
|
760
|
+
*
|
|
761
|
+
* Creates a new branch at the Chronicle sequence recorded before that turn
|
|
762
|
+
* and switches to it, atomically rolling back all state (messages, context
|
|
763
|
+
* log, inference log, MCPL checkpoints).
|
|
764
|
+
*
|
|
765
|
+
* The undone branch is saved so `redo()` can restore it.
|
|
766
|
+
* Returns the checkpoint that was undone, or null if nothing to undo.
|
|
767
|
+
*/
|
|
768
|
+
undoLastTurn(agentName) {
|
|
769
|
+
const agent = this.agents.get(agentName);
|
|
770
|
+
if (!agent) {
|
|
771
|
+
throw new Error(`Unknown agent: ${agentName}`);
|
|
772
|
+
}
|
|
773
|
+
if (agent.state.status !== 'idle') {
|
|
774
|
+
throw new Error(`Cannot undo while agent is ${agent.state.status}`);
|
|
775
|
+
}
|
|
776
|
+
const checkpoints = this.getTurnCheckpoints(agentName);
|
|
777
|
+
if (checkpoints.length === 0) {
|
|
778
|
+
return { undone: false };
|
|
779
|
+
}
|
|
780
|
+
const checkpoint = checkpoints.pop();
|
|
781
|
+
this.saveTurnCheckpoints(agentName, checkpoints);
|
|
782
|
+
const currentBranch = this.store.currentBranch();
|
|
783
|
+
const undoBranchName = `undo/${agentName}/${checkpoint.turnIndex}-${Date.now()}`;
|
|
784
|
+
this.store.createBranchAt(undoBranchName, currentBranch.name, checkpoint.sequenceBefore);
|
|
785
|
+
this.store.switchBranch(undoBranchName);
|
|
786
|
+
// Materialize config files from the new branch (fire-and-forget; gate picks up via mtime)
|
|
787
|
+
const wsUndo = this.moduleRegistry.getModule('workspace');
|
|
788
|
+
if (wsUndo && 'materializeMount' in wsUndo) {
|
|
789
|
+
wsUndo.materializeMount('_config').catch(() => { });
|
|
790
|
+
}
|
|
791
|
+
// Push onto redo stack
|
|
792
|
+
let redoStack = this.redoStacks.get(agentName);
|
|
793
|
+
if (!redoStack) {
|
|
794
|
+
redoStack = [];
|
|
795
|
+
this.redoStacks.set(agentName, redoStack);
|
|
796
|
+
}
|
|
797
|
+
redoStack.push({ branchName: currentBranch.name, checkpoint });
|
|
798
|
+
this.emitTrace({
|
|
799
|
+
type: 'undo:completed',
|
|
800
|
+
agentName,
|
|
801
|
+
turnIndex: checkpoint.turnIndex,
|
|
802
|
+
fromBranch: currentBranch.name,
|
|
803
|
+
toBranch: undoBranchName,
|
|
804
|
+
});
|
|
805
|
+
return {
|
|
806
|
+
undone: true,
|
|
807
|
+
turnIndex: checkpoint.turnIndex,
|
|
808
|
+
fromBranch: currentBranch.name,
|
|
809
|
+
toBranch: undoBranchName,
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Redo a previously undone turn for an agent.
|
|
814
|
+
*
|
|
815
|
+
* Switches back to the branch that was active before the last undo.
|
|
816
|
+
* Returns false if there's nothing to redo.
|
|
817
|
+
*/
|
|
818
|
+
redo(agentName) {
|
|
819
|
+
const agent = this.agents.get(agentName);
|
|
820
|
+
if (!agent) {
|
|
821
|
+
throw new Error(`Unknown agent: ${agentName}`);
|
|
822
|
+
}
|
|
823
|
+
if (agent.state.status !== 'idle') {
|
|
824
|
+
throw new Error(`Cannot redo while agent is ${agent.state.status}`);
|
|
825
|
+
}
|
|
826
|
+
const redoStack = this.redoStacks.get(agentName);
|
|
827
|
+
if (!redoStack || redoStack.length === 0) {
|
|
828
|
+
return { redone: false };
|
|
829
|
+
}
|
|
830
|
+
const { branchName, checkpoint } = redoStack.pop();
|
|
831
|
+
const currentBranch = this.store.currentBranch();
|
|
832
|
+
this.store.switchBranch(branchName);
|
|
833
|
+
// Materialize config files from the restored branch (fire-and-forget; gate picks up via mtime)
|
|
834
|
+
const wsRedo = this.moduleRegistry.getModule('workspace');
|
|
835
|
+
if (wsRedo && 'materializeMount' in wsRedo) {
|
|
836
|
+
wsRedo.materializeMount('_config').catch(() => { });
|
|
837
|
+
}
|
|
838
|
+
// Restore the checkpoint
|
|
839
|
+
const checkpoints = this.getTurnCheckpoints(agentName);
|
|
840
|
+
checkpoints.push(checkpoint);
|
|
841
|
+
this.saveTurnCheckpoints(agentName, checkpoints);
|
|
842
|
+
this.emitTrace({
|
|
843
|
+
type: 'redo:completed',
|
|
844
|
+
agentName,
|
|
845
|
+
fromBranch: currentBranch.name,
|
|
846
|
+
toBranch: branchName,
|
|
847
|
+
});
|
|
848
|
+
return {
|
|
849
|
+
redone: true,
|
|
850
|
+
fromBranch: currentBranch.name,
|
|
851
|
+
toBranch: branchName,
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Check if undo/redo is available for an agent.
|
|
856
|
+
*/
|
|
857
|
+
getUndoRedoState(agentName) {
|
|
858
|
+
const checkpoints = this.getTurnCheckpoints(agentName);
|
|
859
|
+
const redoStack = this.redoStacks.get(agentName);
|
|
860
|
+
return {
|
|
861
|
+
canUndo: checkpoints.length > 0,
|
|
862
|
+
canRedo: (redoStack?.length ?? 0) > 0,
|
|
863
|
+
undoDepth: checkpoints.length,
|
|
864
|
+
redoDepth: redoStack?.length ?? 0,
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
// ==========================================================================
|
|
868
|
+
// Turn checkpoint internals
|
|
869
|
+
// ==========================================================================
|
|
870
|
+
recordTurnCheckpoint(agentName) {
|
|
871
|
+
const turnIndex = this.turnCounters.get(agentName) ?? 0;
|
|
872
|
+
this.turnCounters.set(agentName, turnIndex + 1);
|
|
873
|
+
const checkpoint = {
|
|
874
|
+
agentName,
|
|
875
|
+
turnIndex,
|
|
876
|
+
sequenceBefore: this.store.currentSequence(),
|
|
877
|
+
branchName: this.store.currentBranch().name,
|
|
878
|
+
timestamp: Date.now(),
|
|
879
|
+
};
|
|
880
|
+
const checkpoints = this.getTurnCheckpoints(agentName);
|
|
881
|
+
checkpoints.push(checkpoint);
|
|
882
|
+
// Trim to max depth
|
|
883
|
+
if (checkpoints.length > MAX_TURN_CHECKPOINTS) {
|
|
884
|
+
checkpoints.splice(0, checkpoints.length - MAX_TURN_CHECKPOINTS);
|
|
885
|
+
}
|
|
886
|
+
this.saveTurnCheckpoints(agentName, checkpoints);
|
|
887
|
+
}
|
|
888
|
+
getTurnCheckpoints(agentName) {
|
|
889
|
+
const data = this.store.getStateJson(TURN_CHECKPOINTS_ID);
|
|
890
|
+
if (!data || typeof data !== 'object')
|
|
891
|
+
return [];
|
|
892
|
+
const allCheckpoints = data;
|
|
893
|
+
return Array.isArray(allCheckpoints[agentName]) ? [...allCheckpoints[agentName]] : [];
|
|
894
|
+
}
|
|
895
|
+
saveTurnCheckpoints(agentName, checkpoints) {
|
|
896
|
+
const data = this.store.getStateJson(TURN_CHECKPOINTS_ID);
|
|
897
|
+
const allCheckpoints = (data && typeof data === 'object' ? data : {});
|
|
898
|
+
allCheckpoints[agentName] = checkpoints;
|
|
899
|
+
this.store.setStateJson(TURN_CHECKPOINTS_ID, allCheckpoints);
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Run until the queue is empty and all agents are idle.
|
|
903
|
+
* Useful for testing.
|
|
904
|
+
*/
|
|
905
|
+
async runUntilIdle() {
|
|
906
|
+
while (!this.queue.isEmpty ||
|
|
907
|
+
this.activeStreams.size > 0 ||
|
|
908
|
+
Array.from(this.agents.values()).some((a) => a.state.status !== 'idle')) {
|
|
909
|
+
await this.processNextEvent();
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
async createAgent(config) {
|
|
913
|
+
// Create context manager for this agent
|
|
914
|
+
const contextManager = await ContextManager.open({
|
|
915
|
+
store: this.store,
|
|
916
|
+
namespace: `agents/${config.name}`,
|
|
917
|
+
strategy: config.strategy ?? new PassthroughStrategy(),
|
|
918
|
+
membrane: this.membrane,
|
|
919
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
920
|
+
});
|
|
921
|
+
const agent = new Agent(config, contextManager, this.membrane);
|
|
922
|
+
this.agents.set(config.name, agent);
|
|
923
|
+
// First non-ephemeral agent becomes the primary for message routing
|
|
924
|
+
if (!this.primaryAgentName) {
|
|
925
|
+
this.primaryAgentName = config.name;
|
|
926
|
+
}
|
|
927
|
+
return agent;
|
|
928
|
+
}
|
|
929
|
+
async runLoop() {
|
|
930
|
+
while (this.running) {
|
|
931
|
+
try {
|
|
932
|
+
await this.processNextEvent();
|
|
933
|
+
}
|
|
934
|
+
catch (error) {
|
|
935
|
+
console.error('Error in event loop:', error);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
async processNextEvent() {
|
|
940
|
+
// Try to get next process event (with timeout to check running flag)
|
|
941
|
+
const event = this.queue.tryPop();
|
|
942
|
+
if (event) {
|
|
943
|
+
await this.handleProcessEvent(event);
|
|
944
|
+
}
|
|
945
|
+
// Check for inference requests
|
|
946
|
+
await this.processInferenceRequests();
|
|
947
|
+
// Yield to the event loop between iterations.
|
|
948
|
+
// Full 10ms sleep when truly idle; minimal yield when streams are active
|
|
949
|
+
// (needed to let stream microtasks and tool-call callbacks execute).
|
|
950
|
+
if (!event && this.pendingRequests.length === 0) {
|
|
951
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
952
|
+
}
|
|
953
|
+
else if (this.activeStreams.size > 0) {
|
|
954
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
async handleProcessEvent(event) {
|
|
958
|
+
const startTime = Date.now();
|
|
959
|
+
// Handle tool results FIRST — before any module dispatch or MCPL handling.
|
|
960
|
+
// This ensures the tool_use → tool_result message adjacency required by the
|
|
961
|
+
// Anthropic API. If we let modules or MCPL handlers run first they may add
|
|
962
|
+
// messages between tool_use and tool_result, causing a 400 error.
|
|
963
|
+
if (event.type === 'tool-result') {
|
|
964
|
+
const agent = this.agents.get(event.agentName);
|
|
965
|
+
if (!agent) {
|
|
966
|
+
console.warn(`[framework] Dropping tool-result for unknown agent '${event.agentName}' (callId=${event.callId}). ` +
|
|
967
|
+
`Agent may have been destroyed while tool was executing.`);
|
|
968
|
+
}
|
|
969
|
+
else if (agent.state.status !== 'waiting_for_tools') {
|
|
970
|
+
console.warn(`[framework] Dropping tool-result for agent '${event.agentName}' — ` +
|
|
971
|
+
`expected status 'waiting_for_tools' but got '${agent.state.status}' (callId=${event.callId}). ` +
|
|
972
|
+
`Agent may have been reset/cancelled while tool was executing.`);
|
|
973
|
+
this.emitTrace({
|
|
974
|
+
type: 'tool:result_dropped',
|
|
975
|
+
agentName: event.agentName,
|
|
976
|
+
callId: event.callId,
|
|
977
|
+
agentStatus: agent.state.status,
|
|
978
|
+
result: event.result,
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
if (agent && agent.state.status === 'waiting_for_tools') {
|
|
982
|
+
agent.provideToolResult(event.callId, event.result);
|
|
983
|
+
// Check if agent is now ready (state may have changed after provideToolResult)
|
|
984
|
+
// Cast to AgentState to bypass TypeScript's control flow narrowing
|
|
985
|
+
const currentState = agent.state;
|
|
986
|
+
if (currentState.status === 'ready') {
|
|
987
|
+
// Flush pending assistant blocks (tool_use + preamble text) to context
|
|
988
|
+
const pendingBlocks = this.pendingAssistantBlocks.get(agent.name);
|
|
989
|
+
if (pendingBlocks) {
|
|
990
|
+
agent.addAssistantResponse(pendingBlocks);
|
|
991
|
+
this.pendingAssistantBlocks.delete(agent.name);
|
|
992
|
+
}
|
|
993
|
+
// Compute truncation limit from agent's strategy (maxMessageTokens * 4 chars)
|
|
994
|
+
const maxChars = this.getMaxToolResultChars(agent);
|
|
995
|
+
// Store tool results as a user message (tool_result blocks)
|
|
996
|
+
const toolResultContent = currentState.toolResults.map(tc => {
|
|
997
|
+
let content;
|
|
998
|
+
if (tc.result.isError) {
|
|
999
|
+
content = tc.result.error ?? 'Unknown error';
|
|
1000
|
+
}
|
|
1001
|
+
else {
|
|
1002
|
+
content = JSON.stringify(tc.result.data);
|
|
1003
|
+
if (maxChars && content.length > maxChars) {
|
|
1004
|
+
content = safeSlice(content, 0, maxChars)
|
|
1005
|
+
+ '\n\n[truncated — original was ' + content.length + ' chars]';
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
return {
|
|
1009
|
+
type: 'tool_result',
|
|
1010
|
+
toolUseId: tc.id,
|
|
1011
|
+
content,
|
|
1012
|
+
isError: tc.result.isError,
|
|
1013
|
+
};
|
|
1014
|
+
});
|
|
1015
|
+
agent.getContextManager().addMessage('user', toolResultContent);
|
|
1016
|
+
// Flush any messages that were deferred while waiting for tool results.
|
|
1017
|
+
// Route to the PRIMARY agent — deferred messages are framework-level
|
|
1018
|
+
// (e.g. subagent return notifications) meant for the main conversation.
|
|
1019
|
+
if (this.deferredMessages.length > 0) {
|
|
1020
|
+
const deferred = this.deferredMessages.splice(0);
|
|
1021
|
+
const primary = this.primaryAgentName
|
|
1022
|
+
? this.agents.get(this.primaryAgentName)
|
|
1023
|
+
: agent;
|
|
1024
|
+
for (const msg of deferred) {
|
|
1025
|
+
(primary ?? agent).getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
// Check if any tool result requested endTurn
|
|
1029
|
+
const shouldEndTurn = currentState.toolResults.some(tc => tc.result.endTurn);
|
|
1030
|
+
// Check if accumulated input tokens exceed the agent's budget
|
|
1031
|
+
const overBudget = currentState.stream
|
|
1032
|
+
&& agent.lastStreamInputTokens > 0
|
|
1033
|
+
&& agent.lastStreamInputTokens > agent.maxStreamTokens;
|
|
1034
|
+
if (shouldEndTurn) {
|
|
1035
|
+
// endTurn: messages already stored above, cancel stream, reset to idle.
|
|
1036
|
+
if (currentState.stream) {
|
|
1037
|
+
agent.cancelStream();
|
|
1038
|
+
}
|
|
1039
|
+
agent.reset();
|
|
1040
|
+
this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
|
|
1041
|
+
}
|
|
1042
|
+
else if (overBudget) {
|
|
1043
|
+
// Context budget exceeded: break the stream, let compile() compress
|
|
1044
|
+
agent.cancelStream();
|
|
1045
|
+
this.emitTrace({
|
|
1046
|
+
type: 'inference:stream_restarted',
|
|
1047
|
+
agentName: agent.name,
|
|
1048
|
+
reason: 'context_budget',
|
|
1049
|
+
inputTokens: agent.lastStreamInputTokens,
|
|
1050
|
+
budget: agent.maxStreamTokens,
|
|
1051
|
+
});
|
|
1052
|
+
this.pendingRequests.push({
|
|
1053
|
+
agentName: agent.name,
|
|
1054
|
+
reason: 'context_budget_restart',
|
|
1055
|
+
source: 'framework',
|
|
1056
|
+
timestamp: Date.now(),
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
else if (currentState.stream) {
|
|
1060
|
+
// Streaming path: convert results and resume the stream
|
|
1061
|
+
const membraneResults = currentState.toolResults.map(tc => this.toMembraneToolResult(tc.id, tc.result, maxChars));
|
|
1062
|
+
currentState.stream.provideToolResults(membraneResults);
|
|
1063
|
+
agent.setStreaming(currentState.stream);
|
|
1064
|
+
this.emitTrace({ type: 'inference:stream_resumed', agentName: agent.name });
|
|
1065
|
+
}
|
|
1066
|
+
else {
|
|
1067
|
+
// Non-streaming fallback: schedule re-inference
|
|
1068
|
+
this.pendingRequests.push({
|
|
1069
|
+
agentName: agent.name,
|
|
1070
|
+
reason: 'tool_results_ready',
|
|
1071
|
+
source: 'framework',
|
|
1072
|
+
timestamp: Date.now(),
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
// Built-in: convert MCPL events to context messages.
|
|
1079
|
+
// These events are protocol-level (spec Sections 9 & 14) and always
|
|
1080
|
+
// represent content intended for the model's context window.
|
|
1081
|
+
if (event.type === 'mcpl:channel-incoming') {
|
|
1082
|
+
this.handleMcplChannelIncoming(event);
|
|
1083
|
+
}
|
|
1084
|
+
else if (event.type === 'mcpl:push-event') {
|
|
1085
|
+
this.handleMcplPushEvent(event);
|
|
1086
|
+
}
|
|
1087
|
+
// Dispatch to all modules, tracking responses with module names
|
|
1088
|
+
const responses = [];
|
|
1089
|
+
for (const module of this.moduleRegistry.getAllModules()) {
|
|
1090
|
+
try {
|
|
1091
|
+
const processState = this.moduleRegistry.createProcessState(module.name);
|
|
1092
|
+
const response = await module.onProcess(event, processState);
|
|
1093
|
+
responses.push({ moduleName: module.name, response });
|
|
1094
|
+
}
|
|
1095
|
+
catch (error) {
|
|
1096
|
+
console.error(`Module ${module.name} error handling process event:`, error);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
// Apply responses
|
|
1100
|
+
for (const { moduleName, response } of responses) {
|
|
1101
|
+
await this.applyProcessResponse(response, event, moduleName);
|
|
1102
|
+
}
|
|
1103
|
+
// Handle tool calls specially
|
|
1104
|
+
if (event.type === 'tool-call') {
|
|
1105
|
+
this.dispatchToolCallEvent(event);
|
|
1106
|
+
}
|
|
1107
|
+
const durationMs = Date.now() - startTime;
|
|
1108
|
+
// Always emit trace for observability (UI needs this)
|
|
1109
|
+
this.emitTrace({ type: 'process:completed', processEvent: event, responses, durationMs });
|
|
1110
|
+
// Log to Chronicle (if enabled)
|
|
1111
|
+
if (this.processLoggingPersist) {
|
|
1112
|
+
this.logProcessEvent(event, responses);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
async applyProcessResponse(response, event, moduleName) {
|
|
1116
|
+
// Add messages
|
|
1117
|
+
if (response.addMessages) {
|
|
1118
|
+
for (const msg of response.addMessages) {
|
|
1119
|
+
const id = this.addMessage(msg.participant, msg.content, msg.metadata);
|
|
1120
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: event.type });
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
// Edit messages
|
|
1124
|
+
if (response.editMessages) {
|
|
1125
|
+
for (const edit of response.editMessages) {
|
|
1126
|
+
this.editMessage(edit.messageId, edit.content);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
// Remove messages
|
|
1130
|
+
if (response.removeMessages) {
|
|
1131
|
+
for (const id of response.removeMessages) {
|
|
1132
|
+
this.removeMessage(id);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
// Apply module state update atomically with message operations
|
|
1136
|
+
if (response.stateUpdate !== undefined) {
|
|
1137
|
+
this.moduleRegistry.setModuleState(moduleName, response.stateUpdate);
|
|
1138
|
+
}
|
|
1139
|
+
// Queue inference requests
|
|
1140
|
+
if (response.requestInference) {
|
|
1141
|
+
const source = 'source' in event ? event.source : 'unknown';
|
|
1142
|
+
// Gate non-MCPL events (MCPL events are already gated in PushHandler/ChannelRegistry)
|
|
1143
|
+
if (this.eventGate && event.type !== 'mcpl:push-event' && event.type !== 'mcpl:channel-incoming') {
|
|
1144
|
+
const content = 'content' in event ? String(event.content) : '';
|
|
1145
|
+
const decision = this.eventGate.evaluate({
|
|
1146
|
+
content,
|
|
1147
|
+
eventType: event.type,
|
|
1148
|
+
serverId: source,
|
|
1149
|
+
channelId: '',
|
|
1150
|
+
metadata: 'metadata' in event ? event.metadata : {},
|
|
1151
|
+
});
|
|
1152
|
+
if (!decision.trigger)
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
const targetAgents = response.requestInference === true
|
|
1156
|
+
? Array.from(this.agents.keys())
|
|
1157
|
+
: response.requestInference;
|
|
1158
|
+
for (const agentName of targetAgents) {
|
|
1159
|
+
const agent = this.agents.get(agentName);
|
|
1160
|
+
if (agent && agent.canBeTriggeredBy(source)) {
|
|
1161
|
+
this.pendingRequests.push({
|
|
1162
|
+
agentName,
|
|
1163
|
+
reason: event.type,
|
|
1164
|
+
source,
|
|
1165
|
+
timestamp: Date.now(),
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
// ==========================================================================
|
|
1172
|
+
// Built-in MCPL event → context message conversion
|
|
1173
|
+
// ==========================================================================
|
|
1174
|
+
/**
|
|
1175
|
+
* Convert an incoming MCPL channel message to a context message.
|
|
1176
|
+
* This replaces the old MCPLModule.onProcess() message conversion.
|
|
1177
|
+
*/
|
|
1178
|
+
handleMcplChannelIncoming(event) {
|
|
1179
|
+
const metadata = {
|
|
1180
|
+
...event.metadata,
|
|
1181
|
+
channelId: event.channelId,
|
|
1182
|
+
messageId: event.messageId,
|
|
1183
|
+
author: event.author,
|
|
1184
|
+
triggered: event.triggerInference ?? false,
|
|
1185
|
+
serverId: event.serverId,
|
|
1186
|
+
};
|
|
1187
|
+
if (event.threadId)
|
|
1188
|
+
metadata.threadId = event.threadId;
|
|
1189
|
+
const id = this.addMessage('user', event.content, metadata);
|
|
1190
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
|
|
1191
|
+
if (event.triggerInference) {
|
|
1192
|
+
for (const agentName of this.agents.keys()) {
|
|
1193
|
+
this.pendingRequests.push({
|
|
1194
|
+
agentName,
|
|
1195
|
+
reason: 'mcpl:channel-incoming',
|
|
1196
|
+
source: event.serverId,
|
|
1197
|
+
timestamp: Date.now(),
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Convert an MCPL push event to a context message.
|
|
1204
|
+
*/
|
|
1205
|
+
handleMcplPushEvent(event) {
|
|
1206
|
+
const metadata = {
|
|
1207
|
+
...event.origin,
|
|
1208
|
+
serverId: event.serverId,
|
|
1209
|
+
featureSet: event.featureSet,
|
|
1210
|
+
eventId: event.eventId,
|
|
1211
|
+
triggered: event.triggerInference ?? false,
|
|
1212
|
+
};
|
|
1213
|
+
const id = this.addMessage('user', event.content, metadata);
|
|
1214
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
|
|
1215
|
+
if (event.triggerInference) {
|
|
1216
|
+
const targetAgents = event.targetAgents ?? [...this.agents.keys()];
|
|
1217
|
+
for (const agentName of targetAgents) {
|
|
1218
|
+
this.pendingRequests.push({
|
|
1219
|
+
agentName,
|
|
1220
|
+
reason: 'mcpl:push-event',
|
|
1221
|
+
source: event.serverId,
|
|
1222
|
+
timestamp: Date.now(),
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
async processInferenceRequests() {
|
|
1228
|
+
if (this.pendingRequests.length === 0) {
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
const STALE_REQUEST_MS = 30_000;
|
|
1232
|
+
const now = Date.now();
|
|
1233
|
+
const state = this.createFrameworkState();
|
|
1234
|
+
// Group requests by agent
|
|
1235
|
+
const requestsByAgent = new Map();
|
|
1236
|
+
for (const req of this.pendingRequests) {
|
|
1237
|
+
const existing = requestsByAgent.get(req.agentName) ?? [];
|
|
1238
|
+
existing.push(req);
|
|
1239
|
+
requestsByAgent.set(req.agentName, existing);
|
|
1240
|
+
}
|
|
1241
|
+
// Clear pending (we'll re-add if inference doesn't run)
|
|
1242
|
+
this.pendingRequests = [];
|
|
1243
|
+
// Check each agent
|
|
1244
|
+
for (const [agentName, requests] of requestsByAgent) {
|
|
1245
|
+
const agent = this.agents.get(agentName);
|
|
1246
|
+
if (!agent) {
|
|
1247
|
+
// Agent not found — request is orphaned. Emit warning and drop.
|
|
1248
|
+
const oldest = Math.min(...requests.map(r => r.timestamp));
|
|
1249
|
+
this.emitTrace({
|
|
1250
|
+
type: 'inference:request_dropped',
|
|
1251
|
+
agentName,
|
|
1252
|
+
reason: 'agent_not_found',
|
|
1253
|
+
requestCount: requests.length,
|
|
1254
|
+
oldestRequestAge: now - oldest,
|
|
1255
|
+
});
|
|
1256
|
+
continue;
|
|
1257
|
+
}
|
|
1258
|
+
// Skip if agent is busy (inferring, streaming, or waiting for tools)
|
|
1259
|
+
if (agent.state.status === 'inferring' || agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') {
|
|
1260
|
+
// Re-queue requests, but warn if they've been pending too long
|
|
1261
|
+
const oldest = Math.min(...requests.map(r => r.timestamp));
|
|
1262
|
+
if (now - oldest > STALE_REQUEST_MS) {
|
|
1263
|
+
this.emitTrace({
|
|
1264
|
+
type: 'inference:request_stale',
|
|
1265
|
+
agentName,
|
|
1266
|
+
agentStatus: agent.state.status,
|
|
1267
|
+
requestCount: requests.length,
|
|
1268
|
+
oldestRequestAge: now - oldest,
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
this.pendingRequests.push(...requests);
|
|
1272
|
+
continue;
|
|
1273
|
+
}
|
|
1274
|
+
// Check policy
|
|
1275
|
+
if (!this.inferencePolicy.shouldInfer(agentName, requests, state)) {
|
|
1276
|
+
continue;
|
|
1277
|
+
}
|
|
1278
|
+
// Start streaming inference (non-blocking — driveStream runs in background)
|
|
1279
|
+
const trigger = requests[0];
|
|
1280
|
+
await this.startAgentStream(agent, trigger);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
async startAgentStream(agent, trigger, attempt = 0) {
|
|
1284
|
+
// Record turn checkpoint before inference (only on first attempt, not retries)
|
|
1285
|
+
if (attempt === 0) {
|
|
1286
|
+
this.recordTurnCheckpoint(agent.name);
|
|
1287
|
+
this.redoStacks.delete(agent.name); // new work invalidates redo
|
|
1288
|
+
}
|
|
1289
|
+
this.emitTrace({ type: 'inference:started', agentName: agent.name });
|
|
1290
|
+
this.eventGate?.onInferenceStarted(agent.name);
|
|
1291
|
+
try {
|
|
1292
|
+
const allTools = this.getAllTools();
|
|
1293
|
+
const tools = allTools.filter((t) => agent.canUseTool(t.name));
|
|
1294
|
+
// Gather context from modules (pull-based) and MCPL hooks (push-based)
|
|
1295
|
+
// Both produce ContextInjection[] that get merged before inference.
|
|
1296
|
+
let injections;
|
|
1297
|
+
// Module gatherContext (fail-open, 5s timeout per module)
|
|
1298
|
+
try {
|
|
1299
|
+
const moduleInjections = await this.moduleRegistry.gatherContext(agent.name);
|
|
1300
|
+
if (moduleInjections.length > 0) {
|
|
1301
|
+
injections = moduleInjections;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
catch (error) {
|
|
1305
|
+
console.error('Module gatherContext error:', error);
|
|
1306
|
+
}
|
|
1307
|
+
// MCPL beforeInference hooks (fail-open)
|
|
1308
|
+
if (this.hookOrchestrator) {
|
|
1309
|
+
try {
|
|
1310
|
+
const hookParams = this.buildBeforeInferenceParams(agent, trigger);
|
|
1311
|
+
const hookInjections = await this.hookOrchestrator.beforeInference(hookParams);
|
|
1312
|
+
if (hookInjections.length > 0) {
|
|
1313
|
+
injections = injections ? [...injections, ...hookInjections] : hookInjections;
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
catch (error) {
|
|
1317
|
+
console.error('beforeInference hook error:', error);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
const { stream, request: compiledRequest } = await agent.startStreamWithInjections(tools, injections);
|
|
1321
|
+
const handle = this.driveStream(agent, stream, trigger, attempt, compiledRequest);
|
|
1322
|
+
this.activeStreams.set(agent.name, handle);
|
|
1323
|
+
}
|
|
1324
|
+
catch (error) {
|
|
1325
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
1326
|
+
this.emitTrace({
|
|
1327
|
+
type: 'inference:failed',
|
|
1328
|
+
agentName: agent.name,
|
|
1329
|
+
error: err.message,
|
|
1330
|
+
stack: err.stack,
|
|
1331
|
+
});
|
|
1332
|
+
agent.reset();
|
|
1333
|
+
const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
|
|
1334
|
+
if (action.retry) {
|
|
1335
|
+
await new Promise((resolve) => setTimeout(resolve, action.delayMs));
|
|
1336
|
+
await this.startAgentStream(agent, trigger, attempt + 1);
|
|
1337
|
+
}
|
|
1338
|
+
else {
|
|
1339
|
+
this.emitTrace({
|
|
1340
|
+
type: 'inference:exhausted',
|
|
1341
|
+
agentName: agent.name,
|
|
1342
|
+
error: err.message,
|
|
1343
|
+
});
|
|
1344
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
1345
|
+
if (action.emit) {
|
|
1346
|
+
this.pushEvent(action.emit);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
async driveStream(agent, stream, trigger, attempt = 0, compiledRequest) {
|
|
1352
|
+
const startTime = Date.now();
|
|
1353
|
+
const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1354
|
+
const myStreamId = agent.streamId;
|
|
1355
|
+
let hadToolCalls = false;
|
|
1356
|
+
try {
|
|
1357
|
+
for await (const event of stream) {
|
|
1358
|
+
switch (event.type) {
|
|
1359
|
+
case 'tokens':
|
|
1360
|
+
this.emitTrace({
|
|
1361
|
+
type: 'inference:tokens',
|
|
1362
|
+
agentName: agent.name,
|
|
1363
|
+
content: event.content,
|
|
1364
|
+
});
|
|
1365
|
+
break;
|
|
1366
|
+
case 'tool-calls': {
|
|
1367
|
+
hadToolCalls = true;
|
|
1368
|
+
this.emitTrace({
|
|
1369
|
+
type: 'inference:tool_calls_yielded',
|
|
1370
|
+
agentName: agent.name,
|
|
1371
|
+
calls: event.calls.map((c) => ({ id: c.id, name: c.name, input: c.input })),
|
|
1372
|
+
});
|
|
1373
|
+
// Build assistant content blocks from preamble text + tool_use blocks
|
|
1374
|
+
const assistantBlocks = [];
|
|
1375
|
+
if (event.context.preamble) {
|
|
1376
|
+
assistantBlocks.push({ type: 'text', text: event.context.preamble });
|
|
1377
|
+
}
|
|
1378
|
+
for (const c of event.calls) {
|
|
1379
|
+
assistantBlocks.push({ type: 'tool_use', id: c.id, name: c.name, input: c.input });
|
|
1380
|
+
}
|
|
1381
|
+
this.pendingAssistantBlocks.set(agent.name, assistantBlocks);
|
|
1382
|
+
// Note: max_tokens truncation cannot produce tool-calls events here.
|
|
1383
|
+
// The membrane only yields 'tool-calls' when stop_reason is 'tool_use'
|
|
1384
|
+
// (native API) or the closing </function_calls> tag is found (text parser).
|
|
1385
|
+
// Truncated responses yield 'complete' with stop_reason 'max_tokens' directly.
|
|
1386
|
+
// So all calls here have valid input — {} is legitimate for parameterless tools.
|
|
1387
|
+
agent.enterWaitingForTools(event.calls, stream);
|
|
1388
|
+
for (const call of event.calls) {
|
|
1389
|
+
this.dispatchToolCall(agent.name, call);
|
|
1390
|
+
}
|
|
1391
|
+
// Stream's async iterator blocks on next() until provideToolResults() is called
|
|
1392
|
+
break;
|
|
1393
|
+
}
|
|
1394
|
+
case 'complete': {
|
|
1395
|
+
const durationMs = Date.now() - startTime;
|
|
1396
|
+
const response = event.response;
|
|
1397
|
+
// If the agent is still waiting_for_tools when 'complete' fires
|
|
1398
|
+
// (shouldn't happen after incomplete-tool-call fix, but guard anyway),
|
|
1399
|
+
// flush pending blocks + inject error results for any remaining pending tools.
|
|
1400
|
+
if (agent.state.status === 'waiting_for_tools') {
|
|
1401
|
+
console.warn(`[framework] 'complete' event fired while agent '${agent.name}' is still waiting_for_tools. ` +
|
|
1402
|
+
`Injecting error results for ${agent.getPendingToolIds().length} pending tool(s).`);
|
|
1403
|
+
// Inject error results for all remaining pending tools
|
|
1404
|
+
for (const pendingId of agent.getPendingToolIds()) {
|
|
1405
|
+
agent.provideToolResult(pendingId, {
|
|
1406
|
+
success: false,
|
|
1407
|
+
error: 'Stream completed before tool result was received (likely max_tokens truncation).',
|
|
1408
|
+
isError: true,
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
// Flush pending assistant blocks
|
|
1412
|
+
const pending = this.pendingAssistantBlocks.get(agent.name);
|
|
1413
|
+
if (pending) {
|
|
1414
|
+
agent.addAssistantResponse(pending);
|
|
1415
|
+
this.pendingAssistantBlocks.delete(agent.name);
|
|
1416
|
+
}
|
|
1417
|
+
// Store tool results
|
|
1418
|
+
const readyState = agent.state;
|
|
1419
|
+
if (readyState.status === 'ready') {
|
|
1420
|
+
const toolResultContent = readyState.toolResults.map(tc => ({
|
|
1421
|
+
type: 'tool_result',
|
|
1422
|
+
toolUseId: tc.id,
|
|
1423
|
+
content: tc.result.isError ? (tc.result.error ?? 'Unknown error') : JSON.stringify(tc.result.data),
|
|
1424
|
+
isError: tc.result.isError,
|
|
1425
|
+
}));
|
|
1426
|
+
agent.getContextManager().addMessage('user', toolResultContent);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
// Add assistant response to context.
|
|
1430
|
+
// If we had tool calls, the tool_use blocks were already stored as
|
|
1431
|
+
// pendingAssistantBlocks and flushed when tool results arrived.
|
|
1432
|
+
// Only store trailing content (text after the last tool round) to
|
|
1433
|
+
// avoid double-storing tool_use blocks.
|
|
1434
|
+
const hadToolCalls = response.content.some((b) => b.type === 'tool_use' || b.type === 'tool_result');
|
|
1435
|
+
if (hadToolCalls) {
|
|
1436
|
+
// Filter to only trailing text/thinking blocks (no tool_use — those are already stored)
|
|
1437
|
+
const trailingContent = response.content.filter((block) => block.type !== 'tool_use' && block.type !== 'tool_result');
|
|
1438
|
+
if (trailingContent.length > 0) {
|
|
1439
|
+
agent.addAssistantResponse(trailingContent);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
else {
|
|
1443
|
+
agent.addAssistantResponse(response.content);
|
|
1444
|
+
}
|
|
1445
|
+
// Run afterInference hooks (no-op if no MCPL servers)
|
|
1446
|
+
if (this.hookOrchestrator) {
|
|
1447
|
+
try {
|
|
1448
|
+
const speechText = response.content
|
|
1449
|
+
.filter((block) => block.type === 'text')
|
|
1450
|
+
.map((b) => b.text)
|
|
1451
|
+
.join('\n');
|
|
1452
|
+
const afterParams = {
|
|
1453
|
+
inferenceId: requestId,
|
|
1454
|
+
conversationId: agent.name,
|
|
1455
|
+
turnIndex: 0,
|
|
1456
|
+
userMessage: null,
|
|
1457
|
+
assistantMessage: speechText,
|
|
1458
|
+
model: {
|
|
1459
|
+
id: agent.model,
|
|
1460
|
+
vendor: 'unknown',
|
|
1461
|
+
contextWindow: 200000,
|
|
1462
|
+
capabilities: ['tools'],
|
|
1463
|
+
},
|
|
1464
|
+
usage: {
|
|
1465
|
+
inputTokens: response.usage?.inputTokens ?? 0,
|
|
1466
|
+
outputTokens: response.usage?.outputTokens ?? 0,
|
|
1467
|
+
cacheCreationTokens: response.details?.usage?.cacheCreationTokens,
|
|
1468
|
+
cacheReadTokens: response.details?.usage?.cacheReadTokens,
|
|
1469
|
+
},
|
|
1470
|
+
};
|
|
1471
|
+
await this.hookOrchestrator.afterInference(afterParams);
|
|
1472
|
+
}
|
|
1473
|
+
catch (error) {
|
|
1474
|
+
// Fail-open: continue with speech dispatch
|
|
1475
|
+
console.error('afterInference hook error:', error);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
// Separate speech from thoughts.
|
|
1479
|
+
// When tools were used, ALL text is thoughts (the tools themselves are
|
|
1480
|
+
// the agent's actions — surrounding text is just reasoning).
|
|
1481
|
+
// When no tools were used, all text is speech.
|
|
1482
|
+
const isTextBlock = (block) => block.type === 'text';
|
|
1483
|
+
const allText = response.content.filter(isTextBlock);
|
|
1484
|
+
const speechContent = hadToolCalls ? [] : allText;
|
|
1485
|
+
const thoughts = hadToolCalls ? allText : [];
|
|
1486
|
+
const tokenUsage = response.usage
|
|
1487
|
+
? {
|
|
1488
|
+
input: response.usage.inputTokens,
|
|
1489
|
+
output: response.usage.outputTokens,
|
|
1490
|
+
cacheCreation: response.details?.usage?.cacheCreationTokens,
|
|
1491
|
+
cacheRead: response.details?.usage?.cacheReadTokens,
|
|
1492
|
+
}
|
|
1493
|
+
: undefined;
|
|
1494
|
+
this.emitTrace({
|
|
1495
|
+
type: 'inference:completed',
|
|
1496
|
+
agentName: agent.name,
|
|
1497
|
+
durationMs,
|
|
1498
|
+
tokenUsage,
|
|
1499
|
+
});
|
|
1500
|
+
// Log inference
|
|
1501
|
+
this.logInference({
|
|
1502
|
+
timestamp: startTime,
|
|
1503
|
+
agentName: agent.name,
|
|
1504
|
+
requestId,
|
|
1505
|
+
success: true,
|
|
1506
|
+
request: compiledRequest ?? { note: 'streaming request' },
|
|
1507
|
+
response: response.raw ?? { note: 'streaming response' },
|
|
1508
|
+
durationMs,
|
|
1509
|
+
tokenUsage,
|
|
1510
|
+
stopReason: response.stopReason,
|
|
1511
|
+
});
|
|
1512
|
+
// Dispatch speech (and thoughts if any)
|
|
1513
|
+
if (speechContent.length > 0 || thoughts.length > 0) {
|
|
1514
|
+
const speechContext = {
|
|
1515
|
+
turnComplete: true,
|
|
1516
|
+
trigger: trigger ?? {
|
|
1517
|
+
reason: 'unknown',
|
|
1518
|
+
source: 'unknown',
|
|
1519
|
+
timestamp: Date.now(),
|
|
1520
|
+
},
|
|
1521
|
+
thoughts: thoughts.length > 0 ? thoughts : undefined,
|
|
1522
|
+
};
|
|
1523
|
+
await this.moduleRegistry.dispatchSpeech(agent.name, speechContent, speechContext);
|
|
1524
|
+
}
|
|
1525
|
+
// Done — reset to idle
|
|
1526
|
+
agent.reset();
|
|
1527
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
1528
|
+
break;
|
|
1529
|
+
}
|
|
1530
|
+
case 'error': {
|
|
1531
|
+
const err = event.error;
|
|
1532
|
+
const durationMs = Date.now() - startTime;
|
|
1533
|
+
this.emitTrace({
|
|
1534
|
+
type: 'inference:failed',
|
|
1535
|
+
agentName: agent.name,
|
|
1536
|
+
error: err.message,
|
|
1537
|
+
stack: err.stack,
|
|
1538
|
+
});
|
|
1539
|
+
this.logInference({
|
|
1540
|
+
timestamp: startTime,
|
|
1541
|
+
agentName: agent.name,
|
|
1542
|
+
requestId,
|
|
1543
|
+
success: false,
|
|
1544
|
+
error: err.message,
|
|
1545
|
+
request: compiledRequest ?? { note: 'streaming request failed' },
|
|
1546
|
+
durationMs,
|
|
1547
|
+
});
|
|
1548
|
+
// Only reset + retry if this is still the active stream
|
|
1549
|
+
if (agent.streamId !== myStreamId)
|
|
1550
|
+
break;
|
|
1551
|
+
agent.reset();
|
|
1552
|
+
const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
|
|
1553
|
+
if (action.retry) {
|
|
1554
|
+
await new Promise((resolve) => setTimeout(resolve, action.delayMs));
|
|
1555
|
+
await this.startAgentStream(agent, trigger, attempt + 1);
|
|
1556
|
+
}
|
|
1557
|
+
else {
|
|
1558
|
+
this.emitTrace({
|
|
1559
|
+
type: 'inference:exhausted',
|
|
1560
|
+
agentName: agent.name,
|
|
1561
|
+
error: err.message,
|
|
1562
|
+
});
|
|
1563
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
1564
|
+
if (action.emit) {
|
|
1565
|
+
this.pushEvent(action.emit);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
break;
|
|
1569
|
+
}
|
|
1570
|
+
case 'aborted': {
|
|
1571
|
+
const reason = event.reason ?? 'unknown';
|
|
1572
|
+
// Only reset if this is still the active stream (a budget restart
|
|
1573
|
+
// may have already started a new stream, bumping streamId)
|
|
1574
|
+
if (agent.streamId === myStreamId) {
|
|
1575
|
+
agent.reset();
|
|
1576
|
+
this.emitTrace({
|
|
1577
|
+
type: 'inference:exhausted',
|
|
1578
|
+
agentName: agent.name,
|
|
1579
|
+
error: `Stream aborted: ${reason}`,
|
|
1580
|
+
});
|
|
1581
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
1582
|
+
}
|
|
1583
|
+
break;
|
|
1584
|
+
}
|
|
1585
|
+
case 'usage':
|
|
1586
|
+
agent.lastStreamInputTokens = event.usage.inputTokens;
|
|
1587
|
+
this.emitTrace({
|
|
1588
|
+
type: 'inference:usage',
|
|
1589
|
+
agentName: agent.name,
|
|
1590
|
+
tokenUsage: {
|
|
1591
|
+
input: event.usage.inputTokens,
|
|
1592
|
+
output: event.usage.outputTokens,
|
|
1593
|
+
},
|
|
1594
|
+
});
|
|
1595
|
+
break;
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
catch (error) {
|
|
1600
|
+
// Stream itself threw (unexpected) — no retry path here, so also emit
|
|
1601
|
+
// inference:exhausted so ephemeral agent promises can settle.
|
|
1602
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
1603
|
+
this.emitTrace({
|
|
1604
|
+
type: 'inference:failed',
|
|
1605
|
+
agentName: agent.name,
|
|
1606
|
+
error: err.message,
|
|
1607
|
+
stack: err.stack,
|
|
1608
|
+
});
|
|
1609
|
+
this.emitTrace({
|
|
1610
|
+
type: 'inference:exhausted',
|
|
1611
|
+
agentName: agent.name,
|
|
1612
|
+
error: err.message,
|
|
1613
|
+
});
|
|
1614
|
+
agent.reset();
|
|
1615
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
1616
|
+
}
|
|
1617
|
+
finally {
|
|
1618
|
+
this.activeStreams.delete(agent.name);
|
|
1619
|
+
this.pendingAssistantBlocks.delete(agent.name);
|
|
1620
|
+
// Flush any deferred messages (e.g. if stream failed while tools were pending)
|
|
1621
|
+
if (this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
|
|
1622
|
+
const deferred = this.deferredMessages.splice(0);
|
|
1623
|
+
for (const msg of deferred) {
|
|
1624
|
+
this.addMessage(msg.participant, msg.content, msg.metadata);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
/**
|
|
1630
|
+
* Execute a tool call and return the result.
|
|
1631
|
+
* Routes to the appropriate handler (module registry or MCPL).
|
|
1632
|
+
* Used by SubagentModule to dispatch tool calls for ephemeral agents.
|
|
1633
|
+
*/
|
|
1634
|
+
async executeToolCall(call) {
|
|
1635
|
+
// MCPL tools are dispatched via the MCPL subsystem
|
|
1636
|
+
if (this.mcplServerRegistry) {
|
|
1637
|
+
// Check if this is an MCPL-prefixed tool
|
|
1638
|
+
const prefix = call.name.split('--').slice(0, -1).join('--');
|
|
1639
|
+
const serverConfigs = this.mcplServerConfigs;
|
|
1640
|
+
for (const [, config] of serverConfigs) {
|
|
1641
|
+
const toolPrefix = config.toolPrefix ?? `mcpl--${config.id}`;
|
|
1642
|
+
if (call.name.startsWith(toolPrefix + '--')) {
|
|
1643
|
+
return this.executeMcplToolCall(call, config);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
// Module tools
|
|
1648
|
+
return this.moduleRegistry.handleToolCall(call);
|
|
1649
|
+
}
|
|
1650
|
+
async executeMcplToolCall(call, config) {
|
|
1651
|
+
if (!this.mcplServerRegistry) {
|
|
1652
|
+
return { success: false, error: 'MCPL not initialized', isError: true };
|
|
1653
|
+
}
|
|
1654
|
+
const server = this.mcplServerRegistry.getServer(config.id);
|
|
1655
|
+
if (!server) {
|
|
1656
|
+
return { success: false, error: `MCPL server ${config.id} not found`, isError: true };
|
|
1657
|
+
}
|
|
1658
|
+
const prefix = config.toolPrefix ?? `mcpl--${config.id}`;
|
|
1659
|
+
const toolName = call.name.slice(prefix.length + 2); // Strip prefix + '--'
|
|
1660
|
+
try {
|
|
1661
|
+
const result = await server.sendToolsCall(toolName, call.input);
|
|
1662
|
+
return {
|
|
1663
|
+
success: true,
|
|
1664
|
+
data: result.content,
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
catch (err) {
|
|
1668
|
+
return {
|
|
1669
|
+
success: false,
|
|
1670
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1671
|
+
isError: true,
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
toMembraneToolResult(callId, afResult, maxChars) {
|
|
1676
|
+
let content;
|
|
1677
|
+
if (afResult.isError) {
|
|
1678
|
+
content = afResult.error ?? 'Unknown error';
|
|
1679
|
+
}
|
|
1680
|
+
else {
|
|
1681
|
+
content = JSON.stringify(afResult.data);
|
|
1682
|
+
if (maxChars && content.length > maxChars) {
|
|
1683
|
+
content = safeSlice(content, 0, maxChars)
|
|
1684
|
+
+ '\n\n[truncated — original was ' + content.length + ' chars]';
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
return { toolUseId: callId, content, isError: afResult.isError };
|
|
1688
|
+
}
|
|
1689
|
+
getMaxToolResultChars(agent) {
|
|
1690
|
+
const strategy = agent.getContextManager().getStrategy();
|
|
1691
|
+
const maxTokens = strategy.maxMessageTokens;
|
|
1692
|
+
if (maxTokens && maxTokens > 0)
|
|
1693
|
+
return maxTokens * 4;
|
|
1694
|
+
return undefined;
|
|
1695
|
+
}
|
|
1696
|
+
logInference(entry) {
|
|
1697
|
+
// Store large request/response as blobs
|
|
1698
|
+
const entryToStore = { ...entry };
|
|
1699
|
+
// Blob threshold: 10KB - typical context-heavy requests exceed this
|
|
1700
|
+
const BLOB_THRESHOLD = 10000;
|
|
1701
|
+
if (entry.request && typeof entry.request === 'object') {
|
|
1702
|
+
const requestJson = JSON.stringify(entry.request);
|
|
1703
|
+
if (requestJson.length > BLOB_THRESHOLD) {
|
|
1704
|
+
const blobId = this.store.storeBlob(Buffer.from(requestJson), 'application/json');
|
|
1705
|
+
entryToStore.request = { blobId };
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
if (entry.response && typeof entry.response === 'object') {
|
|
1709
|
+
const responseJson = JSON.stringify(entry.response);
|
|
1710
|
+
if (responseJson.length > BLOB_THRESHOLD) {
|
|
1711
|
+
const blobId = this.store.storeBlob(Buffer.from(responseJson), 'application/json');
|
|
1712
|
+
entryToStore.response = { blobId };
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
// Append to the inference log state
|
|
1716
|
+
const data = this.store.getStateJson(INFERENCE_LOG_ID);
|
|
1717
|
+
const entries = Array.isArray(data) ? data : [];
|
|
1718
|
+
entries.push(entryToStore);
|
|
1719
|
+
this.store.setStateJson(INFERENCE_LOG_ID, entries);
|
|
1720
|
+
}
|
|
1721
|
+
logProcessEvent(event, responses) {
|
|
1722
|
+
const entry = {
|
|
1723
|
+
timestamp: Date.now(),
|
|
1724
|
+
processEvent: event,
|
|
1725
|
+
responses,
|
|
1726
|
+
};
|
|
1727
|
+
// Blob threshold: 10KB
|
|
1728
|
+
const BLOB_THRESHOLD = 10000;
|
|
1729
|
+
const entryToStore = { ...entry };
|
|
1730
|
+
const responsesJson = JSON.stringify(responses);
|
|
1731
|
+
if (responsesJson.length > BLOB_THRESHOLD) {
|
|
1732
|
+
const blobId = this.store.storeBlob(Buffer.from(responsesJson), 'application/json');
|
|
1733
|
+
entryToStore.responses = { blobId };
|
|
1734
|
+
}
|
|
1735
|
+
// Append to the process log state
|
|
1736
|
+
const data = this.store.getStateJson(PROCESS_LOG_ID);
|
|
1737
|
+
const entries = Array.isArray(data) ? data : [];
|
|
1738
|
+
entries.push(entryToStore);
|
|
1739
|
+
this.store.setStateJson(PROCESS_LOG_ID, entries);
|
|
1740
|
+
}
|
|
1741
|
+
/**
|
|
1742
|
+
* Find the MCPL server for a tool call by checking against the prefix map.
|
|
1743
|
+
* Returns [serverId, prefix] if found, null otherwise.
|
|
1744
|
+
*/
|
|
1745
|
+
resolveMcplTool(toolName) {
|
|
1746
|
+
for (const [prefix, serverId] of this.mcplPrefixMap) {
|
|
1747
|
+
if (toolName.startsWith(prefix + '--')) {
|
|
1748
|
+
return [serverId, prefix];
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
return null;
|
|
1752
|
+
}
|
|
1753
|
+
dispatchToolCall(agentName, call) {
|
|
1754
|
+
// Enrich call with caller identity so modules can resolve the calling agent
|
|
1755
|
+
const enrichedCall = { ...call, callerAgentName: agentName };
|
|
1756
|
+
// Route MCPL tool calls to the appropriate server via prefix map
|
|
1757
|
+
const mcplMatch = this.resolveMcplTool(enrichedCall.name);
|
|
1758
|
+
if (mcplMatch && this.mcplServerRegistry) {
|
|
1759
|
+
this.dispatchMcplToolCall(agentName, enrichedCall, mcplMatch[0], mcplMatch[1]);
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
// Route synthesized channel tools
|
|
1763
|
+
if (enrichedCall.name.startsWith('channel_') && this.channelRegistry) {
|
|
1764
|
+
this.dispatchChannelToolCall(agentName, enrichedCall);
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
// Route gate:status tool
|
|
1768
|
+
if (enrichedCall.name === 'gate:status' && this.eventGate) {
|
|
1769
|
+
this.dispatchGateToolCall(agentName, enrichedCall);
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1772
|
+
const sepIndex = enrichedCall.name.indexOf('--');
|
|
1773
|
+
const moduleName = sepIndex >= 0 ? enrichedCall.name.substring(0, sepIndex) : 'unknown';
|
|
1774
|
+
const toolName = sepIndex >= 0 ? call.name.substring(sepIndex + 2) : call.name;
|
|
1775
|
+
this.pushEvent({
|
|
1776
|
+
type: 'tool-call',
|
|
1777
|
+
callId: call.id,
|
|
1778
|
+
agentName,
|
|
1779
|
+
moduleName,
|
|
1780
|
+
toolName,
|
|
1781
|
+
call,
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
dispatchToolCallEvent(event) {
|
|
1785
|
+
const { call, agentName, moduleName } = event;
|
|
1786
|
+
this.emitTrace({
|
|
1787
|
+
type: 'tool:started',
|
|
1788
|
+
module: moduleName,
|
|
1789
|
+
tool: call.name,
|
|
1790
|
+
callId: call.id,
|
|
1791
|
+
input: call.input,
|
|
1792
|
+
});
|
|
1793
|
+
const startTime = Date.now();
|
|
1794
|
+
this.moduleRegistry
|
|
1795
|
+
.handleToolCall(call)
|
|
1796
|
+
.then((result) => {
|
|
1797
|
+
const durationMs = Date.now() - startTime;
|
|
1798
|
+
this.emitTrace({
|
|
1799
|
+
type: 'tool:completed',
|
|
1800
|
+
module: moduleName,
|
|
1801
|
+
tool: call.name,
|
|
1802
|
+
callId: call.id,
|
|
1803
|
+
durationMs,
|
|
1804
|
+
});
|
|
1805
|
+
this.pushEvent({
|
|
1806
|
+
type: 'tool-result',
|
|
1807
|
+
callId: call.id,
|
|
1808
|
+
agentName,
|
|
1809
|
+
moduleName,
|
|
1810
|
+
result,
|
|
1811
|
+
});
|
|
1812
|
+
})
|
|
1813
|
+
.catch((error) => {
|
|
1814
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
1815
|
+
this.emitTrace({
|
|
1816
|
+
type: 'tool:failed',
|
|
1817
|
+
module: moduleName,
|
|
1818
|
+
tool: call.name,
|
|
1819
|
+
callId: call.id,
|
|
1820
|
+
error: err.message,
|
|
1821
|
+
stack: err.stack,
|
|
1822
|
+
});
|
|
1823
|
+
this.pushEvent({
|
|
1824
|
+
type: 'tool-result',
|
|
1825
|
+
callId: call.id,
|
|
1826
|
+
agentName,
|
|
1827
|
+
moduleName,
|
|
1828
|
+
result: {
|
|
1829
|
+
success: false,
|
|
1830
|
+
error: err.message,
|
|
1831
|
+
isError: true,
|
|
1832
|
+
},
|
|
1833
|
+
});
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
addMessage(participant, content, metadata) {
|
|
1837
|
+
// Route to the primary agent's context manager (not ephemeral subagents).
|
|
1838
|
+
const agent = this.primaryAgentName
|
|
1839
|
+
? this.agents.get(this.primaryAgentName)
|
|
1840
|
+
: this.agents.values().next().value;
|
|
1841
|
+
if (!agent) {
|
|
1842
|
+
throw new Error('No agents configured');
|
|
1843
|
+
}
|
|
1844
|
+
// If any agent has pending assistant blocks (tool_use not yet flushed),
|
|
1845
|
+
// defer non-tool_result messages to preserve tool_use → tool_result adjacency.
|
|
1846
|
+
const hasToolResult = content.some(b => b.type === 'tool_result');
|
|
1847
|
+
if (!hasToolResult && this.pendingAssistantBlocks.size > 0) {
|
|
1848
|
+
this.deferredMessages.push({ participant, content, metadata });
|
|
1849
|
+
return ''; // Deferred — will be added after tool_result flush
|
|
1850
|
+
}
|
|
1851
|
+
return agent.getContextManager().addMessage(participant, content, metadata);
|
|
1852
|
+
}
|
|
1853
|
+
editMessage(id, content) {
|
|
1854
|
+
const agent = this.primaryAgentName
|
|
1855
|
+
? this.agents.get(this.primaryAgentName)
|
|
1856
|
+
: this.agents.values().next().value;
|
|
1857
|
+
if (!agent) {
|
|
1858
|
+
throw new Error('No agents configured');
|
|
1859
|
+
}
|
|
1860
|
+
agent.getContextManager().editMessage(id, content);
|
|
1861
|
+
}
|
|
1862
|
+
removeMessage(id) {
|
|
1863
|
+
const agent = this.primaryAgentName
|
|
1864
|
+
? this.agents.get(this.primaryAgentName)
|
|
1865
|
+
: this.agents.values().next().value;
|
|
1866
|
+
if (!agent) {
|
|
1867
|
+
throw new Error('No agents configured');
|
|
1868
|
+
}
|
|
1869
|
+
agent.getContextManager().removeMessage(id);
|
|
1870
|
+
}
|
|
1871
|
+
getMessage(id) {
|
|
1872
|
+
const agent = this.primaryAgentName
|
|
1873
|
+
? this.agents.get(this.primaryAgentName)
|
|
1874
|
+
: this.agents.values().next().value;
|
|
1875
|
+
if (!agent) {
|
|
1876
|
+
return null;
|
|
1877
|
+
}
|
|
1878
|
+
return agent.getContextManager().getMessage(id);
|
|
1879
|
+
}
|
|
1880
|
+
queryMessages(filter) {
|
|
1881
|
+
const agent = this.primaryAgentName
|
|
1882
|
+
? this.agents.get(this.primaryAgentName)
|
|
1883
|
+
: this.agents.values().next().value;
|
|
1884
|
+
if (!agent) {
|
|
1885
|
+
return { messages: [], totalCount: 0 };
|
|
1886
|
+
}
|
|
1887
|
+
return agent.getContextManager().queryMessages(filter);
|
|
1888
|
+
}
|
|
1889
|
+
createFrameworkState() {
|
|
1890
|
+
return {
|
|
1891
|
+
getAgentStatus: (name) => {
|
|
1892
|
+
const agent = this.agents.get(name);
|
|
1893
|
+
return agent?.state ?? null;
|
|
1894
|
+
},
|
|
1895
|
+
getModule: (name) => {
|
|
1896
|
+
return this.moduleRegistry.getModule(name);
|
|
1897
|
+
},
|
|
1898
|
+
getPendingRequests: () => {
|
|
1899
|
+
return [...this.pendingRequests];
|
|
1900
|
+
},
|
|
1901
|
+
queueDepth: this.queue.depth,
|
|
1902
|
+
};
|
|
1903
|
+
}
|
|
1904
|
+
emitTrace(event) {
|
|
1905
|
+
const traceEvent = {
|
|
1906
|
+
...event,
|
|
1907
|
+
timestamp: Date.now(),
|
|
1908
|
+
};
|
|
1909
|
+
for (const listener of this.traceListeners) {
|
|
1910
|
+
try {
|
|
1911
|
+
listener(traceEvent);
|
|
1912
|
+
}
|
|
1913
|
+
catch (error) {
|
|
1914
|
+
console.error('Trace listener error:', error);
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
// ==========================================================================
|
|
1919
|
+
// MCPL subsystem wiring
|
|
1920
|
+
// ==========================================================================
|
|
1921
|
+
/**
|
|
1922
|
+
* Initialize all MCPL subsystems and connect configured servers.
|
|
1923
|
+
* Fail-open: individual server connection failures don't prevent framework startup.
|
|
1924
|
+
*/
|
|
1925
|
+
async initializeMcpl(serverConfigs, inferenceRouting) {
|
|
1926
|
+
this.mcplServerRegistry = new McplServerRegistry();
|
|
1927
|
+
this.featureSetManager = new FeatureSetManager();
|
|
1928
|
+
this.scopeManager = new ScopeManager();
|
|
1929
|
+
this.hookOrchestrator = new HookOrchestrator(this.mcplServerRegistry, this.featureSetManager);
|
|
1930
|
+
// Build prefix map and store configs for tool routing
|
|
1931
|
+
for (const config of serverConfigs) {
|
|
1932
|
+
const prefix = config.toolPrefix ?? `mcpl--${config.id}`;
|
|
1933
|
+
this.mcplPrefixMap.set(prefix, config.id);
|
|
1934
|
+
this.mcplServerConfigs.set(config.id, config);
|
|
1935
|
+
}
|
|
1936
|
+
// Find shouldTriggerInference callback:
|
|
1937
|
+
// Per-server callback takes precedence; fall back to EventGate; fall back to no filter.
|
|
1938
|
+
const triggerFilter = serverConfigs.find(c => c.shouldTriggerInference)?.shouldTriggerInference
|
|
1939
|
+
?? (this.eventGate ? this.eventGate.asShouldTriggerCallback() : undefined);
|
|
1940
|
+
// Push events handler (Step 6)
|
|
1941
|
+
this.pushHandler = new PushHandler(this.featureSetManager, (event) => this.pushEvent(event), (event) => this.emitTrace(event), triggerFilter);
|
|
1942
|
+
// Server-initiated inference router (Step 6)
|
|
1943
|
+
this.inferenceRouter = new InferenceRouter(this.membrane, this.hookOrchestrator, this.featureSetManager, inferenceRouting ?? null, (event) => this.emitTrace(event), (serverId, params) => {
|
|
1944
|
+
const server = this.mcplServerRegistry.getServer(serverId);
|
|
1945
|
+
server?.sendInferenceChunk(params);
|
|
1946
|
+
});
|
|
1947
|
+
// Checkpoint manager (Step 8)
|
|
1948
|
+
this.checkpointManager = new CheckpointManager(this.store, (event) => this.emitTrace(event));
|
|
1949
|
+
// Channel registry (Step 7)
|
|
1950
|
+
this.channelRegistry = new ChannelRegistry(this.mcplServerRegistry, this.featureSetManager, (event) => this.pushEvent(event), (event) => this.emitTrace(event), {
|
|
1951
|
+
sendTypingFn: (serverId, channelId) => {
|
|
1952
|
+
const server = this.mcplServerRegistry.getServer(serverId);
|
|
1953
|
+
if (server) {
|
|
1954
|
+
server.sendChannelsTyping(channelId);
|
|
1955
|
+
}
|
|
1956
|
+
},
|
|
1957
|
+
shouldTriggerInference: triggerFilter,
|
|
1958
|
+
});
|
|
1959
|
+
// Host capabilities advertised during the MCP handshake
|
|
1960
|
+
const hostCapabilities = {
|
|
1961
|
+
version: '0.4',
|
|
1962
|
+
pushEvents: true,
|
|
1963
|
+
contextHooks: {
|
|
1964
|
+
beforeInference: true,
|
|
1965
|
+
afterInference: { blocking: true },
|
|
1966
|
+
},
|
|
1967
|
+
featureSets: true,
|
|
1968
|
+
};
|
|
1969
|
+
for (const config of serverConfigs) {
|
|
1970
|
+
try {
|
|
1971
|
+
const connection = await this.mcplServerRegistry.addServer(config, hostCapabilities);
|
|
1972
|
+
// Wire event listeners then flush any events that arrived during the
|
|
1973
|
+
// handshake window (between setupMessageRouting and now).
|
|
1974
|
+
this.wireMcplEvents(connection);
|
|
1975
|
+
connection.ready();
|
|
1976
|
+
// Initialize feature sets if server advertises MCPL capabilities
|
|
1977
|
+
if (connection.capabilities) {
|
|
1978
|
+
const updateParams = this.featureSetManager.initializeServer(config.id, connection.capabilities, {
|
|
1979
|
+
enabledFeatureSets: config.enabledFeatureSets,
|
|
1980
|
+
disabledFeatureSets: config.disabledFeatureSets,
|
|
1981
|
+
});
|
|
1982
|
+
// Inform server which feature sets are enabled/disabled
|
|
1983
|
+
if (updateParams.enabled?.length || updateParams.disabled?.length) {
|
|
1984
|
+
connection.sendFeatureSetsUpdate(updateParams);
|
|
1985
|
+
}
|
|
1986
|
+
// Configure scope whitelist/blacklist patterns
|
|
1987
|
+
if (config.scopes) {
|
|
1988
|
+
this.scopeManager.configureAll(config.scopes);
|
|
1989
|
+
}
|
|
1990
|
+
// Register stateful feature sets with checkpoint manager (Step 8)
|
|
1991
|
+
if (this.checkpointManager) {
|
|
1992
|
+
const declared = this.featureSetManager.getDeclaredFeatureSets(config.id);
|
|
1993
|
+
if (declared) {
|
|
1994
|
+
for (const [fsName, fsDecl] of Object.entries(declared)) {
|
|
1995
|
+
if (fsDecl.rollback || fsDecl.hostState) {
|
|
1996
|
+
this.checkpointManager.registerFeatureSet(config.id, fsName, {
|
|
1997
|
+
hostState: fsDecl.hostState ?? false,
|
|
1998
|
+
rollback: fsDecl.rollback ?? false,
|
|
1999
|
+
});
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
|
|
2006
|
+
}
|
|
2007
|
+
catch (error) {
|
|
2008
|
+
// Fail-open: log and continue with remaining servers
|
|
2009
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2010
|
+
console.error(`Failed to connect MCPL server "${config.id}":`, err.message);
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
// Discover tools from all connected servers
|
|
2014
|
+
await this.refreshMcplTools();
|
|
2015
|
+
}
|
|
2016
|
+
/**
|
|
2017
|
+
* Wire event listeners on an MCPL server connection.
|
|
2018
|
+
* Push events and inference requests are deferred to Steps 6/7.
|
|
2019
|
+
*/
|
|
2020
|
+
wireMcplEvents(connection) {
|
|
2021
|
+
// Handle dynamic feature set changes from server
|
|
2022
|
+
connection.on('feature-sets-changed', (params) => {
|
|
2023
|
+
this.featureSetManager?.handleFeatureSetsChanged(connection.id, params);
|
|
2024
|
+
});
|
|
2025
|
+
// Handle scope elevation requests
|
|
2026
|
+
connection.on('scope-elevate', async (params, responder) => {
|
|
2027
|
+
if (this.scopeManager && responder) {
|
|
2028
|
+
try {
|
|
2029
|
+
const result = await this.scopeManager.handleElevation(params);
|
|
2030
|
+
responder.respond(result);
|
|
2031
|
+
}
|
|
2032
|
+
catch (error) {
|
|
2033
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2034
|
+
responder.respondError(-32603, err.message);
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
});
|
|
2038
|
+
// Handle push events (Step 6)
|
|
2039
|
+
connection.on('push-event', (params, responder) => {
|
|
2040
|
+
this.pushHandler?.handlePushEvent(connection.id, params, responder);
|
|
2041
|
+
});
|
|
2042
|
+
// Handle server-initiated inference requests (Step 6)
|
|
2043
|
+
connection.on('inference-request', async (params, responder) => {
|
|
2044
|
+
if (this.inferenceRouter && responder) {
|
|
2045
|
+
await this.inferenceRouter.handleInferenceRequest(connection.id, params, {
|
|
2046
|
+
respond: responder.respond,
|
|
2047
|
+
respondError: responder.respondError,
|
|
2048
|
+
requestId: responder.id,
|
|
2049
|
+
});
|
|
2050
|
+
}
|
|
2051
|
+
});
|
|
2052
|
+
// Handle channel registration (Step 7)
|
|
2053
|
+
connection.on('channels-register', async (params, responder) => {
|
|
2054
|
+
await this.channelRegistry?.handleRegister(connection.id, params, responder);
|
|
2055
|
+
});
|
|
2056
|
+
// Handle channel changes (Step 7)
|
|
2057
|
+
connection.on('channels-changed', async (params) => {
|
|
2058
|
+
await this.channelRegistry?.handleChanged(connection.id, params);
|
|
2059
|
+
});
|
|
2060
|
+
// Handle incoming channel messages (Step 7)
|
|
2061
|
+
connection.on('channels-incoming', (params, responder) => {
|
|
2062
|
+
this.channelRegistry?.handleIncoming(connection.id, params, responder);
|
|
2063
|
+
});
|
|
2064
|
+
// Handle dynamic tool list changes (notifications/tools/list_changed)
|
|
2065
|
+
connection.on('tools-list-changed', () => {
|
|
2066
|
+
this.handleToolsListChanged(connection.id);
|
|
2067
|
+
});
|
|
2068
|
+
// Also refresh tools on reconnect (server may have different tools)
|
|
2069
|
+
connection.on('reconnect', () => {
|
|
2070
|
+
this.handleToolsListChanged(connection.id);
|
|
2071
|
+
});
|
|
2072
|
+
// Cleanup on disconnect
|
|
2073
|
+
connection.on('close', () => {
|
|
2074
|
+
this.featureSetManager?.removeServer(connection.id);
|
|
2075
|
+
this.checkpointManager?.removeServer(connection.id);
|
|
2076
|
+
this.emitTrace({ type: 'module:removed', moduleName: `mcpl:${connection.id}` });
|
|
2077
|
+
});
|
|
2078
|
+
}
|
|
2079
|
+
/**
|
|
2080
|
+
* Discover tools from all connected MCPL servers and cache them.
|
|
2081
|
+
* Tools are namespaced as `{toolPrefix}--{toolName}` per server config.
|
|
2082
|
+
*/
|
|
2083
|
+
async refreshMcplTools() {
|
|
2084
|
+
if (!this.mcplServerRegistry)
|
|
2085
|
+
return;
|
|
2086
|
+
const tools = [];
|
|
2087
|
+
for (const server of this.mcplServerRegistry.getAllServers()) {
|
|
2088
|
+
const config = this.mcplServerConfigs.get(server.id);
|
|
2089
|
+
const prefix = config?.toolPrefix ?? `mcpl--${server.id}`;
|
|
2090
|
+
try {
|
|
2091
|
+
const result = await server.sendToolsList();
|
|
2092
|
+
for (const tool of result.tools) {
|
|
2093
|
+
// MCP tool schemas are generic JSON Schema; cast to membrane's ToolDefinition format
|
|
2094
|
+
const schema = tool.inputSchema;
|
|
2095
|
+
tools.push({
|
|
2096
|
+
name: `${prefix}--${tool.name}`,
|
|
2097
|
+
description: tool.description ?? '',
|
|
2098
|
+
inputSchema: schema,
|
|
2099
|
+
});
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
catch {
|
|
2103
|
+
// Server may not support tools/list — skip silently
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
this.mcplTools = tools;
|
|
2107
|
+
}
|
|
2108
|
+
/**
|
|
2109
|
+
* Handle a tools/list_changed notification with collapse logic.
|
|
2110
|
+
* At most 2 refresh cycles can be in-flight: one running and one pending.
|
|
2111
|
+
*/
|
|
2112
|
+
handleToolsListChanged(serverId) {
|
|
2113
|
+
if (this.mcplToolRefreshInFlight) {
|
|
2114
|
+
this.mcplToolRefreshPending = true;
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
this.mcplToolRefreshInFlight = true;
|
|
2118
|
+
const oldToolNames = new Set(this.mcplTools.map(t => t.name));
|
|
2119
|
+
this.refreshMcplTools()
|
|
2120
|
+
.then(() => {
|
|
2121
|
+
this.emitMcplToolDiff(oldToolNames, serverId);
|
|
2122
|
+
})
|
|
2123
|
+
.catch((error) => {
|
|
2124
|
+
console.error('MCPL tool refresh error:', error);
|
|
2125
|
+
})
|
|
2126
|
+
.finally(() => {
|
|
2127
|
+
this.mcplToolRefreshInFlight = false;
|
|
2128
|
+
if (this.mcplToolRefreshPending) {
|
|
2129
|
+
this.mcplToolRefreshPending = false;
|
|
2130
|
+
this.handleToolsListChanged(serverId);
|
|
2131
|
+
}
|
|
2132
|
+
});
|
|
2133
|
+
}
|
|
2134
|
+
/**
|
|
2135
|
+
* Emit a trace event and push an external-message listing newly added tools.
|
|
2136
|
+
*/
|
|
2137
|
+
emitMcplToolDiff(oldToolNames, serverId) {
|
|
2138
|
+
const newTools = this.mcplTools.filter(t => !oldToolNames.has(t.name));
|
|
2139
|
+
const removedTools = [...oldToolNames].filter(name => !this.mcplTools.some(t => t.name === name));
|
|
2140
|
+
if (newTools.length === 0 && removedTools.length === 0)
|
|
2141
|
+
return;
|
|
2142
|
+
this.emitTrace({
|
|
2143
|
+
type: 'module:added',
|
|
2144
|
+
moduleName: `mcpl:${serverId}:tools-refreshed`,
|
|
2145
|
+
});
|
|
2146
|
+
if (newTools.length > 0) {
|
|
2147
|
+
const toolList = newTools.map(t => t.name).join(', ');
|
|
2148
|
+
this.pushEvent({
|
|
2149
|
+
type: 'external-message',
|
|
2150
|
+
source: `mcpl:${serverId}`,
|
|
2151
|
+
content: `New tools available: ${toolList}`,
|
|
2152
|
+
metadata: { newTools: newTools.map(t => t.name), removedTools },
|
|
2153
|
+
});
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
/**
|
|
2157
|
+
* Dispatch a tool call to an MCPL server.
|
|
2158
|
+
* Strips the configured toolPrefix and routes to the server.
|
|
2159
|
+
*/
|
|
2160
|
+
dispatchMcplToolCall(agentName, call, serverId, prefix) {
|
|
2161
|
+
const toolName = call.name.slice(prefix.length + 2); // strip "{prefix}--"
|
|
2162
|
+
const server = this.mcplServerRegistry.getServer(serverId);
|
|
2163
|
+
if (!server) {
|
|
2164
|
+
this.pushEvent({
|
|
2165
|
+
type: 'tool-result',
|
|
2166
|
+
callId: call.id,
|
|
2167
|
+
agentName,
|
|
2168
|
+
moduleName: `mcpl:${serverId}`,
|
|
2169
|
+
result: { success: false, error: `MCPL server not found: ${serverId}`, isError: true },
|
|
2170
|
+
});
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
2173
|
+
this.emitTrace({ type: 'tool:started', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, input: call.input });
|
|
2174
|
+
const startTime = Date.now();
|
|
2175
|
+
const args = (call.input && typeof call.input === 'object') ? call.input : {};
|
|
2176
|
+
// Build state params for stateful tools (Step 8)
|
|
2177
|
+
let stateParams;
|
|
2178
|
+
if (this.checkpointManager) {
|
|
2179
|
+
const fs = this.checkpointManager.getStatefulFeatureSet(serverId);
|
|
2180
|
+
if (fs) {
|
|
2181
|
+
if (this.checkpointManager.isHostManaged(serverId, fs)) {
|
|
2182
|
+
stateParams = { state: this.checkpointManager.getCurrentState(serverId, fs) };
|
|
2183
|
+
}
|
|
2184
|
+
else {
|
|
2185
|
+
const cp = this.checkpointManager.getCurrentCheckpoint(serverId, fs);
|
|
2186
|
+
if (cp)
|
|
2187
|
+
stateParams = { checkpoint: cp };
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
server.sendToolsCall(toolName, args, stateParams)
|
|
2192
|
+
.then((result) => {
|
|
2193
|
+
const durationMs = Date.now() - startTime;
|
|
2194
|
+
this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, durationMs });
|
|
2195
|
+
// Record checkpoint from stateful tool response (Step 8)
|
|
2196
|
+
if (result.state && this.checkpointManager) {
|
|
2197
|
+
const fs = this.checkpointManager.getStatefulFeatureSet(serverId);
|
|
2198
|
+
if (fs) {
|
|
2199
|
+
this.checkpointManager.recordCheckpoint(serverId, fs, result.state);
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
// Convert MCP tool result to framework ToolResult
|
|
2203
|
+
const textContent = result.content
|
|
2204
|
+
?.filter((c) => c.type === 'text' && c.text)
|
|
2205
|
+
.map((c) => c.text)
|
|
2206
|
+
.join('\n');
|
|
2207
|
+
this.pushEvent({
|
|
2208
|
+
type: 'tool-result',
|
|
2209
|
+
callId: call.id,
|
|
2210
|
+
agentName,
|
|
2211
|
+
moduleName: `mcpl:${serverId}`,
|
|
2212
|
+
result: {
|
|
2213
|
+
success: !result.isError,
|
|
2214
|
+
data: result.isError ? undefined : textContent || undefined,
|
|
2215
|
+
error: result.isError ? (textContent || 'Tool call failed') : undefined,
|
|
2216
|
+
isError: result.isError ?? false,
|
|
2217
|
+
},
|
|
2218
|
+
});
|
|
2219
|
+
})
|
|
2220
|
+
.catch((error) => {
|
|
2221
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2222
|
+
this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: err.message, stack: err.stack });
|
|
2223
|
+
this.pushEvent({
|
|
2224
|
+
type: 'tool-result',
|
|
2225
|
+
callId: call.id,
|
|
2226
|
+
agentName,
|
|
2227
|
+
moduleName: `mcpl:${serverId}`,
|
|
2228
|
+
result: { success: false, error: err.message, isError: true },
|
|
2229
|
+
});
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
/**
|
|
2233
|
+
* Build BeforeInferenceParams from agent state and trigger context.
|
|
2234
|
+
*/
|
|
2235
|
+
/**
|
|
2236
|
+
* Dispatch a synthesized channel tool call.
|
|
2237
|
+
*/
|
|
2238
|
+
dispatchChannelToolCall(agentName, call) {
|
|
2239
|
+
this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input });
|
|
2240
|
+
const startTime = Date.now();
|
|
2241
|
+
this.channelRegistry.handleChannelToolCall(call.name, call.input)
|
|
2242
|
+
.then((result) => {
|
|
2243
|
+
const durationMs = Date.now() - startTime;
|
|
2244
|
+
this.emitTrace({ type: 'tool:completed', module: 'channels', tool: call.name, callId: call.id, durationMs });
|
|
2245
|
+
this.pushEvent({
|
|
2246
|
+
type: 'tool-result',
|
|
2247
|
+
callId: call.id,
|
|
2248
|
+
agentName,
|
|
2249
|
+
moduleName: 'channels',
|
|
2250
|
+
result,
|
|
2251
|
+
});
|
|
2252
|
+
})
|
|
2253
|
+
.catch((error) => {
|
|
2254
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2255
|
+
this.emitTrace({ type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id, error: err.message });
|
|
2256
|
+
this.pushEvent({
|
|
2257
|
+
type: 'tool-result',
|
|
2258
|
+
callId: call.id,
|
|
2259
|
+
agentName,
|
|
2260
|
+
moduleName: 'channels',
|
|
2261
|
+
result: { success: false, error: err.message, isError: true },
|
|
2262
|
+
});
|
|
2263
|
+
});
|
|
2264
|
+
}
|
|
2265
|
+
dispatchGateToolCall(agentName, call) {
|
|
2266
|
+
this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
|
|
2267
|
+
const startTime = Date.now();
|
|
2268
|
+
this.eventGate.handleToolCall()
|
|
2269
|
+
.then((result) => {
|
|
2270
|
+
const durationMs = Date.now() - startTime;
|
|
2271
|
+
this.emitTrace({ type: 'tool:completed', module: 'gate', tool: call.name, callId: call.id, durationMs });
|
|
2272
|
+
this.pushEvent({
|
|
2273
|
+
type: 'tool-result',
|
|
2274
|
+
callId: call.id,
|
|
2275
|
+
agentName,
|
|
2276
|
+
moduleName: 'gate',
|
|
2277
|
+
result,
|
|
2278
|
+
});
|
|
2279
|
+
})
|
|
2280
|
+
.catch((error) => {
|
|
2281
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2282
|
+
this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
|
|
2283
|
+
this.pushEvent({
|
|
2284
|
+
type: 'tool-result',
|
|
2285
|
+
callId: call.id,
|
|
2286
|
+
agentName,
|
|
2287
|
+
moduleName: 'gate',
|
|
2288
|
+
result: { success: false, error: err.message, isError: true },
|
|
2289
|
+
});
|
|
2290
|
+
});
|
|
2291
|
+
}
|
|
2292
|
+
buildBeforeInferenceParams(agent, trigger) {
|
|
2293
|
+
const inferenceId = `${agent.name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2294
|
+
return {
|
|
2295
|
+
inferenceId,
|
|
2296
|
+
conversationId: agent.name, // Simplified; proper conversation tracking TODO
|
|
2297
|
+
turnIndex: 0, // Simplified; needs per-conversation counter TODO
|
|
2298
|
+
userMessage: null, // Could extract from trigger context
|
|
2299
|
+
model: {
|
|
2300
|
+
id: agent.model,
|
|
2301
|
+
vendor: 'unknown',
|
|
2302
|
+
contextWindow: 200000,
|
|
2303
|
+
capabilities: ['tools'],
|
|
2304
|
+
},
|
|
2305
|
+
channels: this.channelRegistry?.buildChannelContext(),
|
|
2306
|
+
};
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
//# sourceMappingURL=framework.js.map
|