@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,201 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { createFakeRuntimeProvider } from './fakeRuntimeProvider.js';
|
|
4
|
+
import {
|
|
5
|
+
RUNTIME_PROTOCOL_VERSION,
|
|
6
|
+
RuntimeProviderUnavailableError,
|
|
7
|
+
assertRuntimeDescription,
|
|
8
|
+
assertRuntimeProvider,
|
|
9
|
+
} from './runtimeProvider.js';
|
|
10
|
+
|
|
11
|
+
function echoProvider(options) {
|
|
12
|
+
const provider = createFakeRuntimeProvider(options);
|
|
13
|
+
assertRuntimeProvider(provider);
|
|
14
|
+
return provider;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function waitForStatus(provider, runId, predicate, { timeoutMs = 1000 } = {}) {
|
|
18
|
+
const deadline = Date.now() + timeoutMs;
|
|
19
|
+
while (Date.now() < deadline) {
|
|
20
|
+
const status = await provider.status(runId);
|
|
21
|
+
if (predicate(status.status)) return status;
|
|
22
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 2));
|
|
23
|
+
}
|
|
24
|
+
throw new Error(`run ${runId} did not reach the expected status in time`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('discovery exposes the declared capabilities (Test 1)', async () => {
|
|
28
|
+
const provider = echoProvider({ capabilities: [{ name: 'agent.echo', operations: ['run'] }] });
|
|
29
|
+
|
|
30
|
+
const description = assertRuntimeDescription(await provider.describe());
|
|
31
|
+
assert.equal(description.runtime, 'fake');
|
|
32
|
+
assert.equal(description.protocolVersion, RUNTIME_PROTOCOL_VERSION);
|
|
33
|
+
assert.equal(description.health, 'available');
|
|
34
|
+
|
|
35
|
+
assert.deepEqual(
|
|
36
|
+
await provider.discoverCapabilities(),
|
|
37
|
+
[{ name: 'agent.echo', operations: ['run'] }],
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('execute returns a runId and reaches completed (Test 2)', async () => {
|
|
42
|
+
const provider = echoProvider();
|
|
43
|
+
|
|
44
|
+
const run = await provider.execute({ objective: 'echo' });
|
|
45
|
+
assert.equal(run.status, 'running');
|
|
46
|
+
assert.ok(run.runId);
|
|
47
|
+
|
|
48
|
+
const status = await waitForStatus(provider, run.runId, (value) => value === 'completed');
|
|
49
|
+
assert.equal(status.status, 'completed');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('subscribe replays and streams the run events (Test 3)', async () => {
|
|
53
|
+
const provider = echoProvider();
|
|
54
|
+
|
|
55
|
+
const run = await provider.execute({ objective: 'echo' });
|
|
56
|
+
const events = [];
|
|
57
|
+
const unsubscribe = provider.subscribe(run.runId, (event) => events.push(event));
|
|
58
|
+
|
|
59
|
+
await waitForStatus(provider, run.runId, (value) => value === 'completed');
|
|
60
|
+
|
|
61
|
+
const types = events.map((event) => event.type);
|
|
62
|
+
assert.ok(types.includes('run_started'), 'replayed run_started');
|
|
63
|
+
assert.ok(types.includes('tool_finished'), 'replayed tool_finished');
|
|
64
|
+
assert.ok(types.includes('run_completed'), 'streamed run_completed');
|
|
65
|
+
unsubscribe();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('cancel stops the run and emits run_cancelled (Test 4)', async () => {
|
|
69
|
+
const provider = echoProvider({ autoCompleteMs: 50 });
|
|
70
|
+
|
|
71
|
+
const run = await provider.execute({ objective: 'echo' });
|
|
72
|
+
const events = [];
|
|
73
|
+
provider.subscribe(run.runId, (event) => events.push(event));
|
|
74
|
+
|
|
75
|
+
await provider.cancel(run.runId);
|
|
76
|
+
const status = await provider.status(run.runId);
|
|
77
|
+
assert.equal(status.status, 'cancelled');
|
|
78
|
+
assert.ok(events.some((event) => event.type === 'run_cancelled'));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a down provider reports unavailable and rejects execution (Test 5)', async () => {
|
|
82
|
+
const provider = echoProvider({ available: false });
|
|
83
|
+
|
|
84
|
+
const description = await provider.describe();
|
|
85
|
+
assert.equal(description.health, 'unavailable');
|
|
86
|
+
|
|
87
|
+
await assert.rejects(() => provider.discoverCapabilities(), RuntimeProviderUnavailableError);
|
|
88
|
+
await assert.rejects(() => provider.execute({ objective: 'echo' }), RuntimeProviderUnavailableError);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('concurrent runs are independent (Test 6)', async () => {
|
|
92
|
+
const provider = echoProvider();
|
|
93
|
+
|
|
94
|
+
const [a, b, c] = await Promise.all([
|
|
95
|
+
provider.execute({ objective: 'A' }),
|
|
96
|
+
provider.execute({ objective: 'B' }),
|
|
97
|
+
provider.execute({ objective: 'C' }),
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
const runIds = [a.runId, b.runId, c.runId];
|
|
101
|
+
assert.equal(new Set(runIds).size, 3, 'three distinct run ids');
|
|
102
|
+
|
|
103
|
+
const statuses = await Promise.all(
|
|
104
|
+
runIds.map((runId) => waitForStatus(provider, runId, (value) => value === 'completed')),
|
|
105
|
+
);
|
|
106
|
+
assert.deepEqual(
|
|
107
|
+
statuses.map((status) => status.status),
|
|
108
|
+
['completed', 'completed', 'completed'],
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('a provider with requireApproval waits for approve() before completing (Test 7)', async () => {
|
|
113
|
+
const provider = echoProvider({ requireApproval: true });
|
|
114
|
+
|
|
115
|
+
const run = await provider.execute({ objective: 'echo' });
|
|
116
|
+
assert.equal(run.status, 'waiting_approval');
|
|
117
|
+
|
|
118
|
+
const events = [];
|
|
119
|
+
provider.subscribe(run.runId, (event) => events.push(event));
|
|
120
|
+
assert.ok(events.some((event) => event.type === 'approval_required'), 'the proposal was emitted');
|
|
121
|
+
assert.ok(!events.some((event) => event.type === 'run_completed'), 'nothing completes before approval');
|
|
122
|
+
|
|
123
|
+
await provider.approve(run.runId, { approved: true, scope: ['echo'] });
|
|
124
|
+
const status = await waitForStatus(provider, run.runId, (value) => value === 'completed');
|
|
125
|
+
assert.equal(status.status, 'completed');
|
|
126
|
+
assert.ok(events.some((event) => event.type === 'run_completed'));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('denying the HITL approval cancels the run', async () => {
|
|
130
|
+
const provider = echoProvider({ requireApproval: true });
|
|
131
|
+
|
|
132
|
+
const run = await provider.execute({ objective: 'echo' });
|
|
133
|
+
await provider.approve(run.runId, { approved: false, reason: 'refused' });
|
|
134
|
+
|
|
135
|
+
const status = await provider.status(run.runId);
|
|
136
|
+
assert.equal(status.status, 'cancelled');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('per-capability HITL: a mutating capability waits for approve(), a read-only one does not', async () => {
|
|
140
|
+
const provider = echoProvider({
|
|
141
|
+
capabilities: [
|
|
142
|
+
{ name: 'agent.review', operations: ['run'] },
|
|
143
|
+
{ name: 'agent.research', operations: ['run'], mutationClass: 'ingest' },
|
|
144
|
+
],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const readRun = await provider.execute({ capability: 'agent.review', objective: 'audit' });
|
|
148
|
+
const readStatus = await waitForStatus(provider, readRun.runId, (value) => value === 'completed');
|
|
149
|
+
assert.equal(readStatus.status, 'completed');
|
|
150
|
+
|
|
151
|
+
const mutatingRun = await provider.execute({ capability: 'agent.research', objective: 'research' });
|
|
152
|
+
assert.equal(mutatingRun.status, 'waiting_approval');
|
|
153
|
+
const events = [];
|
|
154
|
+
provider.subscribe(mutatingRun.runId, (event) => events.push(event));
|
|
155
|
+
const approval = events.find((event) => event.type === 'approval_required');
|
|
156
|
+
assert.equal(approval.proposal.mutations[0].kind, 'ingest', 'the announced class matches the declared mutationClass');
|
|
157
|
+
|
|
158
|
+
await provider.approve(mutatingRun.runId, { approved: true, scope: ['ingest'] });
|
|
159
|
+
const final = await waitForStatus(provider, mutatingRun.runId, (value) => value === 'completed');
|
|
160
|
+
assert.equal(final.status, 'completed');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("the 'plan' operation is a dry-run and never pauses, even on a mutating capability", async () => {
|
|
164
|
+
const provider = echoProvider({
|
|
165
|
+
capabilities: [{ name: 'agent.plan', operations: ['plan', 'run'], defaultRequiresApproval: true }],
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const dryRun = await provider.execute({ capability: 'agent.plan', operation: 'plan', objective: 'propose' });
|
|
169
|
+
const dryStatus = await waitForStatus(provider, dryRun.runId, (value) => value === 'completed');
|
|
170
|
+
assert.equal(dryStatus.status, 'completed', 'plan completes without approval');
|
|
171
|
+
|
|
172
|
+
const liveRun = await provider.execute({ capability: 'agent.plan', operation: 'run', objective: 'propose' });
|
|
173
|
+
assert.equal(liveRun.status, 'waiting_approval', 'run pauses for approval');
|
|
174
|
+
await provider.approve(liveRun.runId, { approved: true });
|
|
175
|
+
const final = await waitForStatus(provider, liveRun.runId, (value) => value === 'completed');
|
|
176
|
+
assert.equal(final.status, 'completed');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('fake provider carries a proposal in the structured result, like the gateway', async () => {
|
|
180
|
+
const proposal = {
|
|
181
|
+
capability: 'knowledge.update',
|
|
182
|
+
operation: 'ingest',
|
|
183
|
+
objective: 'ingest the pending raw sources',
|
|
184
|
+
reason: 'deterministic E2E',
|
|
185
|
+
};
|
|
186
|
+
const provider = createFakeRuntimeProvider({
|
|
187
|
+
capabilities: [{ name: 'agent.proposal-test', operations: ['run'] }],
|
|
188
|
+
proposal,
|
|
189
|
+
});
|
|
190
|
+
const run = await provider.execute({ capability: 'agent.proposal-test', operation: 'run', objective: 'propose' });
|
|
191
|
+
const status = await waitForStatus(provider, run.runId, (value) => value === 'completed');
|
|
192
|
+
assert.deepEqual(status.result.planExpansionRequest, proposal);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('fake provider without a proposal keeps a plain result', async () => {
|
|
196
|
+
const provider = createFakeRuntimeProvider({ capabilities: [{ name: 'agent.echo', operations: ['run'] }] });
|
|
197
|
+
const run = await provider.execute({ capability: 'agent.echo', operation: 'run', objective: 'echo' });
|
|
198
|
+
const status = await waitForStatus(provider, run.runId, (value) => value === 'completed');
|
|
199
|
+
assert.equal(status.result.planExpansionRequest, undefined);
|
|
200
|
+
assert.equal(typeof status.result.content, 'string');
|
|
201
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { assertContract } from '../../contracts/schemas.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* External Agent Runtime Provider — contrat.
|
|
5
|
+
*
|
|
6
|
+
* Le point d'abstraction qui laisse Wiki Manager router une tâche agentique
|
|
7
|
+
* vers un moteur externe (Deep Agents, autre) SANS le transformer en couche
|
|
8
|
+
* d'orchestration. Le contrat est volontairement plus petit que le moteur
|
|
9
|
+
* interne : il ne reproduit ni Control Queue, ni scheduler, ni DAG, ni
|
|
10
|
+
* politique d'approbation complète, ni objective resolver — ces responsabilités
|
|
11
|
+
* restent dans Wiki Manager (voir RFC § 8).
|
|
12
|
+
*
|
|
13
|
+
* Un provider implémente :
|
|
14
|
+
*
|
|
15
|
+
* describe(): Promise<RuntimeDescription>
|
|
16
|
+
* { runtime, version, protocolVersion, health, capabilities? }
|
|
17
|
+
*
|
|
18
|
+
* discoverCapabilities(): Promise<Capability[]>
|
|
19
|
+
* [{ name: 'agent.review', operations: ['run'] }, ...]
|
|
20
|
+
*
|
|
21
|
+
* execute(request: RuntimeExecuteRequest): Promise<RuntimeRun>
|
|
22
|
+
* { runId, status: 'running' } — ne bloque pas.
|
|
23
|
+
*
|
|
24
|
+
* status(runId: string): Promise<RuntimeStatus>
|
|
25
|
+
* { runId, status } — status parmi les états terminaux du moteur.
|
|
26
|
+
*
|
|
27
|
+
* cancel(runId: string): Promise<void>
|
|
28
|
+
*
|
|
29
|
+
* subscribe(runId: string, listener: RuntimeEventListener): Unsubscribe
|
|
30
|
+
* le listener reçoit des RuntimeEvent ; `Unsubscribe` est une fonction.
|
|
31
|
+
*
|
|
32
|
+
* approve(runId: string, { approved, scope?, reason? }): Promise<void>
|
|
33
|
+
* Réponse au human-in-the-loop du runtime. CE N'EST PAS un mécanisme
|
|
34
|
+
* d'approbation : son seul appelant est le dispatcher, et uniquement
|
|
35
|
+
* après qu'un grant humain couvre la demande (`approvalCovered`).
|
|
36
|
+
* Jamais exposé en tool ni en endpoint — le projet a déjà retiré un
|
|
37
|
+
* self-approval tool, ce serait le réintroduire.
|
|
38
|
+
*
|
|
39
|
+
* Le mot « provider » est ici distinct des `providers` du `CapabilityRegistry`
|
|
40
|
+
* (qui sont des instances d'agents MCP). Un RuntimeProvider n'est PAS un agent
|
|
41
|
+
* MCP : c'est un backend d'exécution externe découvert séparément.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
export const RUNTIME_PROTOCOL_VERSION = '1';
|
|
45
|
+
|
|
46
|
+
export const RUNTIME_EVENT_TYPES = [
|
|
47
|
+
'run_created',
|
|
48
|
+
'run_started',
|
|
49
|
+
'agent_thinking',
|
|
50
|
+
'tool_started',
|
|
51
|
+
'tool_finished',
|
|
52
|
+
'subagent_started',
|
|
53
|
+
'subagent_finished',
|
|
54
|
+
'message',
|
|
55
|
+
'approval_required',
|
|
56
|
+
'run_completed',
|
|
57
|
+
'run_failed',
|
|
58
|
+
'run_cancelled',
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
export class RuntimeProviderUnavailableError extends Error {
|
|
62
|
+
constructor(runtime, reason) {
|
|
63
|
+
super(`Runtime provider unavailable: ${runtime} (${reason})`);
|
|
64
|
+
this.name = 'RuntimeProviderUnavailableError';
|
|
65
|
+
this.runtime = String(runtime ?? 'unknown');
|
|
66
|
+
this.reason = String(reason ?? 'unknown');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const CONTRACT_METHODS = [
|
|
71
|
+
'describe',
|
|
72
|
+
'discoverCapabilities',
|
|
73
|
+
'execute',
|
|
74
|
+
'status',
|
|
75
|
+
'cancel',
|
|
76
|
+
'subscribe',
|
|
77
|
+
'approve',
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
export function assertRuntimeProvider(provider) {
|
|
81
|
+
if (!provider || typeof provider !== 'object') {
|
|
82
|
+
throw new RuntimeProviderUnavailableError('unknown', 'provider is not an object');
|
|
83
|
+
}
|
|
84
|
+
for (const method of CONTRACT_METHODS) {
|
|
85
|
+
if (typeof provider[method] !== 'function') {
|
|
86
|
+
throw new RuntimeProviderUnavailableError(
|
|
87
|
+
provider.runtime ?? 'unknown',
|
|
88
|
+
`missing method "${method}"`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return provider;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function assertRuntimeDescription(description) {
|
|
96
|
+
return assertContract('runtimeDescription', description);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function normalizeRuntimeEvent(event) {
|
|
100
|
+
return assertContract('runtimeEvent', event);
|
|
101
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { dispatchRuntimeLog } from '../../core/agentEvents.js';
|
|
4
|
+
import { managerStateDir } from '../../core/env.js';
|
|
5
|
+
import { createDeepAgentsProvider } from './deepAgentsProvider.js';
|
|
6
|
+
import { createFakeRuntimeProvider } from './fakeRuntimeProvider.js';
|
|
7
|
+
import { assertRuntimeProvider } from './runtimeProvider.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Découverte des runtimes agentiques externes et projection en « agents »
|
|
11
|
+
* synthétiques, afin que leurs capabilities entrent dans le même
|
|
12
|
+
* `CapabilityRegistry` que les agents MCP (RFC § 10, niveau B).
|
|
13
|
+
*
|
|
14
|
+
* Un runtime down ne produit AUCUN agent : ses capabilities sont simplement
|
|
15
|
+
* absentes du registry, et la résolution échoue en `capability_not_found`
|
|
16
|
+
* sans jamais toucher aux capabilities MCP existantes (isolation de panne,
|
|
17
|
+
* RFC § 39). Le runtime défaillant est néanmoins signalé via la liste
|
|
18
|
+
* `unavailable` retournée — une dégradation doit s'annoncer.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export async function discoverRuntimeProviderAgents(runtimeProviders) {
|
|
22
|
+
const providers = Array.isArray(runtimeProviders)
|
|
23
|
+
? runtimeProviders
|
|
24
|
+
: (runtimeProviders?.list?.() ?? []);
|
|
25
|
+
const agents = [];
|
|
26
|
+
const unavailable = [];
|
|
27
|
+
|
|
28
|
+
for (const entry of providers) {
|
|
29
|
+
const provider = entry?.provider ?? entry;
|
|
30
|
+
const runtimeId = String(entry?.id ?? provider?.runtime ?? 'external-runtime');
|
|
31
|
+
let description;
|
|
32
|
+
try {
|
|
33
|
+
assertRuntimeProvider(provider);
|
|
34
|
+
description = await provider.describe();
|
|
35
|
+
} catch (error) {
|
|
36
|
+
unavailable.push({ runtimeId, error: error instanceof Error ? error.message : String(error) });
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (description?.health === 'unavailable') {
|
|
40
|
+
unavailable.push({ runtimeId, error: description?.error ?? 'runtime reports unavailable' });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
let capabilities;
|
|
44
|
+
try {
|
|
45
|
+
capabilities = await provider.discoverCapabilities();
|
|
46
|
+
} catch (error) {
|
|
47
|
+
unavailable.push({ runtimeId, error: error instanceof Error ? error.message : String(error) });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const health = ['available', 'degraded'].includes(description?.health)
|
|
51
|
+
? description.health
|
|
52
|
+
: 'available';
|
|
53
|
+
for (const capability of capabilities ?? []) {
|
|
54
|
+
agents.push(runtimeProviderAgent(runtimeId, provider, description, capability, health));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { agents, unavailable };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Configuration `agentRuntimes` (RFC § 37), fichier `agent-runtimes.json` dans
|
|
63
|
+
* le répertoire d'état du manager. Deux formes tolérées : un tableau nu, ou un
|
|
64
|
+
* objet `{ "runtimes": [...] }`. Absent ou illisible ⇒ aucune runtime déclarée.
|
|
65
|
+
*
|
|
66
|
+
* Entrée : `{ id, type, endpoint?, enabled?, capabilities?, limits? }`.
|
|
67
|
+
*/
|
|
68
|
+
export function loadAgentRuntimesConfig({ stateDir = managerStateDir(), log = () => {}, env = process.env } = {}) {
|
|
69
|
+
const file = join(stateDir, 'agent-runtimes.json');
|
|
70
|
+
let entries = [];
|
|
71
|
+
if (existsSync(file)) {
|
|
72
|
+
try {
|
|
73
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
74
|
+
entries = Array.isArray(raw)
|
|
75
|
+
? raw
|
|
76
|
+
: (raw && typeof raw === 'object' && !Array.isArray(raw) ? raw.runtimes ?? [] : []);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
log(`agent-runtimes.json unreadable: ${error instanceof Error ? error.message : String(error)}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return withImpliedGateway(entries, env, log);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// One switch: when the operator starts the gateway container
|
|
85
|
+
// (GATEWAY_ENABLED=true in the manager .env), the manager implies the runtime
|
|
86
|
+
// declaration itself — if it starts, it is usable. agent-runtimes.json stays
|
|
87
|
+
// for the exceptional cases: a custom endpoint, pinned capabilities, or
|
|
88
|
+
// another engine (fake). An explicit ENABLED deepagents entry wins over the
|
|
89
|
+
// implied one; a disabled entry does not block it — but that override is a
|
|
90
|
+
// case worth stating, not assuming: an operator who wrote `enabled: false`
|
|
91
|
+
// deliberately would otherwise have no way to learn why deepagents ran anyway.
|
|
92
|
+
function withImpliedGateway(entries, env = process.env, log = () => {}) {
|
|
93
|
+
if (!isTruthy(env.GATEWAY_ENABLED)) return entries;
|
|
94
|
+
const explicitEnabled = entries.some((entry) =>
|
|
95
|
+
entry?.type === 'deepagents' && entry?.enabled !== false);
|
|
96
|
+
if (explicitEnabled) return entries;
|
|
97
|
+
const disabledEntry = entries.find((entry) => entry?.type === 'deepagents' && entry?.enabled === false);
|
|
98
|
+
if (disabledEntry) {
|
|
99
|
+
log('agent-runtimes: GATEWAY_ENABLED=true implies a "deepagents" runtime despite an explicit enabled:false entry in agent-runtimes.json');
|
|
100
|
+
}
|
|
101
|
+
const port = String(env.GATEWAY_PORT ?? '7789');
|
|
102
|
+
const token = String(env.GATEWAY_AUTH_TOKEN ?? '').trim();
|
|
103
|
+
// The implied entry inherits the disabled entry's declared capability shape
|
|
104
|
+
// (approval classes, alias operations, descriptions): the scaffolded
|
|
105
|
+
// agent-runtimes.json ships the full list with enabled:false, and dropping it
|
|
106
|
+
// here would let a gateway /capabilities response that omits
|
|
107
|
+
// defaultRequiresApproval/mutationClass turn a mutating capability into a
|
|
108
|
+
// self-approving one. The endpoint stays host-local (the manager runtime runs
|
|
109
|
+
// on the host, not in the agents network) and the env token still wins.
|
|
110
|
+
const inheritedCapabilities = Array.isArray(disabledEntry?.capabilities) && disabledEntry.capabilities.length > 0
|
|
111
|
+
? { capabilities: disabledEntry.capabilities }
|
|
112
|
+
: {};
|
|
113
|
+
const inheritedHeaders = disabledEntry?.headers && typeof disabledEntry.headers === 'object'
|
|
114
|
+
? disabledEntry.headers
|
|
115
|
+
: {};
|
|
116
|
+
return [
|
|
117
|
+
...entries.filter((entry) => entry !== disabledEntry),
|
|
118
|
+
{
|
|
119
|
+
...(disabledEntry?.timeoutMs ? { timeoutMs: disabledEntry.timeoutMs } : {}),
|
|
120
|
+
...inheritedCapabilities,
|
|
121
|
+
id: 'deepagents',
|
|
122
|
+
type: 'deepagents',
|
|
123
|
+
endpoint: `http://localhost:${port}`,
|
|
124
|
+
enabled: true,
|
|
125
|
+
...(token
|
|
126
|
+
? { headers: { ...inheritedHeaders, Authorization: `Bearer ${token}` } }
|
|
127
|
+
: (Object.keys(inheritedHeaders).length > 0 ? { headers: inheritedHeaders } : {})),
|
|
128
|
+
},
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isTruthy(value) {
|
|
133
|
+
return /^(1|true|yes|on)$/i.test(String(value ?? '').trim());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Usines de providers par `type`. `fake` sert les tests/plomberie ; `deepagents`
|
|
138
|
+
* parle HTTP à un runtime externe (Phase 5). Un type inconnu est ignoré et
|
|
139
|
+
* signalé — jamais une erreur fatale au démarrage.
|
|
140
|
+
*/
|
|
141
|
+
export const runtimeProviderFactories = {
|
|
142
|
+
fake: (entry = {}) => createFakeRuntimeProvider({
|
|
143
|
+
runtime: String(entry?.id ?? 'fake'),
|
|
144
|
+
capabilities: Array.isArray(entry?.capabilities) && entry.capabilities.length > 0
|
|
145
|
+
? entry.capabilities
|
|
146
|
+
: undefined,
|
|
147
|
+
...(entry?.available === false ? { available: false } : {}),
|
|
148
|
+
...(entry?.proposal && typeof entry.proposal === 'object' ? { proposal: entry.proposal } : {}),
|
|
149
|
+
}),
|
|
150
|
+
deepagents: (entry = {}) => createDeepAgentsProvider({
|
|
151
|
+
id: String(entry?.id ?? 'deepagents'),
|
|
152
|
+
endpoint: String(entry?.endpoint ?? 'http://agent-runtime:7789'),
|
|
153
|
+
capabilities: Array.isArray(entry?.capabilities) && entry.capabilities.length > 0
|
|
154
|
+
? entry.capabilities
|
|
155
|
+
: null,
|
|
156
|
+
...(entry?.headers && typeof entry.headers === 'object' ? { headers: entry.headers } : {}),
|
|
157
|
+
...(entry?.timeoutMs ? { timeoutMs: Number(entry.timeoutMs) } : {}),
|
|
158
|
+
}),
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export function resolveRuntimeProviders(config = [], { factories = runtimeProviderFactories } = {}) {
|
|
162
|
+
const providers = [];
|
|
163
|
+
const skipped = [];
|
|
164
|
+
for (const entry of Array.isArray(config) ? config : []) {
|
|
165
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
166
|
+
if (entry.enabled === false) continue;
|
|
167
|
+
const id = String(entry?.id ?? entry?.type ?? '');
|
|
168
|
+
const type = String(entry?.type ?? '');
|
|
169
|
+
const factory = factories?.[type];
|
|
170
|
+
if (typeof factory !== 'function') {
|
|
171
|
+
skipped.push({ id, reason: `unknown runtime type "${type}"` });
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const provider = factory(entry);
|
|
176
|
+
assertRuntimeProvider(provider);
|
|
177
|
+
providers.push({ id, type, provider });
|
|
178
|
+
} catch (error) {
|
|
179
|
+
skipped.push({ id, reason: error instanceof Error ? error.message : String(error) });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { providers, skipped };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Hook de découverte : peuple `session.runtimeProviderAgents` à partir de la
|
|
187
|
+
* config, et signale les runtimes sautées/indisponibles dans le journal
|
|
188
|
+
* (une dégradation doit s'annoncer). Appelée au boot et à chaque re-scan,
|
|
189
|
+
* en vis-à-vis de `discoverAgentsOnce`.
|
|
190
|
+
*/
|
|
191
|
+
export async function discoverRuntimeProvidersOnce(session, {
|
|
192
|
+
signal = null,
|
|
193
|
+
config = null,
|
|
194
|
+
} = {}) {
|
|
195
|
+
if (!session || typeof session !== 'object') return [];
|
|
196
|
+
void signal;
|
|
197
|
+
let impliedGatewayOverride = false;
|
|
198
|
+
const entries = config ?? loadAgentRuntimesConfig({
|
|
199
|
+
log: (message) => {
|
|
200
|
+
if (String(message).startsWith('agent-runtimes: GATEWAY_ENABLED=true implies')) impliedGatewayOverride = true;
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
const resolved = resolveRuntimeProviders(entries);
|
|
204
|
+
const { agents, unavailable } = await discoverRuntimeProviderAgents(resolved.providers);
|
|
205
|
+
|
|
206
|
+
// A failed probe is not a lost agent (same rule as agentRegistry): a runtime
|
|
207
|
+
// that reported unavailable this round but for which we hold a last-known-good
|
|
208
|
+
// capability set keeps that set until it recovers, instead of every `agent.*`
|
|
209
|
+
// capability vanishing from the registry on a transient network blip. Health
|
|
210
|
+
// is deliberately not downgraded — capabilityResolver only accepts
|
|
211
|
+
// available/degraded, so moving it to unavailable would trade a silent loss
|
|
212
|
+
// for a silent refusal. A runtime that answers is authoritative, even when it
|
|
213
|
+
// answers with zero capabilities (a real removal); a runtime no longer
|
|
214
|
+
// configured is forgotten.
|
|
215
|
+
session._runtimeProviderLastKnown ??= new Map();
|
|
216
|
+
const lastKnown = session._runtimeProviderLastKnown;
|
|
217
|
+
const configuredIds = new Set(resolved.providers.map((entry) => String(entry?.id ?? entry?.provider?.runtime ?? 'external-runtime')));
|
|
218
|
+
const unavailableIds = new Set(unavailable.map((item) => item.runtimeId));
|
|
219
|
+
const answeredIds = new Set(agents.map((agent) => agent.runtimeId));
|
|
220
|
+
for (const id of configuredIds) {
|
|
221
|
+
if (unavailableIds.has(id) && !answeredIds.has(id)) continue; // preserved below
|
|
222
|
+
const fresh = agents.filter((agent) => agent.runtimeId === id);
|
|
223
|
+
if (fresh.length > 0) lastKnown.set(id, fresh);
|
|
224
|
+
else lastKnown.delete(id);
|
|
225
|
+
}
|
|
226
|
+
for (const id of [...lastKnown.keys()]) {
|
|
227
|
+
if (!configuredIds.has(id)) lastKnown.delete(id);
|
|
228
|
+
}
|
|
229
|
+
const preservedByRuntime = new Map();
|
|
230
|
+
for (const item of unavailable) {
|
|
231
|
+
if (answeredIds.has(item.runtimeId)) continue;
|
|
232
|
+
const kept = lastKnown.get(item.runtimeId);
|
|
233
|
+
if (kept && kept.length > 0) preservedByRuntime.set(item.runtimeId, kept);
|
|
234
|
+
}
|
|
235
|
+
const effectiveAgents = [...agents];
|
|
236
|
+
for (const kept of preservedByRuntime.values()) effectiveAgents.push(...kept);
|
|
237
|
+
session.runtimeProviderAgents = effectiveAgents;
|
|
238
|
+
// A degradation is announced ONCE, on the transition to down/skipped — not
|
|
239
|
+
// on every periodic re-scan. The agent registry learned this the hard way: a
|
|
240
|
+
// stopped endpoint is a fact to state, not an error to repeat every minute.
|
|
241
|
+
// The set is edge-triggered and forgotten as soon as the runtime recovers,
|
|
242
|
+
// so a future outage is announced again.
|
|
243
|
+
if (impliedGatewayOverride && !session._gatewayOverrideAnnounced) {
|
|
244
|
+
dispatchRuntimeLog(session, 'agent-runtimes: GATEWAY_ENABLED=true implies a "deepagents" runtime despite an explicit enabled:false entry in agent-runtimes.json');
|
|
245
|
+
}
|
|
246
|
+
session._gatewayOverrideAnnounced = impliedGatewayOverride;
|
|
247
|
+
session._runtimeProviderDown ??= new Set();
|
|
248
|
+
const currentDown = new Set();
|
|
249
|
+
for (const item of resolved.skipped) {
|
|
250
|
+
const key = `skipped:${item.id || '(unnamed)'}`;
|
|
251
|
+
currentDown.add(key);
|
|
252
|
+
if (!session._runtimeProviderDown.has(key)) {
|
|
253
|
+
dispatchRuntimeLog(session, `agent-runtimes: ${item.id || '(unnamed)'} skipped (${item.reason})`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
for (const item of unavailable) {
|
|
257
|
+
const key = `unavailable:${item.runtimeId}`;
|
|
258
|
+
currentDown.add(key);
|
|
259
|
+
if (!session._runtimeProviderDown.has(key)) {
|
|
260
|
+
const kept = preservedByRuntime.get(item.runtimeId)?.length ?? 0;
|
|
261
|
+
dispatchRuntimeLog(session, kept > 0
|
|
262
|
+
? `agent-runtimes: ${item.runtimeId} unavailable (${item.error}) — keeping ${kept} last-known capabilit${kept === 1 ? 'y' : 'ies'} until it recovers`
|
|
263
|
+
: `agent-runtimes: ${item.runtimeId} unavailable (${item.error})`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
session._runtimeProviderDown = currentDown;
|
|
267
|
+
return effectiveAgents;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function runtimeProviderAgent(runtimeId, provider, description, capability, health) {
|
|
271
|
+
const name = String(capability?.name ?? '');
|
|
272
|
+
const operations = Array.isArray(capability?.operations)
|
|
273
|
+
? capability.operations.map(String).filter(Boolean)
|
|
274
|
+
: [];
|
|
275
|
+
const agentInstanceId = `${runtimeId}::${name}`;
|
|
276
|
+
return {
|
|
277
|
+
agentInstanceId,
|
|
278
|
+
serverName: null,
|
|
279
|
+
legacy: false,
|
|
280
|
+
orchestrable: true,
|
|
281
|
+
health,
|
|
282
|
+
providerKind: 'external-runtime',
|
|
283
|
+
runtimeId,
|
|
284
|
+
runtimeProvider: provider,
|
|
285
|
+
lastSeenAt: new Date().toISOString(),
|
|
286
|
+
description: {
|
|
287
|
+
contractVersion: '1',
|
|
288
|
+
agentType: 'external-runtime',
|
|
289
|
+
agentInstanceId,
|
|
290
|
+
displayName: `${runtimeId} (${name})`,
|
|
291
|
+
capabilities: [{
|
|
292
|
+
id: name,
|
|
293
|
+
version: '1',
|
|
294
|
+
description: String(capability?.description ?? `${runtimeId}/${name}`),
|
|
295
|
+
inputSchema: {},
|
|
296
|
+
outputSchema: {},
|
|
297
|
+
supportedOperations: operations.length > 0 ? operations : ['run'],
|
|
298
|
+
aliases: Array.isArray(capability?.aliases) ? capability.aliases.map(String).filter(Boolean) : [],
|
|
299
|
+
aliasOperations: capability?.aliasOperations && typeof capability.aliasOperations === 'object'
|
|
300
|
+
? { ...capability.aliasOperations }
|
|
301
|
+
: null,
|
|
302
|
+
// Without these, an external-runtime capability can never be seen as
|
|
303
|
+
// mutating by isMutatingTask/buildExecutorOnlyFragment, so a task
|
|
304
|
+
// that should wait for a human grant starts unapproved by construction.
|
|
305
|
+
...(typeof capability?.mutationClass === 'string' ? { mutationClass: capability.mutationClass } : {}),
|
|
306
|
+
...(capability?.defaultRequiresApproval === true ? { defaultRequiresApproval: true } : {}),
|
|
307
|
+
}],
|
|
308
|
+
orchestration: {
|
|
309
|
+
canPlan: false,
|
|
310
|
+
canExpandPlan: false,
|
|
311
|
+
canExecute: true,
|
|
312
|
+
canCancel: true,
|
|
313
|
+
canResume: false,
|
|
314
|
+
supportsIdempotency: false,
|
|
315
|
+
supportsParallelWorkers: true,
|
|
316
|
+
singleTaskOnly: true,
|
|
317
|
+
},
|
|
318
|
+
limits: {
|
|
319
|
+
recommendedConcurrency: Number(description?.limits?.recommendedConcurrency ?? 4),
|
|
320
|
+
maxConcurrency: Number(description?.limits?.maxConcurrency ?? 8),
|
|
321
|
+
},
|
|
322
|
+
health: { status: health },
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
}
|