@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
|
@@ -1,6 +1,95 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import { createDispatcher, normalizeTaskError } from './dispatcher.js';
|
|
3
|
+
import { activeProfileMcp, createDispatcher, normalizeTaskError } from './dispatcher.js';
|
|
4
|
+
|
|
5
|
+
test('activeProfileMcp forwards only the read-only wiki tools to the external runtime', () => {
|
|
6
|
+
const session = {
|
|
7
|
+
mcp: {
|
|
8
|
+
wiki: {
|
|
9
|
+
url: 'http://wiki:3000/mcp',
|
|
10
|
+
status: 'connected',
|
|
11
|
+
token: 't',
|
|
12
|
+
tools: [
|
|
13
|
+
{ name: 'wiki_read_page' },
|
|
14
|
+
{ name: 'wiki_search_context' },
|
|
15
|
+
{ name: 'wiki_workspace_status' },
|
|
16
|
+
{ name: 'help_read' },
|
|
17
|
+
// every mutation tool must be dropped, including the ones the old
|
|
18
|
+
// substring denylist ("write_page|add_source|…") let through:
|
|
19
|
+
{ name: 'wiki_write_page' },
|
|
20
|
+
{ name: 'wiki_add_source' },
|
|
21
|
+
{ name: 'profile_update' },
|
|
22
|
+
{ name: 'template_write' },
|
|
23
|
+
{ name: 'build_context_write' },
|
|
24
|
+
// and a hypothetical future mutation tool the denylist could not know:
|
|
25
|
+
{ name: 'wiki_delete_page' },
|
|
26
|
+
{ name: 'wiki_move_page' },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
const [pool] = activeProfileMcp(session);
|
|
32
|
+
assert.deepEqual(
|
|
33
|
+
[...pool.tools].sort(),
|
|
34
|
+
['help_read', 'wiki_read_page', 'wiki_search_context', 'wiki_workspace_status'],
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('activeProfileMcp hands the external runtime a safe connector\'s read tools by word-boundary verb', () => {
|
|
39
|
+
const session = {
|
|
40
|
+
mcp: {
|
|
41
|
+
wiki: {
|
|
42
|
+
url: 'http://wiki:3000/mcp', status: 'connected',
|
|
43
|
+
tools: [{ name: 'wiki_read_page' }],
|
|
44
|
+
},
|
|
45
|
+
exa: {
|
|
46
|
+
url: 'https://exa/mcp', status: 'connected', external: true,
|
|
47
|
+
tools: [
|
|
48
|
+
{ name: 'web_search_exa' },
|
|
49
|
+
{ name: 'research_paper_search' },
|
|
50
|
+
// a raw substring denylist wrongly dropped these safe reads:
|
|
51
|
+
{ name: 'list_recent_updates' },
|
|
52
|
+
{ name: 'get_created_items' },
|
|
53
|
+
// …and wrongly kept these, which use a verb it did not enumerate:
|
|
54
|
+
{ name: 'crawl_site' },
|
|
55
|
+
{ name: 'post_message' },
|
|
56
|
+
{ name: 'run_report' },
|
|
57
|
+
{ name: 'send_email' },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
const exaBlock = activeProfileMcp(session).find((block) => block.name === 'exa');
|
|
63
|
+
assert.deepEqual(
|
|
64
|
+
[...exaBlock.tools].sort(),
|
|
65
|
+
['get_created_items', 'list_recent_updates', 'research_paper_search', 'web_search_exa'],
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('activeProfileMcp drops an external connector that is approval-gated or workspace-mutating', () => {
|
|
70
|
+
const session = {
|
|
71
|
+
mcp: {
|
|
72
|
+
wiki: { url: 'http://wiki:3000/mcp', status: 'connected', tools: [{ name: 'wiki_read_page' }] },
|
|
73
|
+
gated: { url: 'https://g/mcp', status: 'connected', external: true, requireApproval: ['search'], tools: [{ name: 'search' }] },
|
|
74
|
+
documents: { url: 'https://d/mcp', status: 'connected', external: true, tools: [{ name: 'get_document' }] },
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
const names = activeProfileMcp(session).map((block) => block.name);
|
|
78
|
+
assert.deepEqual(names, ['wiki']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('activeProfileMcp tolerates namespaced tool names', () => {
|
|
82
|
+
const session = {
|
|
83
|
+
mcp: {
|
|
84
|
+
wiki: {
|
|
85
|
+
url: 'http://wiki:3000/mcp',
|
|
86
|
+
status: 'connected',
|
|
87
|
+
tools: [{ name: 'wiki__wiki_read_page' }, { name: 'wiki__wiki_write_page' }],
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
assert.deepEqual(activeProfileMcp(session)[0].tools, ['wiki__wiki_read_page']);
|
|
92
|
+
});
|
|
4
93
|
|
|
5
94
|
test('dispatcher returns a retryable logical failure when agent_execute reports workspace_busy', async () => {
|
|
6
95
|
const session = {
|
|
@@ -197,13 +286,13 @@ test('normalizeTaskError keeps the agent reason as the message, never the fallba
|
|
|
197
286
|
// Regression: the fallback describes only WHERE the failure was seen
|
|
198
287
|
// ("agent_execute rejected task"). Letting it win discarded the one
|
|
199
288
|
// actionable sentence — and left Donna to invent a cause.
|
|
200
|
-
const error = normalizeTaskError("Error: source '
|
|
289
|
+
const error = normalizeTaskError("Error: source 'acme' not found", {
|
|
201
290
|
fallbackCode: 'execution_rejected',
|
|
202
291
|
fallbackMessage: 'agent_execute rejected task',
|
|
203
292
|
});
|
|
204
293
|
|
|
205
|
-
assert.equal(error.code, "Error: source '
|
|
206
|
-
assert.equal(error.message, "Error: source '
|
|
294
|
+
assert.equal(error.code, "Error: source 'acme' not found");
|
|
295
|
+
assert.equal(error.message, "Error: source 'acme' not found");
|
|
207
296
|
});
|
|
208
297
|
|
|
209
298
|
test('normalizeTaskError falls back only when the agent reports no reason at all', () => {
|
|
@@ -215,3 +304,68 @@ test('normalizeTaskError falls back only when the agent reports no reason at all
|
|
|
215
304
|
assert.equal(error.code, 'execution_rejected');
|
|
216
305
|
assert.equal(error.message, 'agent_execute rejected task');
|
|
217
306
|
});
|
|
307
|
+
|
|
308
|
+
test('dispatcher passes confirm=true to agent_execute for an approval-gated task', async () => {
|
|
309
|
+
let executeArgs;
|
|
310
|
+
const session = {
|
|
311
|
+
workspace: 'test',
|
|
312
|
+
mcp: {
|
|
313
|
+
production: {
|
|
314
|
+
tools: [{ name: 'agent_execute' }, { name: 'agent_status' }, { name: 'agent_cancel' }],
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
activities: {},
|
|
318
|
+
};
|
|
319
|
+
const dispatcher = createDispatcher({
|
|
320
|
+
session,
|
|
321
|
+
pollIntervalMs: 1,
|
|
322
|
+
callTool: async (_mcp, _server, tool, args) => {
|
|
323
|
+
if (tool === 'agent_execute') {
|
|
324
|
+
executeArgs = args;
|
|
325
|
+
return { accepted: true, jobId: 'job-ingest', status: 'queued' };
|
|
326
|
+
}
|
|
327
|
+
return { jobId: 'job-ingest', status: 'succeeded', terminal: true, result: { status: 'succeeded' } };
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
await dispatcher.execute(
|
|
332
|
+
{ id: 'ingest-a', requiredCapability: 'knowledge.update', operation: 'ingest_apply', arguments: {}, requiresApproval: true },
|
|
333
|
+
{ serverName: 'production', agentInstanceId: 'production-main' },
|
|
334
|
+
{ runId: 'run-donna-confirm', attempt: { attemptId: 'ingest-a:attempt-1', locks: [], release() {} } },
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
assert.equal(executeArgs.arguments.confirm, true, 'covered mutating task must carry confirm=true');
|
|
338
|
+
assert.equal(executeArgs.constraints.requireApprovalForMutations, true);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test('dispatcher does not invent confirm=true for a non-gated task', async () => {
|
|
342
|
+
let executeArgs;
|
|
343
|
+
const session = {
|
|
344
|
+
workspace: 'test',
|
|
345
|
+
mcp: {
|
|
346
|
+
production: {
|
|
347
|
+
tools: [{ name: 'agent_execute' }, { name: 'agent_status' }, { name: 'agent_cancel' }],
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
activities: {},
|
|
351
|
+
};
|
|
352
|
+
const dispatcher = createDispatcher({
|
|
353
|
+
session,
|
|
354
|
+
pollIntervalMs: 1,
|
|
355
|
+
callTool: async (_mcp, _server, tool, args) => {
|
|
356
|
+
if (tool === 'agent_execute') {
|
|
357
|
+
executeArgs = args;
|
|
358
|
+
return { accepted: true, jobId: 'job-doctor', status: 'queued' };
|
|
359
|
+
}
|
|
360
|
+
return { jobId: 'job-doctor', status: 'succeeded', terminal: true, result: { status: 'succeeded' } };
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
await dispatcher.execute(
|
|
365
|
+
{ id: 'doctor-a', requiredCapability: 'workspace.diagnose', operation: 'doctor', arguments: {} },
|
|
366
|
+
{ serverName: 'production', agentInstanceId: 'production-main' },
|
|
367
|
+
{ runId: 'run-donna-doctor', attempt: { attemptId: 'doctor-a:attempt-1', locks: [], release() {} } },
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
assert.equal(executeArgs.arguments.confirm, undefined);
|
|
371
|
+
});
|
|
@@ -113,11 +113,10 @@ function resolveMentionedRegistryOperation(objective, candidates) {
|
|
|
113
113
|
// A capability with more than one operation may declare
|
|
114
114
|
// aliasOperations, mapping the specific alias phrase that matched to
|
|
115
115
|
// the operation it actually names. Without it, operations[0]
|
|
116
|
-
// (alphabetical) is a silent guess
|
|
117
|
-
//
|
|
118
|
-
// the
|
|
119
|
-
//
|
|
120
|
-
// making that operation structurally unreachable from natural language.
|
|
116
|
+
// (alphabetical) is a silent guess — a multi-operation capability's
|
|
117
|
+
// other operations become structurally unreachable from natural
|
|
118
|
+
// language, and the alphabetical first one may well be the destructive
|
|
119
|
+
// one.
|
|
121
120
|
const operation = candidate.aliasOperations?.[matchedAlias] ?? candidate.operations[0];
|
|
122
121
|
return { capability: candidate.id, operation };
|
|
123
122
|
})
|
|
@@ -176,7 +175,10 @@ function registrySnapshot(session) {
|
|
|
176
175
|
const registry = session?.capabilityRegistry;
|
|
177
176
|
if (registry?.snapshot) return registry.snapshot();
|
|
178
177
|
if (registry && typeof registry === 'object') return registry;
|
|
179
|
-
const agents =
|
|
178
|
+
const agents = [
|
|
179
|
+
...(session?.agentRegistry?.snapshot?.() ?? session?.agentRegistrySnapshot ?? []),
|
|
180
|
+
...(session?.runtimeProviderAgents ?? []),
|
|
181
|
+
];
|
|
180
182
|
const snapshot = {};
|
|
181
183
|
for (const agent of agents) {
|
|
182
184
|
for (const capability of agent?.description?.capabilities ?? []) {
|
|
@@ -187,6 +189,8 @@ function registrySnapshot(session) {
|
|
|
187
189
|
capability,
|
|
188
190
|
description: agent.description,
|
|
189
191
|
health: agent.health,
|
|
192
|
+
providerKind: agent.providerKind ?? null,
|
|
193
|
+
runtimeProvider: agent.runtimeProvider ?? null,
|
|
190
194
|
});
|
|
191
195
|
}
|
|
192
196
|
}
|
|
@@ -122,53 +122,52 @@ test('resolveObjective resolves diagnose via alias despite the notification "sen
|
|
|
122
122
|
assert.equal(result.operation, 'doctor');
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
-
const
|
|
126
|
-
operations: ['
|
|
127
|
-
aliases: ['
|
|
125
|
+
const snapshot = makeCapability('data.snapshot', {
|
|
126
|
+
operations: ['snapshot', 'prune'],
|
|
127
|
+
aliases: ['take a snapshot', 'prune snapshots', 'clean old snapshots'],
|
|
128
128
|
aliasOperations: {
|
|
129
|
-
'
|
|
130
|
-
'
|
|
131
|
-
'
|
|
129
|
+
'take a snapshot': 'snapshot',
|
|
130
|
+
'prune snapshots': 'prune',
|
|
131
|
+
'clean old snapshots': 'prune',
|
|
132
132
|
},
|
|
133
|
-
description: '
|
|
133
|
+
description: 'Take a snapshot of the workspace or prune old snapshots.',
|
|
134
134
|
});
|
|
135
135
|
|
|
136
136
|
/*
|
|
137
137
|
Regression: with two operations, [...new Set(supportedOperations)].sort()
|
|
138
|
-
alphabetizes to ["
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
first.
|
|
138
|
+
alphabetizes to ["prune", "snapshot"], so a naive alias hit defaulting to
|
|
139
|
+
operations[0] would ALWAYS resolve to "prune" — the destructive operation —
|
|
140
|
+
even for aliases explicitly authored to reach the safe "snapshot" one.
|
|
141
|
+
aliasOperations must be consulted first.
|
|
143
142
|
*/
|
|
144
|
-
test('resolveObjective routes "
|
|
145
|
-
const session = sessionWith([provider('production-1',
|
|
143
|
+
test('resolveObjective routes "prune snapshots" to prune, not operations[0]', async () => {
|
|
144
|
+
const session = sessionWith([provider('production-1', snapshot)]);
|
|
146
145
|
session.llm.completeWithTools = async () => {
|
|
147
146
|
throw new Error('the aliased operation must not depend on LLM selection');
|
|
148
147
|
};
|
|
149
|
-
const result = await resolveObjective('Please
|
|
150
|
-
assert.equal(result.capability, '
|
|
151
|
-
assert.equal(result.operation, '
|
|
148
|
+
const result = await resolveObjective('Please prune the old snapshots', session);
|
|
149
|
+
assert.equal(result.capability, 'data.snapshot');
|
|
150
|
+
assert.equal(result.operation, 'prune');
|
|
152
151
|
});
|
|
153
152
|
|
|
154
|
-
test('resolveObjective routes "
|
|
155
|
-
const session = sessionWith([provider('production-1',
|
|
153
|
+
test('resolveObjective routes "clean old snapshots" to prune', async () => {
|
|
154
|
+
const session = sessionWith([provider('production-1', snapshot)]);
|
|
156
155
|
session.llm.completeWithTools = async () => {
|
|
157
156
|
throw new Error('the aliased operation must not depend on LLM selection');
|
|
158
157
|
};
|
|
159
|
-
const result = await resolveObjective('
|
|
160
|
-
assert.equal(result.capability, '
|
|
161
|
-
assert.equal(result.operation, '
|
|
158
|
+
const result = await resolveObjective('Clean old snapshots of the workspace', session);
|
|
159
|
+
assert.equal(result.capability, 'data.snapshot');
|
|
160
|
+
assert.equal(result.operation, 'prune');
|
|
162
161
|
});
|
|
163
162
|
|
|
164
|
-
test('resolveObjective routes "
|
|
165
|
-
const session = sessionWith([provider('production-1',
|
|
163
|
+
test('resolveObjective routes "take a snapshot" to the snapshot operation', async () => {
|
|
164
|
+
const session = sessionWith([provider('production-1', snapshot)]);
|
|
166
165
|
session.llm.completeWithTools = async () => {
|
|
167
166
|
throw new Error('the aliased operation must not depend on LLM selection');
|
|
168
167
|
};
|
|
169
|
-
const result = await resolveObjective('
|
|
170
|
-
assert.equal(result.capability, '
|
|
171
|
-
assert.equal(result.operation, '
|
|
168
|
+
const result = await resolveObjective('Take a snapshot of the workspace', session);
|
|
169
|
+
assert.equal(result.capability, 'data.snapshot');
|
|
170
|
+
assert.equal(result.operation, 'snapshot');
|
|
172
171
|
});
|
|
173
172
|
|
|
174
173
|
test('resolveObjective falls back to operations[0] when a matched alias has no aliasOperations entry', async () => {
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RUNTIME_PROTOCOL_VERSION,
|
|
3
|
+
RuntimeProviderUnavailableError,
|
|
4
|
+
normalizeRuntimeEvent,
|
|
5
|
+
} from './runtimeProvider.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* DeepAgentsProvider — client HTTP vers un runtime Deep Agents externe
|
|
9
|
+
* (RFC § 11, option A). Implémente le contrat RuntimeProvider :
|
|
10
|
+
*
|
|
11
|
+
* GET {endpoint}/health -> { ok, version? } (describe)
|
|
12
|
+
* GET {endpoint}/capabilities -> [{ name, operations }] (discover)
|
|
13
|
+
* POST {endpoint}/runs -> { runId, status } (execute)
|
|
14
|
+
* GET {endpoint}/runs/:id -> { runId, status, result? } (status)
|
|
15
|
+
* POST {endpoint}/runs/:id/cancel -> { ok } (cancel)
|
|
16
|
+
* GET {endpoint}/runs/:id/events -> SSE `data: {json}` (subscribe)
|
|
17
|
+
*
|
|
18
|
+
* `fetchImpl` est injectable pour les tests ; par défaut `globalThis.fetch`
|
|
19
|
+
* (Node 22 / Bun). Un runtime injoignable se manifeste par une `describe()`
|
|
20
|
+
* qui retourne `health: 'unavailable'` (jamais une exception) : l'isolation de
|
|
21
|
+
* panne du discovery s'appuie dessus.
|
|
22
|
+
*/
|
|
23
|
+
export function createDeepAgentsProvider({
|
|
24
|
+
id = 'deepagents',
|
|
25
|
+
endpoint = 'http://agent-runtime:7789',
|
|
26
|
+
capabilities = null,
|
|
27
|
+
fetchImpl = globalThis.fetch,
|
|
28
|
+
headers = {},
|
|
29
|
+
version = null,
|
|
30
|
+
timeoutMs = 10_000,
|
|
31
|
+
} = {}) {
|
|
32
|
+
const base = String(endpoint).replace(/\/+$/, '');
|
|
33
|
+
|
|
34
|
+
async function httpJson(path, { method = 'GET', body = null, signal = null } = {}) {
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
const timer = setTimeout(() => controller.abort(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
37
|
+
const onAbort = () => controller.abort();
|
|
38
|
+
signal?.addEventListener?.('abort', onAbort, { once: true });
|
|
39
|
+
try {
|
|
40
|
+
const response = await fetchImpl(`${base}${path}`, {
|
|
41
|
+
method,
|
|
42
|
+
headers: { 'content-type': 'application/json', accept: 'application/json', ...headers },
|
|
43
|
+
...(body !== null ? { body: JSON.stringify(body) } : {}),
|
|
44
|
+
signal: controller.signal,
|
|
45
|
+
});
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
throw new RuntimeProviderUnavailableError(id, `HTTP ${response.status} on ${method} ${path}`);
|
|
48
|
+
}
|
|
49
|
+
return await response.json();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error instanceof RuntimeProviderUnavailableError) throw error;
|
|
52
|
+
throw new RuntimeProviderUnavailableError(id, error instanceof Error ? error.message : String(error));
|
|
53
|
+
} finally {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
async describe() {
|
|
61
|
+
let health = 'available';
|
|
62
|
+
let runtimeVersion = version ?? null;
|
|
63
|
+
let lastError = null;
|
|
64
|
+
try {
|
|
65
|
+
const info = await httpJson('/health');
|
|
66
|
+
runtimeVersion = info?.version ?? runtimeVersion;
|
|
67
|
+
health = info?.ok === false ? 'unavailable' : 'available';
|
|
68
|
+
} catch (error) {
|
|
69
|
+
health = 'unavailable';
|
|
70
|
+
lastError = error?.reason ?? (error instanceof Error ? error.message : String(error));
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
runtime: id,
|
|
74
|
+
version: runtimeVersion ?? 'unknown',
|
|
75
|
+
protocolVersion: RUNTIME_PROTOCOL_VERSION,
|
|
76
|
+
health,
|
|
77
|
+
...(lastError ? { error: lastError } : {}),
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
async discoverCapabilities() {
|
|
81
|
+
if (Array.isArray(capabilities)) return capabilities;
|
|
82
|
+
const list = await httpJson('/capabilities');
|
|
83
|
+
return Array.isArray(list) ? list : [];
|
|
84
|
+
},
|
|
85
|
+
async execute(request = {}) {
|
|
86
|
+
const accepted = await httpJson('/runs', {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
body: {
|
|
89
|
+
objective: request.objective ?? request.input ?? null,
|
|
90
|
+
operation: request.operation ?? null,
|
|
91
|
+
capability: request.capability ?? null,
|
|
92
|
+
arguments: request.arguments ?? {},
|
|
93
|
+
workspace: request.workspace ?? null,
|
|
94
|
+
model: request.model ?? null,
|
|
95
|
+
language: request.language ?? null,
|
|
96
|
+
mcp: Array.isArray(request.mcp) ? request.mcp : [],
|
|
97
|
+
systemPrompt: request.systemPrompt ?? null,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
const runId = String(accepted?.runId ?? '');
|
|
101
|
+
if (!runId) throw new RuntimeProviderUnavailableError(id, 'execute did not return runId');
|
|
102
|
+
return { runId, status: String(accepted?.status ?? 'running') };
|
|
103
|
+
},
|
|
104
|
+
async status(runId) {
|
|
105
|
+
const state = await httpJson(`/runs/${encodeURIComponent(String(runId))}`);
|
|
106
|
+
return {
|
|
107
|
+
runId: String(state?.runId ?? runId),
|
|
108
|
+
status: String(state?.status ?? 'running'),
|
|
109
|
+
...(state?.result ? { result: state.result } : {}),
|
|
110
|
+
// The gateway reports its failure at the TOP level; dropping it here
|
|
111
|
+
// swallowed the only actionable sentence ("Unable to infer model
|
|
112
|
+
// provider…") and left the manager to invent a cause.
|
|
113
|
+
...(state?.error ? { error: state.error } : {}),
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
async cancel(runId) {
|
|
117
|
+
await httpJson(`/runs/${encodeURIComponent(String(runId))}/cancel`, { method: 'POST' });
|
|
118
|
+
},
|
|
119
|
+
async approve(runId, { approved = true, reason = null, scope = null } = {}) {
|
|
120
|
+
await httpJson(`/runs/${encodeURIComponent(String(runId))}/approve`, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
body: {
|
|
123
|
+
approved: approved === true,
|
|
124
|
+
...(reason ? { reason } : {}),
|
|
125
|
+
...(scope ? { scope } : {}),
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
subscribe(runId, listener) {
|
|
130
|
+
const controller = new AbortController();
|
|
131
|
+
const url = `${base}/runs/${encodeURIComponent(String(runId))}/events`;
|
|
132
|
+
void (async () => {
|
|
133
|
+
try {
|
|
134
|
+
const response = await fetchImpl(url, {
|
|
135
|
+
headers: { accept: 'text/event-stream', ...headers },
|
|
136
|
+
signal: controller.signal,
|
|
137
|
+
});
|
|
138
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
139
|
+
const reader = response.body.getReader();
|
|
140
|
+
const decoder = new TextDecoder();
|
|
141
|
+
let buffer = '';
|
|
142
|
+
while (true) {
|
|
143
|
+
const { done, value } = await reader.read();
|
|
144
|
+
if (done) break;
|
|
145
|
+
buffer += decoder.decode(value, { stream: true });
|
|
146
|
+
const blocks = buffer.split('\n\n');
|
|
147
|
+
buffer = blocks.pop() ?? '';
|
|
148
|
+
for (const block of blocks) {
|
|
149
|
+
let data = '';
|
|
150
|
+
for (const line of block.split('\n')) {
|
|
151
|
+
if (line.startsWith('data: ')) data += line.slice(6);
|
|
152
|
+
}
|
|
153
|
+
if (!data) continue;
|
|
154
|
+
try {
|
|
155
|
+
listener(normalizeRuntimeEvent(JSON.parse(data)));
|
|
156
|
+
} catch {
|
|
157
|
+
// malformed or out-of-contract frame — skip
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
// stream ended or aborted — the unsubscribe path is a no-op
|
|
163
|
+
}
|
|
164
|
+
})();
|
|
165
|
+
return () => controller.abort();
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { createDeepAgentsProvider } from './deepAgentsProvider.js';
|
|
4
|
+
import { RUNTIME_PROTOCOL_VERSION, assertRuntimeProvider } from './runtimeProvider.js';
|
|
5
|
+
import { resolveRuntimeProviders } from './runtimeProviders.js';
|
|
6
|
+
|
|
7
|
+
function jsonResponse(status, data, ok = status < 400) {
|
|
8
|
+
return { ok, status, json: async () => data };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function mockFetch(routes) {
|
|
12
|
+
const calls = [];
|
|
13
|
+
const fetchImpl = async (url, options = {}) => {
|
|
14
|
+
const parsed = new URL(url);
|
|
15
|
+
const key = `${options.method ?? 'GET'} ${parsed.pathname}`;
|
|
16
|
+
calls.push({ method: options.method ?? 'GET', path: parsed.pathname, body: options.body, url });
|
|
17
|
+
const handler = routes[key];
|
|
18
|
+
if (!handler) return { ok: false, status: 404, json: async () => ({}) };
|
|
19
|
+
return handler(parsed, options);
|
|
20
|
+
};
|
|
21
|
+
fetchImpl.calls = calls;
|
|
22
|
+
return fetchImpl;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sseBody(events) {
|
|
26
|
+
const encoder = new TextEncoder();
|
|
27
|
+
const chunks = events.map((event) => encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
|
28
|
+
return new ReadableStream({
|
|
29
|
+
start(controller) {
|
|
30
|
+
for (const chunk of chunks) controller.enqueue(chunk);
|
|
31
|
+
controller.close();
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function waitFor(predicate, { timeoutMs = 1000 } = {}) {
|
|
37
|
+
const deadline = Date.now() + timeoutMs;
|
|
38
|
+
while (Date.now() < deadline) {
|
|
39
|
+
if (predicate()) return;
|
|
40
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 2));
|
|
41
|
+
}
|
|
42
|
+
throw new Error('condition not met in time');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test('describe reports available and reads the version from /health', async () => {
|
|
46
|
+
const fetchImpl = mockFetch({
|
|
47
|
+
'GET /health': () => jsonResponse(200, { ok: true, version: '0.6.10' }),
|
|
48
|
+
});
|
|
49
|
+
const provider = createDeepAgentsProvider({ id: 'deepagents', endpoint: 'http://agent-runtime:8080', fetchImpl });
|
|
50
|
+
assertRuntimeProvider(provider);
|
|
51
|
+
|
|
52
|
+
const description = await provider.describe();
|
|
53
|
+
assert.equal(description.runtime, 'deepagents');
|
|
54
|
+
assert.equal(description.version, '0.6.10');
|
|
55
|
+
assert.equal(description.protocolVersion, RUNTIME_PROTOCOL_VERSION);
|
|
56
|
+
assert.equal(description.health, 'available');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('describe reports unavailable when /health fails, without throwing', async () => {
|
|
60
|
+
const fetchImpl = mockFetch({}); // every route 404
|
|
61
|
+
const provider = createDeepAgentsProvider({ id: 'deepagents', endpoint: 'http://agent-runtime:8080', fetchImpl });
|
|
62
|
+
|
|
63
|
+
const description = await provider.describe();
|
|
64
|
+
assert.equal(description.health, 'unavailable');
|
|
65
|
+
assert.ok(description.error, 'the reason is carried');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('discoverCapabilities fetches /capabilities', async () => {
|
|
69
|
+
const fetchImpl = mockFetch({
|
|
70
|
+
'GET /capabilities': () => jsonResponse(200, [{ name: 'agent.review', operations: ['run'] }]),
|
|
71
|
+
});
|
|
72
|
+
const provider = createDeepAgentsProvider({ endpoint: 'http://agent-runtime:8080', fetchImpl });
|
|
73
|
+
|
|
74
|
+
assert.deepEqual(await provider.discoverCapabilities(), [{ name: 'agent.review', operations: ['run'] }]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('discoverCapabilities uses the static config when provided', async () => {
|
|
78
|
+
const fetchImpl = mockFetch({});
|
|
79
|
+
const provider = createDeepAgentsProvider({
|
|
80
|
+
endpoint: 'http://agent-runtime:8080',
|
|
81
|
+
capabilities: [{ name: 'agent.review', operations: ['run'] }],
|
|
82
|
+
fetchImpl,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
assert.deepEqual(await provider.discoverCapabilities(), [{ name: 'agent.review', operations: ['run'] }]);
|
|
86
|
+
assert.equal(fetchImpl.calls.length, 0, 'no HTTP call when capabilities are static');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('execute POSTs /runs and returns the runId', async () => {
|
|
90
|
+
const fetchImpl = mockFetch({
|
|
91
|
+
'POST /runs': () => jsonResponse(200, { runId: 'run-1', status: 'running' }),
|
|
92
|
+
});
|
|
93
|
+
const provider = createDeepAgentsProvider({ endpoint: 'http://agent-runtime:8080', fetchImpl });
|
|
94
|
+
|
|
95
|
+
const run = await provider.execute({
|
|
96
|
+
objective: 'analyze the demo workspace',
|
|
97
|
+
operation: 'run',
|
|
98
|
+
arguments: {},
|
|
99
|
+
model: { baseUrl: 'http://llm:11434/v1', model: 'qwen3:14b', apiKey: 'secret' },
|
|
100
|
+
});
|
|
101
|
+
assert.deepEqual(run, { runId: 'run-1', status: 'running' });
|
|
102
|
+
assert.equal(fetchImpl.calls[0].path, '/runs');
|
|
103
|
+
const sent = JSON.parse(fetchImpl.calls[0].body);
|
|
104
|
+
assert.equal(sent.objective, 'analyze the demo workspace');
|
|
105
|
+
assert.deepEqual(sent.model, { baseUrl: 'http://llm:11434/v1', model: 'qwen3:14b', apiKey: 'secret' });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('status and cancel hit the run-scoped routes', async () => {
|
|
109
|
+
const fetchImpl = mockFetch({
|
|
110
|
+
'GET /runs/run-1': () => jsonResponse(200, { runId: 'run-1', status: 'completed', result: { status: 'completed' } }),
|
|
111
|
+
'POST /runs/run-1/cancel': () => jsonResponse(200, { ok: true }),
|
|
112
|
+
});
|
|
113
|
+
const provider = createDeepAgentsProvider({ endpoint: 'http://agent-runtime:8080', fetchImpl });
|
|
114
|
+
|
|
115
|
+
const status = await provider.status('run-1');
|
|
116
|
+
assert.equal(status.status, 'completed');
|
|
117
|
+
assert.equal(status.result.status, 'completed');
|
|
118
|
+
|
|
119
|
+
await provider.cancel('run-1');
|
|
120
|
+
assert.equal(fetchImpl.calls.at(-1).path, '/runs/run-1/cancel');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('status forwards the top-level error reported by the gateway', async () => {
|
|
124
|
+
const fetchImpl = mockFetch({
|
|
125
|
+
'GET /runs/run-2': () => jsonResponse(200, { runId: 'run-2', status: 'failed', error: 'Unable to infer model provider' }),
|
|
126
|
+
});
|
|
127
|
+
const provider = createDeepAgentsProvider({ endpoint: 'http://agent-runtime:8080', fetchImpl });
|
|
128
|
+
|
|
129
|
+
const status = await provider.status('run-2');
|
|
130
|
+
assert.equal(status.status, 'failed');
|
|
131
|
+
assert.equal(status.error, 'Unable to infer model provider');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('subscribe parses the SSE stream and forwards normalized events', async () => {
|
|
135
|
+
const fetchImpl = mockFetch({
|
|
136
|
+
'GET /runs/run-1/events': () => ({
|
|
137
|
+
ok: true,
|
|
138
|
+
status: 200,
|
|
139
|
+
body: sseBody([
|
|
140
|
+
{ type: 'tool_started', tool: 'wiki_search' },
|
|
141
|
+
{ type: 'tool_finished', tool: 'wiki_search', resultSummary: '17 found' },
|
|
142
|
+
]),
|
|
143
|
+
}),
|
|
144
|
+
});
|
|
145
|
+
const provider = createDeepAgentsProvider({ endpoint: 'http://agent-runtime:8080', fetchImpl });
|
|
146
|
+
|
|
147
|
+
const events = [];
|
|
148
|
+
provider.subscribe('run-1', (event) => events.push(event));
|
|
149
|
+
|
|
150
|
+
await waitFor(() => events.length === 2);
|
|
151
|
+
assert.deepEqual(events.map((event) => event.type), ['tool_started', 'tool_finished']);
|
|
152
|
+
assert.equal(events[0].tool, 'wiki_search');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('approve posts the decision to the run approval route', async () => {
|
|
156
|
+
const fetchImpl = mockFetch({
|
|
157
|
+
'POST /runs/run-1/approve': () => jsonResponse(200, { ok: true }),
|
|
158
|
+
});
|
|
159
|
+
const provider = createDeepAgentsProvider({ endpoint: 'http://agent-runtime:8080', fetchImpl });
|
|
160
|
+
|
|
161
|
+
await provider.approve('run-1', { approved: true, scope: ['email'] });
|
|
162
|
+
|
|
163
|
+
assert.equal(fetchImpl.calls.at(-1).path, '/runs/run-1/approve');
|
|
164
|
+
const sent = JSON.parse(fetchImpl.calls.at(-1).body);
|
|
165
|
+
assert.equal(sent.approved, true);
|
|
166
|
+
assert.deepEqual(sent.scope, ['email']);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('the deepagents factory is reachable from the agentRuntimes config', () => {
|
|
170
|
+
const { providers, skipped } = resolveRuntimeProviders([
|
|
171
|
+
{ id: 'deepagents', type: 'deepagents', endpoint: 'http://agent-runtime:8080', capabilities: [{ name: 'agent.review', operations: ['run'] }] },
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
assert.equal(skipped.length, 0);
|
|
175
|
+
assert.equal(providers.length, 1);
|
|
176
|
+
assert.equal(providers[0].type, 'deepagents');
|
|
177
|
+
assertRuntimeProvider(providers[0].provider);
|
|
178
|
+
});
|