@dotdrelle/wiki-manager 0.15.66 → 0.15.70
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/.env.example +10 -3
- package/README.md +54 -0
- package/agent-runtimes.example.json +68 -0
- package/agents.docker-compose.yml +35 -1
- package/docker-compose.yml +3 -3
- package/package.json +3 -2
- package/src/agent/graph.js +12 -11
- package/src/agent/skillRecursion.test.js +13 -12
- package/src/cli/wiki-manager.js +124 -36
- package/src/commands/slash.js +38 -3
- package/src/contracts/schemas.js +67 -0
- package/src/core/activity.js +5 -0
- package/src/core/agentEvents.js +18 -1
- package/src/core/buildInfo.json +2 -2
- package/src/core/dockerCompose.test.js +8 -40
- package/src/core/env.js +14 -0
- package/src/core/env.test.js +19 -0
- package/src/core/googleGrants.test.js +1 -1
- package/src/core/mcp.js +1 -1
- package/src/core/runtimeEventAdapter.js +81 -0
- package/src/core/runtimeEventAdapter.test.js +61 -0
- package/src/core/skillChainView.test.js +2 -2
- package/src/core/skillCompiler.test.js +1 -1
- package/src/core/skillInvocation.js +13 -8
- package/src/core/startupCheck.js +58 -0
- package/src/core/startupCheck.test.js +29 -1
- package/src/orchestrator/agentRegistry.js +1 -22
- package/src/orchestrator/assignmentManager.js +16 -4
- package/src/orchestrator/capabilityRegistry.js +8 -1
- package/src/orchestrator/dispatcher.js +361 -2
- package/src/orchestrator/dispatcher.test.js +112 -1
- package/src/orchestrator/objectiveResolver.js +10 -6
- package/src/orchestrator/objectiveResolver.test.js +26 -27
- package/src/orchestrator/providers/deepAgentsProvider.js +168 -0
- package/src/orchestrator/providers/deepAgentsProvider.test.js +178 -0
- package/src/orchestrator/providers/dispatcherExternalRuntime.test.js +409 -0
- package/src/orchestrator/providers/fakeRuntimeProvider.js +164 -0
- package/src/orchestrator/providers/fakeRuntimeProvider.test.js +201 -0
- package/src/orchestrator/providers/runtimeProvider.js +101 -0
- package/src/orchestrator/providers/runtimeProviders.js +325 -0
- package/src/orchestrator/providers/runtimeProviders.test.js +361 -0
- package/src/orchestrator/resultAggregator.js +35 -2
- package/src/orchestrator/resultAggregator.test.js +62 -0
- package/src/runtime/recoveryManager.js +70 -5
- package/src/runtime/skillChain.e2e.test.js +2 -2
- package/src/runtime/supervisor.js +5 -10
- package/src/shell/RightPane.tsx +9 -1
- package/src/shell/StartupScreen.tsx +44 -7
- package/src/shell/repl.test.js +13 -0
- package/wiki-workspace +19 -3
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { capabilityRegistryForSession, createCapabilityRegistry } from '../capabilityRegistry.js';
|
|
7
|
+
import { CapabilityUnavailableError, resolve } from '../capabilityResolver.js';
|
|
8
|
+
import { createAssignmentManager } from '../assignmentManager.js';
|
|
9
|
+
import { resolveObjective } from '../objectiveResolver.js';
|
|
10
|
+
import { createFakeRuntimeProvider } from './fakeRuntimeProvider.js';
|
|
11
|
+
import {
|
|
12
|
+
discoverRuntimeProviderAgents,
|
|
13
|
+
discoverRuntimeProvidersOnce,
|
|
14
|
+
loadAgentRuntimesConfig,
|
|
15
|
+
resolveRuntimeProviders,
|
|
16
|
+
} from './runtimeProviders.js';
|
|
17
|
+
|
|
18
|
+
function mcpAgent(agentInstanceId, capabilityId) {
|
|
19
|
+
return {
|
|
20
|
+
agentInstanceId,
|
|
21
|
+
health: 'available',
|
|
22
|
+
description: {
|
|
23
|
+
contractVersion: '1',
|
|
24
|
+
agentType: agentInstanceId.split('-')[0],
|
|
25
|
+
agentInstanceId,
|
|
26
|
+
displayName: agentInstanceId,
|
|
27
|
+
capabilities: [{
|
|
28
|
+
id: capabilityId,
|
|
29
|
+
version: '1',
|
|
30
|
+
description: capabilityId,
|
|
31
|
+
inputSchema: {},
|
|
32
|
+
outputSchema: {},
|
|
33
|
+
supportedOperations: ['run'],
|
|
34
|
+
}],
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('discovery projects a live runtime into synthetic agents', async () => {
|
|
40
|
+
const provider = createFakeRuntimeProvider({
|
|
41
|
+
runtime: 'deepagents',
|
|
42
|
+
capabilities: [{ name: 'agent.review', operations: ['run'] }],
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const { agents, unavailable } = await discoverRuntimeProviderAgents([
|
|
46
|
+
{ id: 'deepagents', provider },
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
assert.equal(unavailable.length, 0);
|
|
50
|
+
assert.equal(agents.length, 1);
|
|
51
|
+
const agent = agents[0];
|
|
52
|
+
assert.equal(agent.agentInstanceId, 'deepagents::agent.review');
|
|
53
|
+
assert.equal(agent.providerKind, 'external-runtime');
|
|
54
|
+
assert.equal(agent.health, 'available');
|
|
55
|
+
assert.equal(agent.serverName, null);
|
|
56
|
+
assert.equal(agent.runtimeProvider, provider);
|
|
57
|
+
assert.equal(agent.description.capabilities[0].id, 'agent.review');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('a down runtime yields no agents and is reported', async () => {
|
|
61
|
+
const provider = createFakeRuntimeProvider({ available: false });
|
|
62
|
+
|
|
63
|
+
const { agents, unavailable } = await discoverRuntimeProviderAgents([
|
|
64
|
+
{ id: 'deepagents', provider },
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
assert.equal(agents.length, 0);
|
|
68
|
+
assert.equal(unavailable.length, 1);
|
|
69
|
+
assert.equal(unavailable[0].runtimeId, 'deepagents');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('the capability registry carries providerKind and runtimeProvider for external entries', async () => {
|
|
73
|
+
const provider = createFakeRuntimeProvider({ capabilities: [{ name: 'agent.review', operations: ['run'] }] });
|
|
74
|
+
const { agents } = await discoverRuntimeProviderAgents([{ id: 'deepagents', provider }]);
|
|
75
|
+
|
|
76
|
+
const registry = createCapabilityRegistry({ agents });
|
|
77
|
+
|
|
78
|
+
const providers = registry.providersFor('agent.review');
|
|
79
|
+
assert.equal(providers.length, 1);
|
|
80
|
+
assert.equal(providers[0].providerKind, 'external-runtime');
|
|
81
|
+
assert.equal(providers[0].runtimeProvider, provider);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('resolve routes agent.* to the external runtime agent', async () => {
|
|
85
|
+
const provider = createFakeRuntimeProvider({ capabilities: [{ name: 'agent.review', operations: ['run'] }] });
|
|
86
|
+
const { agents } = await discoverRuntimeProviderAgents([{ id: 'deepagents', provider }]);
|
|
87
|
+
const registry = createCapabilityRegistry({ agents });
|
|
88
|
+
|
|
89
|
+
const assignment = resolve('agent.review', { workspaceConfig: {}, registry });
|
|
90
|
+
|
|
91
|
+
assert.equal(assignment.agentInstanceId, 'deepagents::agent.review');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('assignmentManager produces an external-runtime assignment with a null serverName', async () => {
|
|
95
|
+
const provider = createFakeRuntimeProvider({ capabilities: [{ name: 'agent.review', operations: ['run'] }] });
|
|
96
|
+
const { agents } = await discoverRuntimeProviderAgents([{ id: 'deepagents', provider }]);
|
|
97
|
+
const session = {
|
|
98
|
+
capabilityRegistry: createCapabilityRegistry({ agents }),
|
|
99
|
+
agentRegistrySnapshot: [],
|
|
100
|
+
agents: [],
|
|
101
|
+
};
|
|
102
|
+
const assign = createAssignmentManager({ session });
|
|
103
|
+
|
|
104
|
+
const assignment = await assign.assign({
|
|
105
|
+
id: 'review-a',
|
|
106
|
+
requiredCapability: 'agent.review',
|
|
107
|
+
operation: 'run',
|
|
108
|
+
arguments: {},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
assert.equal(assignment.agentInstanceId, 'deepagents::agent.review');
|
|
112
|
+
assert.equal(assignment.serverName, null);
|
|
113
|
+
assert.equal(assignment.runtimeProvider, provider);
|
|
114
|
+
assert.equal(assignment.providerKind, 'external-runtime');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('a down runtime is isolated: agent.* resolves nowhere while MCP capabilities still do', async () => {
|
|
118
|
+
const { agents } = await discoverRuntimeProviderAgents([
|
|
119
|
+
{ id: 'deepagents', provider: createFakeRuntimeProvider({ available: false }) },
|
|
120
|
+
]);
|
|
121
|
+
assert.equal(agents.length, 0);
|
|
122
|
+
|
|
123
|
+
const session = {
|
|
124
|
+
agentRegistry: { snapshot: () => [mcpAgent('production-main', 'knowledge.update')] },
|
|
125
|
+
agentRegistrySnapshot: [],
|
|
126
|
+
};
|
|
127
|
+
const registry = capabilityRegistryForSession(session);
|
|
128
|
+
|
|
129
|
+
// The external capability is absent because its runtime is down.
|
|
130
|
+
assert.throws(
|
|
131
|
+
() => resolve('agent.review', { workspaceConfig: {}, registry }),
|
|
132
|
+
(error) => error instanceof CapabilityUnavailableError && error.reason === 'capability_not_found',
|
|
133
|
+
);
|
|
134
|
+
// The MCP capability is unaffected.
|
|
135
|
+
const mcpAssignment = resolve('knowledge.update', { workspaceConfig: {}, registry });
|
|
136
|
+
assert.equal(mcpAssignment.agentInstanceId, 'production-main');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
function sessionWithEvents() {
|
|
140
|
+
return { workspace: 'test', mcp: {}, agentEvents: [], activities: {} };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
test('resolveRuntimeProviders maps enabled entries and skips unknown types', () => {
|
|
144
|
+
const { providers, skipped } = resolveRuntimeProviders([
|
|
145
|
+
{ id: 'fake-1', type: 'fake', capabilities: [{ name: 'agent.echo', operations: ['run'] }] },
|
|
146
|
+
{ id: 'mystery', type: 'mystery-runtime' },
|
|
147
|
+
{ id: 'off', type: 'fake', enabled: false },
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
assert.equal(providers.length, 1);
|
|
151
|
+
assert.equal(providers[0].id, 'fake-1');
|
|
152
|
+
assert.equal(providers[0].type, 'fake');
|
|
153
|
+
assert.equal(skipped.length, 1);
|
|
154
|
+
assert.equal(skipped[0].id, 'mystery');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('loadAgentRuntimesConfig reads both array and object forms', () => {
|
|
158
|
+
const dir = mkdtempSync(join(tmpdir(), 'agent-runtimes-'));
|
|
159
|
+
try {
|
|
160
|
+
writeFileSync(join(dir, 'agent-runtimes.json'), JSON.stringify({ runtimes: [{ id: 'a', type: 'fake' }] }));
|
|
161
|
+
assert.deepEqual(loadAgentRuntimesConfig({ stateDir: dir }), [{ id: 'a', type: 'fake' }]);
|
|
162
|
+
} finally {
|
|
163
|
+
rmSync(dir, { recursive: true, force: true });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('loadAgentRuntimesConfig returns [] for a missing or unreadable file', () => {
|
|
168
|
+
const dir = mkdtempSync(join(tmpdir(), 'agent-runtimes-'));
|
|
169
|
+
try {
|
|
170
|
+
assert.deepEqual(loadAgentRuntimesConfig({ stateDir: dir, env: {} }), []);
|
|
171
|
+
writeFileSync(join(dir, 'agent-runtimes.json'), '{not json');
|
|
172
|
+
const logs = [];
|
|
173
|
+
assert.deepEqual(loadAgentRuntimesConfig({ stateDir: dir, log: (message) => logs.push(message), env: {} }), []);
|
|
174
|
+
assert.equal(logs.length, 1);
|
|
175
|
+
} finally {
|
|
176
|
+
rmSync(dir, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('GATEWAY_ENABLED implies the deepagents runtime with the bearer token (one switch)', () => {
|
|
181
|
+
const dir = mkdtempSync(join(tmpdir(), 'agent-runtimes-'));
|
|
182
|
+
try {
|
|
183
|
+
const config = loadAgentRuntimesConfig({
|
|
184
|
+
stateDir: dir,
|
|
185
|
+
env: { GATEWAY_ENABLED: 'true', GATEWAY_PORT: '7789', GATEWAY_AUTH_TOKEN: 'tok-1' },
|
|
186
|
+
});
|
|
187
|
+
const implied = config.find((entry) => entry.type === 'deepagents');
|
|
188
|
+
assert.ok(implied, 'the gateway is declared when GATEWAY_ENABLED is set');
|
|
189
|
+
assert.equal(implied.enabled, true);
|
|
190
|
+
assert.equal(implied.endpoint, 'http://localhost:7789');
|
|
191
|
+
assert.deepEqual(implied.headers, { Authorization: 'Bearer tok-1' });
|
|
192
|
+
} finally {
|
|
193
|
+
rmSync(dir, { recursive: true, force: true });
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('an explicit enabled deepagents entry wins over the implied one', () => {
|
|
198
|
+
const dir = mkdtempSync(join(tmpdir(), 'agent-runtimes-'));
|
|
199
|
+
try {
|
|
200
|
+
writeFileSync(join(dir, 'agent-runtimes.json'), JSON.stringify({
|
|
201
|
+
runtimes: [{ id: 'deepagents', type: 'deepagents', endpoint: 'http://custom:9000', enabled: true }],
|
|
202
|
+
}));
|
|
203
|
+
const config = loadAgentRuntimesConfig({ stateDir: dir, env: { GATEWAY_ENABLED: 'true' } });
|
|
204
|
+
assert.equal(config.length, 1);
|
|
205
|
+
assert.equal(config[0].endpoint, 'http://custom:9000');
|
|
206
|
+
} finally {
|
|
207
|
+
rmSync(dir, { recursive: true, force: true });
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('discoverRuntimeProvidersOnce populates session.runtimeProviderAgents and announces down runtimes', async () => {
|
|
212
|
+
const session = sessionWithEvents();
|
|
213
|
+
|
|
214
|
+
const agents = await discoverRuntimeProvidersOnce(session, {
|
|
215
|
+
config: [
|
|
216
|
+
{ id: 'fake', type: 'fake', capabilities: [{ name: 'agent.echo', operations: ['run'] }] },
|
|
217
|
+
{ id: 'down', type: 'fake', available: false },
|
|
218
|
+
],
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
assert.equal(agents.length, 1);
|
|
222
|
+
assert.equal(session.runtimeProviderAgents.length, 1);
|
|
223
|
+
assert.equal(session.runtimeProviderAgents[0].agentInstanceId, 'fake::agent.echo');
|
|
224
|
+
|
|
225
|
+
const logs = (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log');
|
|
226
|
+
assert.ok(
|
|
227
|
+
logs.some((event) => String(event.payload?.message ?? '').includes('down')
|
|
228
|
+
&& String(event.payload?.message ?? '').includes('unavailable')),
|
|
229
|
+
'a down runtime is announced in the journal',
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('discoverRuntimeProvidersOnce announces a down runtime only once across re-scans', async () => {
|
|
234
|
+
const session = sessionWithEvents();
|
|
235
|
+
const config = [{ id: 'down', type: 'fake', available: false }];
|
|
236
|
+
|
|
237
|
+
await discoverRuntimeProvidersOnce(session, { config });
|
|
238
|
+
await discoverRuntimeProvidersOnce(session, { config });
|
|
239
|
+
|
|
240
|
+
const logs = (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log'
|
|
241
|
+
&& String(event.payload?.message ?? '').includes('unavailable'));
|
|
242
|
+
assert.equal(logs.length, 1, 'the degradation is announced once, not on every re-scan');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('a transient probe failure keeps the last-known capability set (a failed probe is not a lost agent)', async () => {
|
|
246
|
+
const session = sessionWithEvents();
|
|
247
|
+
const up = [{ id: 'gw', type: 'fake', capabilities: [{ name: 'agent.review', operations: ['run'] }] }];
|
|
248
|
+
const down = [{ id: 'gw', type: 'fake', available: false }];
|
|
249
|
+
|
|
250
|
+
await discoverRuntimeProvidersOnce(session, { config: up });
|
|
251
|
+
assert.equal(session.runtimeProviderAgents.length, 1);
|
|
252
|
+
|
|
253
|
+
// Network blip during a periodic re-scan: discovery returns nothing.
|
|
254
|
+
await discoverRuntimeProvidersOnce(session, { config: down });
|
|
255
|
+
assert.equal(session.runtimeProviderAgents.length, 1, 'the capability survives the blip');
|
|
256
|
+
assert.equal(session.runtimeProviderAgents[0].agentInstanceId, 'gw::agent.review');
|
|
257
|
+
|
|
258
|
+
const logs = (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log'
|
|
259
|
+
&& String(event.payload?.message ?? '').includes('keeping'));
|
|
260
|
+
assert.equal(logs.length, 1, 'the preservation is announced');
|
|
261
|
+
|
|
262
|
+
// Recovery: a fresh answer is authoritative again.
|
|
263
|
+
await discoverRuntimeProvidersOnce(session, { config: up });
|
|
264
|
+
assert.equal(session.runtimeProviderAgents.length, 1);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('a healthy runtime that answers is authoritative — its set replaces, never merges with, the last-known one', async () => {
|
|
268
|
+
const session = sessionWithEvents();
|
|
269
|
+
await discoverRuntimeProvidersOnce(session, {
|
|
270
|
+
config: [{ id: 'gw', type: 'fake', capabilities: [{ name: 'agent.review', operations: ['run'] }] }],
|
|
271
|
+
});
|
|
272
|
+
assert.deepEqual(session.runtimeProviderAgents.map((a) => a.agentInstanceId), ['gw::agent.review']);
|
|
273
|
+
|
|
274
|
+
await discoverRuntimeProvidersOnce(session, {
|
|
275
|
+
config: [{ id: 'gw', type: 'fake', capabilities: [{ name: 'agent.consistency', operations: ['run'] }] }],
|
|
276
|
+
});
|
|
277
|
+
assert.deepEqual(
|
|
278
|
+
session.runtimeProviderAgents.map((a) => a.agentInstanceId),
|
|
279
|
+
['gw::agent.consistency'],
|
|
280
|
+
'the stale agent.review is gone, not kept alongside',
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('GATEWAY_ENABLED inherits the disabled entry\'s declared capabilities', () => {
|
|
285
|
+
const dir = mkdtempSync(join(tmpdir(), 'agent-runtimes-'));
|
|
286
|
+
try {
|
|
287
|
+
writeFileSync(join(dir, 'agent-runtimes.json'), JSON.stringify({
|
|
288
|
+
runtimes: [{
|
|
289
|
+
id: 'deepagents',
|
|
290
|
+
type: 'deepagents',
|
|
291
|
+
endpoint: 'http://agent-runtime:7789',
|
|
292
|
+
enabled: false,
|
|
293
|
+
capabilities: [
|
|
294
|
+
{ name: 'agent.notify', operations: ['run'], defaultRequiresApproval: true },
|
|
295
|
+
],
|
|
296
|
+
}],
|
|
297
|
+
}));
|
|
298
|
+
const config = loadAgentRuntimesConfig({ stateDir: dir, env: { GATEWAY_ENABLED: 'true' } });
|
|
299
|
+
const implied = config.find((entry) => entry.type === 'deepagents');
|
|
300
|
+
assert.ok(implied);
|
|
301
|
+
assert.equal(implied.enabled, true);
|
|
302
|
+
assert.equal(implied.endpoint, 'http://localhost:7789', 'the endpoint stays host-local');
|
|
303
|
+
assert.deepEqual(implied.capabilities, [
|
|
304
|
+
{ name: 'agent.notify', operations: ['run'], defaultRequiresApproval: true },
|
|
305
|
+
], 'the shipped approval metadata is not discarded');
|
|
306
|
+
assert.equal(config.filter((entry) => entry.type === 'deepagents').length, 1, 'no duplicate entry');
|
|
307
|
+
} finally {
|
|
308
|
+
rmSync(dir, { recursive: true, force: true });
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('external runtime capabilities carry aliases through to the registry', async () => {
|
|
313
|
+
const provider = createFakeRuntimeProvider({
|
|
314
|
+
capabilities: [{ name: 'agent.review', operations: ['run'], aliases: ['audit', 'review', 'analyze'] }],
|
|
315
|
+
});
|
|
316
|
+
const { agents } = await discoverRuntimeProviderAgents([{ id: 'deepagents', provider }]);
|
|
317
|
+
|
|
318
|
+
assert.deepEqual(agents[0].description.capabilities[0].aliases, ['audit', 'review', 'analyze']);
|
|
319
|
+
assert.equal(agents[0].description.orchestration.canPlan, false);
|
|
320
|
+
assert.equal(agents[0].description.orchestration.singleTaskOnly, true, 'external runtimes are executor-only single-task');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('external runtime capabilities carry mutationClass and defaultRequiresApproval through', async () => {
|
|
324
|
+
const provider = createFakeRuntimeProvider({
|
|
325
|
+
capabilities: [
|
|
326
|
+
{ name: 'agent.research', operations: ['run'], mutationClass: 'ingest' },
|
|
327
|
+
{ name: 'agent.notify', operations: ['run'], defaultRequiresApproval: true },
|
|
328
|
+
],
|
|
329
|
+
});
|
|
330
|
+
const { agents } = await discoverRuntimeProviderAgents([{ id: 'deepagents', provider }]);
|
|
331
|
+
|
|
332
|
+
const byName = new Map(
|
|
333
|
+
agents.map((agent) => [agent.description.capabilities[0].id, agent.description.capabilities[0]]),
|
|
334
|
+
);
|
|
335
|
+
assert.equal(byName.get('agent.research').mutationClass, 'ingest');
|
|
336
|
+
assert.equal(byName.get('agent.research').defaultRequiresApproval, undefined);
|
|
337
|
+
assert.equal(byName.get('agent.notify').defaultRequiresApproval, true);
|
|
338
|
+
assert.equal(byName.get('agent.notify').mutationClass, undefined);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test('external runtime capabilities carry aliasOperations through', async () => {
|
|
342
|
+
const provider = createFakeRuntimeProvider({
|
|
343
|
+
capabilities: [{ name: 'agent.plan', operations: ['plan', 'run'], aliasOperations: { plan: 'plan', apply: 'run' } }],
|
|
344
|
+
});
|
|
345
|
+
const { agents } = await discoverRuntimeProviderAgents([{ id: 'deepagents', provider }]);
|
|
346
|
+
|
|
347
|
+
assert.deepEqual(agents[0].description.capabilities[0].aliasOperations, { plan: 'plan', apply: 'run' });
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test('resolveObjective deterministically routes "audit" to agent.review via aliases (no LLM)', async () => {
|
|
351
|
+
const provider = createFakeRuntimeProvider({
|
|
352
|
+
capabilities: [{ name: 'agent.review', operations: ['run'], aliases: ['audit', 'review', 'analyze'] }],
|
|
353
|
+
});
|
|
354
|
+
const { agents } = await discoverRuntimeProviderAgents([{ id: 'deepagents', provider }]);
|
|
355
|
+
const session = { runtimeProviderAgents: agents };
|
|
356
|
+
|
|
357
|
+
const selection = await resolveObjective('audit la couverture conceptuelle', session);
|
|
358
|
+
|
|
359
|
+
assert.equal(selection.capability, 'agent.review');
|
|
360
|
+
assert.equal(selection.operation, 'run');
|
|
361
|
+
});
|
|
@@ -2,6 +2,7 @@ import { validateContract } from '../contracts/schemas.js';
|
|
|
2
2
|
import { parseJsonText } from '../core/activity.js';
|
|
3
3
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
4
4
|
import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
|
|
5
|
+
import { capabilityRegistryForSession } from './capabilityRegistry.js';
|
|
5
6
|
import { resolve as resolveCapability } from './capabilityResolver.js';
|
|
6
7
|
import { integrate } from './planIntegrator.js';
|
|
7
8
|
import { validateFragment } from './planValidator.js';
|
|
@@ -112,7 +113,11 @@ async function maybeExpandPlan(result, {
|
|
|
112
113
|
return rejectExpansion({ session, runId, taskId, store, errors: requestValidation.errors.map((message) => ({ code: 'invalid_plan_expansion_request', message })) });
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
|
|
116
|
+
// session.capabilityRegistry is never assigned anywhere in production;
|
|
117
|
+
// falling back to it here meant every planExpansionRequest resolved
|
|
118
|
+
// against `undefined` and was unconditionally rejected as
|
|
119
|
+
// capability_unavailable, regardless of whether the capability existed.
|
|
120
|
+
const effectiveRegistry = registry ?? capabilityRegistryForSession(session);
|
|
116
121
|
let resolved;
|
|
117
122
|
try {
|
|
118
123
|
resolved = resolveCapability(request.capability, {
|
|
@@ -151,6 +156,24 @@ async function maybeExpandPlan(result, {
|
|
|
151
156
|
const toolName = toolNameFor(session, serverName, 'agent_plan');
|
|
152
157
|
const planRequest = agentPlanRequest(request, session);
|
|
153
158
|
const fragment = parseToolPayload(await callTool(session.mcp, serverName, toolName, planRequest));
|
|
159
|
+
// A planner that REFUSES answers { ok: false, error } (the production agent
|
|
160
|
+
// does, e.g. "knowledge.update cannot plan operation: doctor"). Feeding that
|
|
161
|
+
// envelope to validateFragment turned the planner's actual sentence into
|
|
162
|
+
// contract noise ("taskGraphFragment.ok is not allowed") — the human saw a
|
|
163
|
+
// schema violation instead of the reason. Surface the planner's words.
|
|
164
|
+
if (fragment && typeof fragment === 'object' && !Array.isArray(fragment) && fragment.ok === false) {
|
|
165
|
+
return rejectExpansion({
|
|
166
|
+
session,
|
|
167
|
+
runId,
|
|
168
|
+
taskId,
|
|
169
|
+
store,
|
|
170
|
+
errors: [{
|
|
171
|
+
code: 'planner_rejected',
|
|
172
|
+
message: String(fragment.error ?? 'the planner rejected the objective without a reason'),
|
|
173
|
+
details: { capability: request.capability, operation: request.operation ?? null },
|
|
174
|
+
}],
|
|
175
|
+
});
|
|
176
|
+
}
|
|
154
177
|
const validation = validateFragment(fragment, {
|
|
155
178
|
registry: effectiveRegistry,
|
|
156
179
|
run: { plannerAgentInstanceId: resolved.agentInstanceId },
|
|
@@ -211,7 +234,17 @@ function agentPlanRequest(request, session) {
|
|
|
211
234
|
objective: request.objective ?? request.reason ?? undefined,
|
|
212
235
|
workspace: request.workspace ?? workspaceRequest(session),
|
|
213
236
|
arguments: request.arguments && typeof request.arguments === 'object' ? request.arguments : {},
|
|
214
|
-
constraints:
|
|
237
|
+
constraints: {
|
|
238
|
+
...(request.constraints && typeof request.constraints === 'object' ? request.constraints : {}),
|
|
239
|
+
// Default governance, same as prepareDelegation: mutations wait for a
|
|
240
|
+
// human grant. Omitting it here let the planner answer mutating tasks
|
|
241
|
+
// with requiresApproval:false — the scheduler dispatched an ingest
|
|
242
|
+
// plan with no gate, and the production agent's confirmation guard
|
|
243
|
+
// then rejected every job ("requires confirm=true").
|
|
244
|
+
// requireApprovalForMutations: false from the proposal itself remains
|
|
245
|
+
// an explicit opt-out (a runtime declaring its own policy).
|
|
246
|
+
requireApprovalForMutations: request.constraints?.requireApprovalForMutations !== false,
|
|
247
|
+
},
|
|
215
248
|
};
|
|
216
249
|
}
|
|
217
250
|
|
|
@@ -209,3 +209,65 @@ function expansionRegistry() {
|
|
|
209
209
|
},
|
|
210
210
|
};
|
|
211
211
|
}
|
|
212
|
+
|
|
213
|
+
test('resultAggregator surfaces the planner rejection instead of contract noise', async () => {
|
|
214
|
+
const session = sessionWithPlan();
|
|
215
|
+
session.approvals = [{ id: 'approval-run', scope: 'run', status: 'approved', runId: 'run-expansion' }];
|
|
216
|
+
|
|
217
|
+
const result = await accept(taskResultWithExpansion(), {
|
|
218
|
+
session,
|
|
219
|
+
runId: 'run-expansion',
|
|
220
|
+
task: session.headlessPlan[0],
|
|
221
|
+
assignment: { agentInstanceId: 'production-main', serverName: 'production' },
|
|
222
|
+
registry: expansionRegistry(),
|
|
223
|
+
callTool: async () => ({ ok: false, error: 'knowledge.update cannot plan operation: doctor' }),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
assert.equal(result.ok, true, 'the producing task itself completed');
|
|
227
|
+
assert.equal(result.expansion.ok, false);
|
|
228
|
+
assert.equal(result.expansion.errors[0].code, 'planner_rejected');
|
|
229
|
+
assert.match(result.expansion.errors[0].message, /cannot plan operation: doctor/);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('resultAggregator defaults requireApprovalForMutations to true on the plan request', async () => {
|
|
233
|
+
const session = sessionWithPlan();
|
|
234
|
+
session.approvals = [{ id: 'approval-run', scope: 'run', status: 'approved', runId: 'run-expansion' }];
|
|
235
|
+
const calls = [];
|
|
236
|
+
|
|
237
|
+
await accept(taskResultWithExpansion(), {
|
|
238
|
+
session,
|
|
239
|
+
runId: 'run-expansion',
|
|
240
|
+
task: session.headlessPlan[0],
|
|
241
|
+
assignment: { agentInstanceId: 'production-main', serverName: 'production' },
|
|
242
|
+
registry: expansionRegistry(),
|
|
243
|
+
callTool: async (_mcp, serverName, toolName, args) => {
|
|
244
|
+
calls.push(args);
|
|
245
|
+
return expansionFragment();
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
assert.equal(calls[0].constraints.requireApprovalForMutations, true);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('resultAggregator honours an explicit opt-out from the proposal constraints', async () => {
|
|
253
|
+
const session = sessionWithPlan();
|
|
254
|
+
session.approvals = [{ id: 'approval-run', scope: 'run', status: 'approved', runId: 'run-expansion' }];
|
|
255
|
+
const calls = [];
|
|
256
|
+
const expansion = taskResultWithExpansion();
|
|
257
|
+
expansion.planExpansionRequest.constraints = { requireApprovalForMutations: false, maxTasks: 2 };
|
|
258
|
+
|
|
259
|
+
await accept(expansion, {
|
|
260
|
+
session,
|
|
261
|
+
runId: 'run-expansion',
|
|
262
|
+
task: session.headlessPlan[0],
|
|
263
|
+
assignment: { agentInstanceId: 'production-main', serverName: 'production' },
|
|
264
|
+
registry: expansionRegistry(),
|
|
265
|
+
callTool: async (_mcp, serverName, toolName, args) => {
|
|
266
|
+
calls.push(args);
|
|
267
|
+
return expansionFragment();
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
assert.equal(calls[0].constraints.requireApprovalForMutations, false);
|
|
272
|
+
assert.equal(calls[0].constraints.maxTasks, 2);
|
|
273
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { parseJsonText } from '../core/activity.js';
|
|
2
2
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
3
3
|
import { formatMcpToolResult, callMcpTool as defaultCallMcpTool } from '../core/mcp.js';
|
|
4
|
-
import {
|
|
4
|
+
import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
|
|
5
5
|
import { accept as acceptResult } from '../orchestrator/resultAggregator.js';
|
|
6
6
|
import { isSuccessful, isTerminal } from '../orchestrator/taskStatuses.js';
|
|
7
7
|
|
|
@@ -100,6 +100,9 @@ async function recoverTask({ store, session, run, task, callTool, resultAggregat
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
const agent = agentFor(session, assignment.agentInstanceId);
|
|
103
|
+
if (agent?.providerKind === 'external-runtime' && typeof agent?.runtimeProvider?.status === 'function') {
|
|
104
|
+
return recoverExternalRuntimeTask({ store, session, run, task, attempt, assignment, agent, resultAggregator });
|
|
105
|
+
}
|
|
103
106
|
const serverName = agent?.serverName ?? assignment.agentId ?? assignment.agentInstanceId;
|
|
104
107
|
const statusTool = toolNameFor(session, serverName, 'agent_status');
|
|
105
108
|
const status = parseToolPayload(await callTool(session.mcp, serverName, statusTool, { jobId: attempt.jobId }));
|
|
@@ -146,6 +149,70 @@ async function recoverTask({ store, session, run, task, callTool, resultAggregat
|
|
|
146
149
|
return interruptTask({ store, session, run, task, reason: 'active job is non-terminal and task has no idempotencyKey' });
|
|
147
150
|
}
|
|
148
151
|
|
|
152
|
+
// An MCP job survives a manager restart on the agent's own side and reports
|
|
153
|
+
// its status through agent_status. An external-runtime job has no such
|
|
154
|
+
// side-channel here: the only way to check on it, or to give it up, is the
|
|
155
|
+
// same RuntimeProvider the dispatcher used to start it, re-resolved by
|
|
156
|
+
// agentInstanceId from the live registry rather than replayed from storage.
|
|
157
|
+
async function recoverExternalRuntimeTask({ store, session, run, task, attempt, assignment, agent, resultAggregator }) {
|
|
158
|
+
const runtimeProvider = agent.runtimeProvider;
|
|
159
|
+
let status;
|
|
160
|
+
try {
|
|
161
|
+
status = await runtimeProvider.status(attempt.jobId);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
return interruptTask({
|
|
164
|
+
store,
|
|
165
|
+
session,
|
|
166
|
+
run,
|
|
167
|
+
task,
|
|
168
|
+
reason: `external runtime status check failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (isTerminal(status?.status)) {
|
|
173
|
+
const result = {
|
|
174
|
+
ok: isSuccessful(String(status?.status ?? '').toLowerCase()),
|
|
175
|
+
taskId: task.id,
|
|
176
|
+
attemptId: attempt.attemptId ?? null,
|
|
177
|
+
jobId: attempt.jobId,
|
|
178
|
+
agentInstanceId: assignment.agentInstanceId,
|
|
179
|
+
status: status?.status,
|
|
180
|
+
outputRefs: Array.isArray(status?.result?.outputRefs) ? status.result.outputRefs : [],
|
|
181
|
+
metrics: status?.result?.metrics ?? {},
|
|
182
|
+
// The gateway reports its failure at the TOP level of the status
|
|
183
|
+
// payload ({ runId, status, error }), not inside `result` — same fix
|
|
184
|
+
// already applied in dispatcher.js's taskResultFromStatus and
|
|
185
|
+
// deepAgentsProvider.js's status().
|
|
186
|
+
error: status?.result?.error ?? status?.error ?? null,
|
|
187
|
+
rawStatus: status,
|
|
188
|
+
};
|
|
189
|
+
await resultAggregator(result, {
|
|
190
|
+
session,
|
|
191
|
+
runId: run.id,
|
|
192
|
+
task,
|
|
193
|
+
assignment: { agentInstanceId: assignment.agentInstanceId, serverName: null, agent },
|
|
194
|
+
store,
|
|
195
|
+
registry: capabilityRegistryForSession(session),
|
|
196
|
+
workspaceConfig: session.wikircConfig ?? session.wikirc?.config ?? {},
|
|
197
|
+
});
|
|
198
|
+
return { status: 'recovered', runId: run.id, taskId: task.id, jobId: attempt.jobId };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The runtime declares supportsIdempotency: false (runtimeProviders.js), so
|
|
202
|
+
// a fresh invocation cannot be deduped against the one still running on the
|
|
203
|
+
// external side. Requeuing it to `pending` like the MCP path below would
|
|
204
|
+
// start a duplicate while the orphaned original keeps running/billing.
|
|
205
|
+
// Cancel it explicitly instead of leaving it to run unattended.
|
|
206
|
+
await runtimeProvider.cancel(attempt.jobId).catch(() => null);
|
|
207
|
+
return interruptTask({
|
|
208
|
+
store,
|
|
209
|
+
session,
|
|
210
|
+
run,
|
|
211
|
+
task,
|
|
212
|
+
reason: 'active external-runtime job cancelled on recovery (no idempotency support)',
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
149
216
|
function interruptTask({ store, session, run, task, reason }) {
|
|
150
217
|
dispatch(session, store, 'runtime_log', {
|
|
151
218
|
origin: 'recovery_manager',
|
|
@@ -171,10 +238,7 @@ function latestAssignment(assignments, attemptId) {
|
|
|
171
238
|
|
|
172
239
|
|
|
173
240
|
function capabilityResolvable(session, capability) {
|
|
174
|
-
const registry = session
|
|
175
|
-
?? ((session.agentRegistrySnapshot ?? []).length > 0
|
|
176
|
-
? createCapabilityRegistry({ agents: session.agentRegistrySnapshot })
|
|
177
|
-
: null);
|
|
241
|
+
const registry = capabilityRegistryForSession(session);
|
|
178
242
|
if (!registry || typeof registry.providersFor !== 'function') return true;
|
|
179
243
|
// Only trust a registry that actually knows about capabilities. An empty
|
|
180
244
|
// one (discovery not finished, or agents described without capability
|
|
@@ -211,6 +275,7 @@ function agentFor(session, agentInstanceId) {
|
|
|
211
275
|
return [
|
|
212
276
|
...(session.agentRegistrySnapshot ?? []),
|
|
213
277
|
...(session.agents ?? []),
|
|
278
|
+
...(session.runtimeProviderAgents ?? []),
|
|
214
279
|
].find((agent) => agent?.agentInstanceId === agentInstanceId) ?? null;
|
|
215
280
|
}
|
|
216
281
|
|
|
@@ -142,7 +142,7 @@ test('E2E-002 wiki-sync: two objectives, two ordered runs, one chainId', async (
|
|
|
142
142
|
assert.equal(body.objectives, 2);
|
|
143
143
|
assert.equal(env.runs.length, 2, 'the second objective must run after the first');
|
|
144
144
|
assert.match(env.runs[0].input, /^Export the requested Confluence source/);
|
|
145
|
-
assert.match(env.runs[1].input, /^Run the production pipeline over the newly exported Markdown/);
|
|
145
|
+
assert.match(env.runs[1].input, /^Run the production pipeline step ingest over the newly exported Markdown/);
|
|
146
146
|
// CME first, Production second — and the parameter reaches the step that
|
|
147
147
|
// consumes it, not only the last objective.
|
|
148
148
|
for (const run of env.runs) assert.match(run.input, /User parameters:\nsource: docs/);
|
|
@@ -202,7 +202,7 @@ test('E2E-003 cancel: the running step and its chain stop, unrelated queue survi
|
|
|
202
202
|
// that silently fragments would show up as extra runs, not as extra objectives.
|
|
203
203
|
const PERFORMANCE_TABLE = {
|
|
204
204
|
pipeline: 1,
|
|
205
|
-
'wiki-ingest':
|
|
205
|
+
'wiki-ingest': 1,
|
|
206
206
|
'wiki-build': 1,
|
|
207
207
|
deliver: 1,
|
|
208
208
|
diagnose: 1,
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { openSync, readSync, closeSync, fstatSync } from 'node:fs';
|
|
2
2
|
import { isAbsolute, join, normalize, resolve } from 'node:path';
|
|
3
|
-
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
3
|
+
import { createAgentEvent, dispatchAgentEvent, dispatchRuntimeLog } from '../core/agentEvents.js';
|
|
4
4
|
import { extractActivity, mergePolledActivity, parseJsonText, sessionActivities } from '../core/activity.js';
|
|
5
5
|
import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
|
|
6
|
-
import { normalizeRuntimeLog } from '../core/runtimeLog.js';
|
|
7
6
|
import { startNextQueuedJob, syncQueueWithActivity } from '../core/jobQueue.js';
|
|
8
7
|
import { createAgentRegistry } from '../orchestrator/agentRegistry.js';
|
|
8
|
+
import { discoverRuntimeProvidersOnce } from '../orchestrator/providers/runtimeProviders.js';
|
|
9
9
|
|
|
10
10
|
export function startActivitySupervisor(session, {
|
|
11
11
|
intervalMs = 1000,
|
|
@@ -52,11 +52,13 @@ export function startActivitySupervisor(session, {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
void discoverAgentsOnce(session, { registry, signal: runSignal });
|
|
55
|
+
void discoverRuntimeProvidersOnce(session, { signal: runSignal });
|
|
55
56
|
}, agentRegistryIntervalMs)
|
|
56
57
|
: null;
|
|
57
58
|
|
|
58
59
|
void pollActivitiesOnce(session, { pollBusy, callTool, signal: runSignal });
|
|
59
60
|
void discoverAgentsOnce(session, { registry, signal: runSignal });
|
|
61
|
+
void discoverRuntimeProvidersOnce(session, { signal: runSignal });
|
|
60
62
|
|
|
61
63
|
return {
|
|
62
64
|
pollBusy,
|
|
@@ -199,14 +201,7 @@ export async function pollActivitiesOnce(session, {
|
|
|
199
201
|
}
|
|
200
202
|
|
|
201
203
|
export function emitRuntimeLog(session, message) {
|
|
202
|
-
|
|
203
|
-
dispatchAgentEvent(session, createAgentEvent('runtime_log', {
|
|
204
|
-
origin: 'runtime',
|
|
205
|
-
runId: payload.runId ?? null,
|
|
206
|
-
taskId: payload.taskId ?? null,
|
|
207
|
-
workspace: payload.workspaceId ?? null,
|
|
208
|
-
payload,
|
|
209
|
-
}));
|
|
204
|
+
dispatchRuntimeLog(session, message);
|
|
210
205
|
}
|
|
211
206
|
|
|
212
207
|
function registryIntervalFromEnv() {
|