@dotdrelle/wiki-manager 0.15.66 → 0.15.71
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 +57 -0
- package/agent-runtimes.example.json +68 -0
- package/agents.docker-compose.yml +39 -1
- package/docker-compose.yml +3 -3
- package/package.json +3 -2
- package/src/activity/activityAggregator.test.js +2 -2
- package/src/agent/graph.js +13 -11
- package/src/agent/skillRecursion.test.js +13 -12
- package/src/cli/wiki-manager.js +125 -37
- package/src/cli/wiki-manager.test.js +16 -16
- package/src/commands/slash.js +59 -5
- package/src/contracts/schemas.js +67 -0
- package/src/core/activity.js +5 -0
- package/src/core/agentEvents.js +139 -25
- package/src/core/agentEvents.test.js +26 -1
- package/src/core/buildInfo.json +2 -2
- package/src/core/commandFailure.test.js +2 -2
- package/src/core/currentArtifact.test.js +5 -5
- 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/mcp.test.js +1 -1
- package/src/core/otherWorkspacesRunning.test.js +6 -6
- package/src/core/runtimeEventAdapter.js +81 -0
- package/src/core/runtimeEventAdapter.test.js +61 -0
- package/src/core/runtimeLog.js +35 -1
- package/src/core/runtimeLog.test.js +27 -2
- 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/skillInvocation.test.js +1 -1
- package/src/core/startupCheck.js +58 -0
- package/src/core/startupCheck.test.js +29 -1
- package/src/core/wikiSetup.js +25 -0
- package/src/core/wikiSetup.test.js +35 -0
- package/src/core/wikirc.test.js +6 -6
- package/src/core/workspaceInherit.test.js +14 -14
- package/src/orchestrator/agentRegistry.js +1 -22
- package/src/orchestrator/agentRegistry.test.js +6 -6
- package/src/orchestrator/assignmentManager.js +16 -4
- package/src/orchestrator/capabilityRegistry.js +8 -1
- package/src/orchestrator/dispatcher.js +405 -2
- package/src/orchestrator/dispatcher.test.js +158 -4
- 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 +378 -0
- package/src/orchestrator/providers/runtimeProviders.test.js +384 -0
- package/src/orchestrator/resultAggregator.js +35 -2
- package/src/orchestrator/resultAggregator.test.js +62 -0
- package/src/orchestrator/scheduler.test.js +4 -4
- package/src/runtime/delegation.test.js +11 -11
- package/src/runtime/recoveryManager.js +70 -5
- package/src/runtime/runner.test.js +1 -1
- package/src/runtime/server.test.js +2 -2
- package/src/runtime/skillChain.e2e.test.js +2 -2
- package/src/runtime/store.test.js +8 -5
- package/src/runtime/supervisor.js +5 -10
- package/src/runtime/workspaceIsolation.test.js +26 -26
- package/src/shell/RightPane.tsx +23 -3
- package/src/shell/StartupScreen.tsx +44 -7
- package/src/shell/repl.js +24 -2
- package/src/shell/repl.test.js +13 -0
- package/wiki-workspace +53 -3
|
@@ -0,0 +1,384 @@
|
|
|
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('the local gateway bearer is injected only into the manager-owned host-local entry', () => {
|
|
198
|
+
const dir = mkdtempSync(join(tmpdir(), 'agent-runtimes-'));
|
|
199
|
+
try {
|
|
200
|
+
writeFileSync(join(dir, 'agent-runtimes.json'), JSON.stringify({
|
|
201
|
+
runtimes: [
|
|
202
|
+
{ id: 'local', type: 'deepagents', endpoint: 'http://127.0.0.1:7789', enabled: true },
|
|
203
|
+
{ id: 'shared', type: 'deepagents', endpoint: 'https://gateway.partner.example', enabled: true },
|
|
204
|
+
{ id: 'otherport', type: 'deepagents', endpoint: 'http://localhost:9999', enabled: true },
|
|
205
|
+
],
|
|
206
|
+
}));
|
|
207
|
+
const config = loadAgentRuntimesConfig({
|
|
208
|
+
stateDir: dir,
|
|
209
|
+
env: { GATEWAY_ENABLED: 'true', GATEWAY_PORT: '7789', GATEWAY_AUTH_TOKEN: 'tok-local' },
|
|
210
|
+
});
|
|
211
|
+
const byId = Object.fromEntries(config.map((entry) => [entry.id, entry]));
|
|
212
|
+
assert.deepEqual(byId.local.headers, { Authorization: 'Bearer tok-local' });
|
|
213
|
+
assert.equal(byId.shared.headers, undefined, 'a foreign gateway host must never receive the local token');
|
|
214
|
+
assert.equal(byId.otherport.headers, undefined, 'a non-gateway port is not the manager-owned gateway');
|
|
215
|
+
} finally {
|
|
216
|
+
rmSync(dir, { recursive: true, force: true });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('an explicit enabled deepagents entry wins over the implied one', () => {
|
|
221
|
+
const dir = mkdtempSync(join(tmpdir(), 'agent-runtimes-'));
|
|
222
|
+
try {
|
|
223
|
+
writeFileSync(join(dir, 'agent-runtimes.json'), JSON.stringify({
|
|
224
|
+
runtimes: [{ id: 'deepagents', type: 'deepagents', endpoint: 'http://custom:9000', enabled: true }],
|
|
225
|
+
}));
|
|
226
|
+
const config = loadAgentRuntimesConfig({ stateDir: dir, env: { GATEWAY_ENABLED: 'true' } });
|
|
227
|
+
assert.equal(config.length, 1);
|
|
228
|
+
assert.equal(config[0].endpoint, 'http://custom:9000');
|
|
229
|
+
} finally {
|
|
230
|
+
rmSync(dir, { recursive: true, force: true });
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('discoverRuntimeProvidersOnce populates session.runtimeProviderAgents and announces down runtimes', async () => {
|
|
235
|
+
const session = sessionWithEvents();
|
|
236
|
+
|
|
237
|
+
const agents = await discoverRuntimeProvidersOnce(session, {
|
|
238
|
+
config: [
|
|
239
|
+
{ id: 'fake', type: 'fake', capabilities: [{ name: 'agent.echo', operations: ['run'] }] },
|
|
240
|
+
{ id: 'down', type: 'fake', available: false },
|
|
241
|
+
],
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
assert.equal(agents.length, 1);
|
|
245
|
+
assert.equal(session.runtimeProviderAgents.length, 1);
|
|
246
|
+
assert.equal(session.runtimeProviderAgents[0].agentInstanceId, 'fake::agent.echo');
|
|
247
|
+
|
|
248
|
+
const logs = (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log');
|
|
249
|
+
assert.ok(
|
|
250
|
+
logs.some((event) => String(event.payload?.message ?? '').includes('down')
|
|
251
|
+
&& String(event.payload?.message ?? '').includes('unavailable')),
|
|
252
|
+
'a down runtime is announced in the journal',
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('discoverRuntimeProvidersOnce announces a down runtime only once across re-scans', async () => {
|
|
257
|
+
const session = sessionWithEvents();
|
|
258
|
+
const config = [{ id: 'down', type: 'fake', available: false }];
|
|
259
|
+
|
|
260
|
+
await discoverRuntimeProvidersOnce(session, { config });
|
|
261
|
+
await discoverRuntimeProvidersOnce(session, { config });
|
|
262
|
+
|
|
263
|
+
const logs = (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log'
|
|
264
|
+
&& String(event.payload?.message ?? '').includes('unavailable'));
|
|
265
|
+
assert.equal(logs.length, 1, 'the degradation is announced once, not on every re-scan');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('a transient probe failure keeps the last-known capability set (a failed probe is not a lost agent)', async () => {
|
|
269
|
+
const session = sessionWithEvents();
|
|
270
|
+
const up = [{ id: 'gw', type: 'fake', capabilities: [{ name: 'agent.review', operations: ['run'] }] }];
|
|
271
|
+
const down = [{ id: 'gw', type: 'fake', available: false }];
|
|
272
|
+
|
|
273
|
+
await discoverRuntimeProvidersOnce(session, { config: up });
|
|
274
|
+
assert.equal(session.runtimeProviderAgents.length, 1);
|
|
275
|
+
|
|
276
|
+
// Network blip during a periodic re-scan: discovery returns nothing.
|
|
277
|
+
await discoverRuntimeProvidersOnce(session, { config: down });
|
|
278
|
+
assert.equal(session.runtimeProviderAgents.length, 1, 'the capability survives the blip');
|
|
279
|
+
assert.equal(session.runtimeProviderAgents[0].agentInstanceId, 'gw::agent.review');
|
|
280
|
+
|
|
281
|
+
const logs = (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log'
|
|
282
|
+
&& String(event.payload?.message ?? '').includes('keeping'));
|
|
283
|
+
assert.equal(logs.length, 1, 'the preservation is announced');
|
|
284
|
+
|
|
285
|
+
// Recovery: a fresh answer is authoritative again.
|
|
286
|
+
await discoverRuntimeProvidersOnce(session, { config: up });
|
|
287
|
+
assert.equal(session.runtimeProviderAgents.length, 1);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('a healthy runtime that answers is authoritative — its set replaces, never merges with, the last-known one', async () => {
|
|
291
|
+
const session = sessionWithEvents();
|
|
292
|
+
await discoverRuntimeProvidersOnce(session, {
|
|
293
|
+
config: [{ id: 'gw', type: 'fake', capabilities: [{ name: 'agent.review', operations: ['run'] }] }],
|
|
294
|
+
});
|
|
295
|
+
assert.deepEqual(session.runtimeProviderAgents.map((a) => a.agentInstanceId), ['gw::agent.review']);
|
|
296
|
+
|
|
297
|
+
await discoverRuntimeProvidersOnce(session, {
|
|
298
|
+
config: [{ id: 'gw', type: 'fake', capabilities: [{ name: 'agent.consistency', operations: ['run'] }] }],
|
|
299
|
+
});
|
|
300
|
+
assert.deepEqual(
|
|
301
|
+
session.runtimeProviderAgents.map((a) => a.agentInstanceId),
|
|
302
|
+
['gw::agent.consistency'],
|
|
303
|
+
'the stale agent.review is gone, not kept alongside',
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('GATEWAY_ENABLED inherits the disabled entry\'s declared capabilities', () => {
|
|
308
|
+
const dir = mkdtempSync(join(tmpdir(), 'agent-runtimes-'));
|
|
309
|
+
try {
|
|
310
|
+
writeFileSync(join(dir, 'agent-runtimes.json'), JSON.stringify({
|
|
311
|
+
runtimes: [{
|
|
312
|
+
id: 'deepagents',
|
|
313
|
+
type: 'deepagents',
|
|
314
|
+
endpoint: 'http://agent-runtime:7789',
|
|
315
|
+
enabled: false,
|
|
316
|
+
capabilities: [
|
|
317
|
+
{ name: 'agent.notify', operations: ['run'], defaultRequiresApproval: true },
|
|
318
|
+
],
|
|
319
|
+
}],
|
|
320
|
+
}));
|
|
321
|
+
const config = loadAgentRuntimesConfig({ stateDir: dir, env: { GATEWAY_ENABLED: 'true' } });
|
|
322
|
+
const implied = config.find((entry) => entry.type === 'deepagents');
|
|
323
|
+
assert.ok(implied);
|
|
324
|
+
assert.equal(implied.enabled, true);
|
|
325
|
+
assert.equal(implied.endpoint, 'http://localhost:7789', 'the endpoint stays host-local');
|
|
326
|
+
assert.deepEqual(implied.capabilities, [
|
|
327
|
+
{ name: 'agent.notify', operations: ['run'], defaultRequiresApproval: true },
|
|
328
|
+
], 'the shipped approval metadata is not discarded');
|
|
329
|
+
assert.equal(config.filter((entry) => entry.type === 'deepagents').length, 1, 'no duplicate entry');
|
|
330
|
+
} finally {
|
|
331
|
+
rmSync(dir, { recursive: true, force: true });
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test('external runtime capabilities carry aliases through to the registry', async () => {
|
|
336
|
+
const provider = createFakeRuntimeProvider({
|
|
337
|
+
capabilities: [{ name: 'agent.review', operations: ['run'], aliases: ['audit', 'review', 'analyze'] }],
|
|
338
|
+
});
|
|
339
|
+
const { agents } = await discoverRuntimeProviderAgents([{ id: 'deepagents', provider }]);
|
|
340
|
+
|
|
341
|
+
assert.deepEqual(agents[0].description.capabilities[0].aliases, ['audit', 'review', 'analyze']);
|
|
342
|
+
assert.equal(agents[0].description.orchestration.canPlan, false);
|
|
343
|
+
assert.equal(agents[0].description.orchestration.singleTaskOnly, true, 'external runtimes are executor-only single-task');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('external runtime capabilities carry mutationClass and defaultRequiresApproval through', async () => {
|
|
347
|
+
const provider = createFakeRuntimeProvider({
|
|
348
|
+
capabilities: [
|
|
349
|
+
{ name: 'agent.research', operations: ['run'], mutationClass: 'ingest' },
|
|
350
|
+
{ name: 'agent.notify', operations: ['run'], defaultRequiresApproval: true },
|
|
351
|
+
],
|
|
352
|
+
});
|
|
353
|
+
const { agents } = await discoverRuntimeProviderAgents([{ id: 'deepagents', provider }]);
|
|
354
|
+
|
|
355
|
+
const byName = new Map(
|
|
356
|
+
agents.map((agent) => [agent.description.capabilities[0].id, agent.description.capabilities[0]]),
|
|
357
|
+
);
|
|
358
|
+
assert.equal(byName.get('agent.research').mutationClass, 'ingest');
|
|
359
|
+
assert.equal(byName.get('agent.research').defaultRequiresApproval, undefined);
|
|
360
|
+
assert.equal(byName.get('agent.notify').defaultRequiresApproval, true);
|
|
361
|
+
assert.equal(byName.get('agent.notify').mutationClass, undefined);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('external runtime capabilities carry aliasOperations through', async () => {
|
|
365
|
+
const provider = createFakeRuntimeProvider({
|
|
366
|
+
capabilities: [{ name: 'agent.plan', operations: ['plan', 'run'], aliasOperations: { plan: 'plan', apply: 'run' } }],
|
|
367
|
+
});
|
|
368
|
+
const { agents } = await discoverRuntimeProviderAgents([{ id: 'deepagents', provider }]);
|
|
369
|
+
|
|
370
|
+
assert.deepEqual(agents[0].description.capabilities[0].aliasOperations, { plan: 'plan', apply: 'run' });
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test('resolveObjective deterministically routes "audit" to agent.review via aliases (no LLM)', async () => {
|
|
374
|
+
const provider = createFakeRuntimeProvider({
|
|
375
|
+
capabilities: [{ name: 'agent.review', operations: ['run'], aliases: ['audit', 'review', 'analyze'] }],
|
|
376
|
+
});
|
|
377
|
+
const { agents } = await discoverRuntimeProviderAgents([{ id: 'deepagents', provider }]);
|
|
378
|
+
const session = { runtimeProviderAgents: agents };
|
|
379
|
+
|
|
380
|
+
const selection = await resolveObjective('audit la couverture conceptuelle', session);
|
|
381
|
+
|
|
382
|
+
assert.equal(selection.capability, 'agent.review');
|
|
383
|
+
assert.equal(selection.operation, 'run');
|
|
384
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -246,7 +246,7 @@ function task(id, overrides = {}) {
|
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
/*
|
|
249
|
-
Cas observé le 2026-08-22 (workspace
|
|
249
|
+
Cas observé le 2026-08-22 (workspace acme) : `/wiki-ingest` planifie 13
|
|
250
250
|
ingest_plan (groupe `ingest`) + 13 ingest_apply (groupe `apply`, sérialisés
|
|
251
251
|
sur le lock `workspace-write`, derrière la barrière `ingest`) + 1 taxonomy
|
|
252
252
|
(barrière `apply`). Le grant run-scope émis par le bouton Approve est « nu » :
|
|
@@ -260,7 +260,7 @@ function task(id, overrides = {}) {
|
|
|
260
260
|
test('un grant run-scope « nu » (sans classes ni révision) débloque les apply derrière une barrière', () => {
|
|
261
261
|
const plan = {
|
|
262
262
|
runId: 'run-1',
|
|
263
|
-
workspace: '
|
|
263
|
+
workspace: 'acme',
|
|
264
264
|
planRevision: 1,
|
|
265
265
|
tasks: [
|
|
266
266
|
// 13 ingest_plan du groupe ingest, tous done.
|
|
@@ -307,7 +307,7 @@ test('un grant run-scope « nu » (sans classes ni révision) débloque les appl
|
|
|
307
307
|
status: 'approved',
|
|
308
308
|
scope: 'run',
|
|
309
309
|
runId: 'run-1',
|
|
310
|
-
workspaceId: '
|
|
310
|
+
workspaceId: 'acme',
|
|
311
311
|
planRevision: null,
|
|
312
312
|
approvalClasses: [],
|
|
313
313
|
}],
|
|
@@ -322,7 +322,7 @@ test('un grant run-scope « nu » (sans classes ni révision) débloque les appl
|
|
|
322
322
|
});
|
|
323
323
|
|
|
324
324
|
/*
|
|
325
|
-
Cas observé le 2026-08-04 (workspace
|
|
325
|
+
Cas observé le 2026-08-04 (workspace demo) : une ingestion de dix fichiers,
|
|
326
326
|
neuf réussis, le dixième en échec sur du JSON malformé. La barrière de groupe
|
|
327
327
|
exigeait que TOUS les membres soient `done` : elle ne s'est jamais ouverte, le
|
|
328
328
|
planificateur n'a plus trouvé de tâche prête, et le run est resté `running`
|
|
@@ -47,11 +47,11 @@ function preparedDelegation(taskId) {
|
|
|
47
47
|
|
|
48
48
|
function runningSession(runId = 'run-conversational') {
|
|
49
49
|
return {
|
|
50
|
-
workspace: '
|
|
50
|
+
workspace: 'demo',
|
|
51
51
|
activities: {},
|
|
52
52
|
agentEvents: [],
|
|
53
53
|
headlessPlan: null,
|
|
54
|
-
_currentRunIdentity: { runId, workspace: '
|
|
54
|
+
_currentRunIdentity: { runId, workspace: 'demo' },
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
57
|
|
|
@@ -98,9 +98,9 @@ test('une demande de build délègue dans le run courant, sans en démarrer un s
|
|
|
98
98
|
const session = runningSession('run-fr-build');
|
|
99
99
|
const started = [];
|
|
100
100
|
|
|
101
|
-
const result = await delegateWithinRun(session, '
|
|
101
|
+
const result = await delegateWithinRun(session, 'build the deliverables of the demo workspace', {
|
|
102
102
|
prepare: async ({ objective }) => {
|
|
103
|
-
assert.match(objective, /
|
|
103
|
+
assert.match(objective, /build the deliverables/);
|
|
104
104
|
return preparedDelegation('build-fr');
|
|
105
105
|
},
|
|
106
106
|
registry: registryDouble(),
|
|
@@ -118,10 +118,10 @@ test('une demande de build délègue dans le run courant, sans en démarrer un s
|
|
|
118
118
|
test('la délégation interne exige un run actif', async () => {
|
|
119
119
|
// Hors run, il n'y a pas d'exécution à transformer : c'est un vrai démarrage
|
|
120
120
|
// de run, et il doit passer par le chemin normal plutôt que par ici.
|
|
121
|
-
const session = { workspace: '
|
|
121
|
+
const session = { workspace: 'demo', agentEvents: [] };
|
|
122
122
|
|
|
123
123
|
await assert.rejects(
|
|
124
|
-
() => delegateWithinRun(session, '
|
|
124
|
+
() => delegateWithinRun(session, 'build the deliverables', {
|
|
125
125
|
prepare: async () => preparedDelegation(),
|
|
126
126
|
registry: registryDouble(),
|
|
127
127
|
}),
|
|
@@ -184,12 +184,12 @@ test('une demande française délègue une fois et bascule, sans jamais duplique
|
|
|
184
184
|
const { runRuntimeAgenticWorkflow } = await import('./runner.js');
|
|
185
185
|
const runId = 'run-fr-integration';
|
|
186
186
|
const session = {
|
|
187
|
-
workspace: '
|
|
187
|
+
workspace: 'demo',
|
|
188
188
|
activities: {},
|
|
189
189
|
agentEvents: [],
|
|
190
190
|
headlessPlan: null,
|
|
191
191
|
// Posée par executeRun avant l'appel au workflow, comme en production.
|
|
192
|
-
_currentRunIdentity: { runId, workspace: '
|
|
192
|
+
_currentRunIdentity: { runId, workspace: 'demo' },
|
|
193
193
|
llm: { async completeWithTools() { assert.fail('aucune évaluation ne doit avoir lieu ici'); } },
|
|
194
194
|
};
|
|
195
195
|
const fiveTasks = {
|
|
@@ -205,7 +205,7 @@ test('une demande française délègue une fois et bascule, sans jamais duplique
|
|
|
205
205
|
async invoke({ session: turnSession }) {
|
|
206
206
|
conversationalTurns += 1;
|
|
207
207
|
// Le tour conversationnel appelle l'outil de délégation, comme Donna.
|
|
208
|
-
const result = await delegateWithinRun(turnSession, '
|
|
208
|
+
const result = await delegateWithinRun(turnSession, 'build the deliverables of the demo workspace', {
|
|
209
209
|
prepare: async () => {
|
|
210
210
|
delegations += 1;
|
|
211
211
|
return { ...preparedDelegation(), fragment: fiveTasks };
|
|
@@ -242,7 +242,7 @@ test('une demande française délègue une fois et bascule, sans jamais duplique
|
|
|
242
242
|
// Borne l'attente d'approbation comme en headless : un test ne doit jamais
|
|
243
243
|
// pouvoir se figer sur une décision humaine qui ne viendra pas.
|
|
244
244
|
session._approvalTimeoutMs = 200;
|
|
245
|
-
await runRuntimeAgenticWorkflow(agent, session, '
|
|
245
|
+
await runRuntimeAgenticWorkflow(agent, session, 'build the deliverables of the demo workspace', {
|
|
246
246
|
runId,
|
|
247
247
|
timeoutMs: 2000,
|
|
248
248
|
maxTurns: 4,
|
|
@@ -272,7 +272,7 @@ test('un run qui porte déjà un plan validé refuse une seconde délégation',
|
|
|
272
272
|
session.headlessPlan = [task('build-a')];
|
|
273
273
|
|
|
274
274
|
await assert.rejects(
|
|
275
|
-
() => delegateWithinRun(session, '
|
|
275
|
+
() => delegateWithinRun(session, 'build the deliverables', {
|
|
276
276
|
prepare: async () => preparedDelegation(),
|
|
277
277
|
registry: registryDouble(),
|
|
278
278
|
}),
|