@dotdrelle/wiki-manager 0.11.0 → 0.11.6
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/README.md +14 -0
- package/agents.docker-compose.yml +3 -3
- package/package.json +2 -2
- package/src/agent/graph.js +99 -3
- package/src/agent/graph.test.js +81 -0
- package/src/cli/wiki-manager.js +11 -4
- package/src/commands/slash.js +7 -1
- package/src/core/agentLoop.js +20 -5
- package/src/core/agentLoop.test.js +19 -1
- package/src/core/mcp.js +1 -1
- package/src/core/planPatch.js +39 -1
- package/src/core/planPatch.test.js +23 -1
- package/src/core/wikiSetup.js +7 -2
- package/src/core/wikirc.test.js +40 -2
- package/src/runtime/client.js +12 -0
- package/src/runtime/donna-contract.test.js +359 -0
- package/src/runtime/lifecycle.js +1 -1
- package/src/runtime/runner.e2e.test.js +52 -0
- package/src/runtime/runner.js +126 -8
- package/src/runtime/runner.test.js +385 -3
- package/src/runtime/server.js +28 -2
- package/src/runtime/server.test.js +38 -0
- package/src/runtime/store.js +72 -2
- package/src/runtime/store.test.js +198 -1
- package/src/shell/LeftPane.tsx +2 -1
- package/src/shell/StartupScreen.tsx +101 -21
- package/src/shell/repl.js +60 -15
- package/src/shell/repl.test.js +33 -1
- package/src/shell/tui.tsx +90 -75
- package/src/shell/useAgent.ts +14 -4
- package/src/shell/useSession.ts +92 -17
package/src/runtime/client.js
CHANGED
|
@@ -97,6 +97,18 @@ export async function postRuntimeCancel({
|
|
|
97
97
|
return response.json();
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
export async function postRuntimeShutdown({
|
|
101
|
+
url = runtimeUrlFromEnv(),
|
|
102
|
+
token = runtimeToken(),
|
|
103
|
+
} = {}) {
|
|
104
|
+
const response = await fetch(runtimeEndpoint(url, '/shutdown'), {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: runtimeHeaders(token),
|
|
107
|
+
});
|
|
108
|
+
if (!response.ok) throw new Error(`Runtime shutdown failed: HTTP ${response.status}`);
|
|
109
|
+
return response.json();
|
|
110
|
+
}
|
|
111
|
+
|
|
100
112
|
export async function postRuntimeResume({
|
|
101
113
|
url = runtimeUrlFromEnv(),
|
|
102
114
|
token = runtimeToken(),
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
4
|
+
import { runRuntimeAgenticWorkflow } from './runner.js';
|
|
5
|
+
import { startRuntimeServer } from './server.js';
|
|
6
|
+
|
|
7
|
+
// Executable version of plan-0.11.5-hotfix-final.md §3 ("La recette — le seul
|
|
8
|
+
// critère de sortie qui compte"). Each test below is one row of that table,
|
|
9
|
+
// referenced by its recipe number. Correctifs 1-3 and 8 (runner.js's
|
|
10
|
+
// conversational short-circuit, plan sanitization, and no-replan-on-vague
|
|
11
|
+
// paths) are exercised with a mocked agent standing in for the LLM's
|
|
12
|
+
// per-turn decision, exactly like the existing runner.test.js unit tests --
|
|
13
|
+
// this file's job is traceability to the recipe, not a new test seam.
|
|
14
|
+
// Recipes 4-6 drive the real scheduler (runAgenticLoop / runRuntimeParallelPlan,
|
|
15
|
+
// imported and executed for real, never reimplemented). Recipe 7 drives the
|
|
16
|
+
// real control-lane HTTP endpoint. Per plan §4.1: this file is the CI gate --
|
|
17
|
+
// no release ships while it is red.
|
|
18
|
+
|
|
19
|
+
function baseSession(overrides = {}) {
|
|
20
|
+
return { activities: {}, headlessPlan: null, ...overrides };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function eventTypes(session) {
|
|
24
|
+
return (session.agentEvents ?? []).map((event) => event.type);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function waitFor(predicate, timeoutMs = 500) {
|
|
28
|
+
const deadline = Date.now() + timeoutMs;
|
|
29
|
+
while (Date.now() < deadline) {
|
|
30
|
+
if (predicate()) return;
|
|
31
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
32
|
+
}
|
|
33
|
+
assert.fail('condition was not met before timeout');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('Recipe #1 — "salut" gets a plain reply: no plan, no activity, no job, run done', async () => {
|
|
37
|
+
const session = baseSession({
|
|
38
|
+
llm: {
|
|
39
|
+
async completeWithTools() {
|
|
40
|
+
assert.fail('a plain greeting must never reach the evaluator or replanner');
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
const agent = {
|
|
45
|
+
async invoke() {
|
|
46
|
+
return { response: 'Bonjour ! Comment puis-je vous aider ?' };
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const result = await runRuntimeAgenticWorkflow(agent, session, 'salut', {
|
|
51
|
+
runId: 'recipe-1',
|
|
52
|
+
timeoutMs: 1000,
|
|
53
|
+
maxTurns: 1,
|
|
54
|
+
maxReplans: 1,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
assert.equal(result.ok, true);
|
|
58
|
+
assert.equal(session.headlessPlan, null);
|
|
59
|
+
assert.equal(Object.keys(session.activities).length, 0);
|
|
60
|
+
const types = eventTypes(session);
|
|
61
|
+
assert.ok(types.includes('run_done'));
|
|
62
|
+
assert.equal(types.includes('run_evaluated'), false);
|
|
63
|
+
assert.equal(types.includes('run_replanned'), false);
|
|
64
|
+
assert.equal(types.includes('tool_call_started'), false);
|
|
65
|
+
assert.equal(types.includes('plan_set'), false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('Recipe #2 — "quel est le profil actif ?" answers in conversation, never poses a plan', async () => {
|
|
69
|
+
const session = baseSession({
|
|
70
|
+
llm: {
|
|
71
|
+
async completeWithTools() {
|
|
72
|
+
assert.fail('a read-only config question must never reach the evaluator or replanner');
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
const agent = {
|
|
77
|
+
async invoke({ session: turnSession }) {
|
|
78
|
+
dispatchAgentEvent(turnSession, createAgentEvent('tool_call_started', {
|
|
79
|
+
origin: 'tool',
|
|
80
|
+
payload: { name: 'shell.status', args: '{}', summary: 'calling...' },
|
|
81
|
+
}));
|
|
82
|
+
dispatchAgentEvent(turnSession, createAgentEvent('tool_call_result', {
|
|
83
|
+
origin: 'tool',
|
|
84
|
+
payload: { name: 'shell.status', ok: true, result: 'profile: albert-openai (mistral-large)', summary: 'done' },
|
|
85
|
+
}));
|
|
86
|
+
return { response: 'Le profil actif est albert-openai (mistral-large).' };
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const result = await runRuntimeAgenticWorkflow(agent, session, 'quel est le profil actif ?', {
|
|
91
|
+
runId: 'recipe-2',
|
|
92
|
+
timeoutMs: 1000,
|
|
93
|
+
maxTurns: 1,
|
|
94
|
+
maxReplans: 1,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
assert.equal(result.ok, true);
|
|
98
|
+
assert.equal(session.headlessPlan, null);
|
|
99
|
+
const types = eventTypes(session);
|
|
100
|
+
assert.ok(types.includes('tool_call_started'));
|
|
101
|
+
assert.ok(types.includes('run_done'));
|
|
102
|
+
assert.equal(types.includes('plan_set'), false);
|
|
103
|
+
assert.equal(types.includes('run_evaluated'), false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('Recipe #3 — "où en est le dernier build ?" reads status, starts no new production run', async () => {
|
|
107
|
+
const session = baseSession({
|
|
108
|
+
llm: {
|
|
109
|
+
async completeWithTools() {
|
|
110
|
+
assert.fail('a status question must never reach the evaluator or replanner');
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
const agent = {
|
|
115
|
+
async invoke({ session: turnSession }) {
|
|
116
|
+
dispatchAgentEvent(turnSession, createAgentEvent('tool_call_started', {
|
|
117
|
+
origin: 'tool',
|
|
118
|
+
payload: { name: 'production.production_job_status', args: '{}', summary: 'calling...' },
|
|
119
|
+
}));
|
|
120
|
+
dispatchAgentEvent(turnSession, createAgentEvent('tool_call_result', {
|
|
121
|
+
origin: 'tool',
|
|
122
|
+
payload: { name: 'production.production_job_status', ok: true, result: 'last build: done', summary: 'done' },
|
|
123
|
+
}));
|
|
124
|
+
return { response: 'Le dernier build est terminé avec succès.' };
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const result = await runRuntimeAgenticWorkflow(agent, session, 'où en est le dernier build ?', {
|
|
129
|
+
runId: 'recipe-3',
|
|
130
|
+
timeoutMs: 1000,
|
|
131
|
+
maxTurns: 1,
|
|
132
|
+
maxReplans: 1,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
assert.equal(result.ok, true);
|
|
136
|
+
assert.equal(session.headlessPlan, null);
|
|
137
|
+
assert.equal(Object.keys(session.activities).length, 0);
|
|
138
|
+
const types = eventTypes(session);
|
|
139
|
+
assert.equal(types.includes('activity_upserted'), false);
|
|
140
|
+
assert.equal(types.includes('plan_set'), false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
function buildSingleTaskAgent({ taskId, description, finalResponse }) {
|
|
144
|
+
let turn = 0;
|
|
145
|
+
return {
|
|
146
|
+
async invoke({ session: turnSession }) {
|
|
147
|
+
turn += 1;
|
|
148
|
+
if (turn === 1) {
|
|
149
|
+
dispatchAgentEvent(turnSession, createAgentEvent('plan_set', {
|
|
150
|
+
origin: 'tool',
|
|
151
|
+
payload: { steps: [{ step: 1, id: taskId, description, status: 'pending', dependsOn: [] }] },
|
|
152
|
+
}));
|
|
153
|
+
return { response: `Je lance ${description}.` };
|
|
154
|
+
}
|
|
155
|
+
dispatchAgentEvent(turnSession, createAgentEvent('plan_step_updated', {
|
|
156
|
+
origin: 'tool',
|
|
157
|
+
payload: { step: 1, status: 'done' },
|
|
158
|
+
}));
|
|
159
|
+
return { response: finalResponse };
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
test('Recipe #4 — "lance le doctor": one-task plan, progress visible, summarized result', async () => {
|
|
165
|
+
const session = baseSession();
|
|
166
|
+
const agent = buildSingleTaskAgent({
|
|
167
|
+
taskId: 'doctor',
|
|
168
|
+
description: 'Diagnostic doctor',
|
|
169
|
+
finalResponse: 'Doctor terminé : aucun problème détecté.',
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const result = await runRuntimeAgenticWorkflow(agent, session, 'lance le doctor', {
|
|
173
|
+
runId: 'recipe-4',
|
|
174
|
+
timeoutMs: 1000,
|
|
175
|
+
maxTurns: 3,
|
|
176
|
+
maxReplans: 1,
|
|
177
|
+
evaluate: false,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
assert.equal(result.ok, true);
|
|
181
|
+
assert.equal(session.headlessPlan.length, 1);
|
|
182
|
+
assert.equal(session.headlessPlan[0].status, 'done');
|
|
183
|
+
const types = eventTypes(session);
|
|
184
|
+
assert.deepEqual(types.filter((type) => type === 'plan_set' || type === 'plan_step_updated'), ['plan_set', 'plan_step_updated']);
|
|
185
|
+
assert.ok(types.includes('run_done'));
|
|
186
|
+
assert.equal(session.agentProjection.conversation.at(-1).content, 'Doctor terminé : aucun problème détecté.');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('Recipe #5 — "construis le livrable X": plan posed, job runs, progress, done, summarized', async () => {
|
|
190
|
+
const session = baseSession();
|
|
191
|
+
const agent = buildSingleTaskAgent({
|
|
192
|
+
taskId: 'build-x',
|
|
193
|
+
description: 'Construire le livrable X',
|
|
194
|
+
finalResponse: 'Le livrable X est construit.',
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const result = await runRuntimeAgenticWorkflow(agent, session, 'construis le livrable X', {
|
|
198
|
+
runId: 'recipe-5',
|
|
199
|
+
timeoutMs: 1000,
|
|
200
|
+
maxTurns: 3,
|
|
201
|
+
maxReplans: 1,
|
|
202
|
+
evaluate: false,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
assert.equal(result.ok, true);
|
|
206
|
+
assert.equal(session.headlessPlan[0].status, 'done');
|
|
207
|
+
assert.ok(eventTypes(session).includes('run_done'));
|
|
208
|
+
assert.equal(session.agentProjection.conversation.at(-1).content, 'Le livrable X est construit.');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
function buildTwoParallelTasksAgent() {
|
|
212
|
+
const started = [];
|
|
213
|
+
const release = {};
|
|
214
|
+
let parentTurnDone = false;
|
|
215
|
+
return {
|
|
216
|
+
started,
|
|
217
|
+
release,
|
|
218
|
+
async invoke({ input, session: turnSession }) {
|
|
219
|
+
const taskMatch = input.match(/Task id: (\w+)/);
|
|
220
|
+
if (!taskMatch) {
|
|
221
|
+
if (!parentTurnDone) {
|
|
222
|
+
parentTurnDone = true;
|
|
223
|
+
dispatchAgentEvent(turnSession, createAgentEvent('plan_set', {
|
|
224
|
+
origin: 'tool',
|
|
225
|
+
payload: {
|
|
226
|
+
steps: [
|
|
227
|
+
{ step: 1, id: 'x', description: 'Construire X', status: 'pending', dependsOn: [] },
|
|
228
|
+
{ step: 2, id: 'y', description: 'Construire Y', status: 'pending', dependsOn: [] },
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
}));
|
|
232
|
+
return { response: 'Je construis X et Y en parallèle.' };
|
|
233
|
+
}
|
|
234
|
+
return { response: 'X et Y sont construits.' };
|
|
235
|
+
}
|
|
236
|
+
const id = taskMatch[1];
|
|
237
|
+
started.push(id);
|
|
238
|
+
await new Promise((resolve) => { release[id] = resolve; });
|
|
239
|
+
return { response: `${id} construit.` };
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
test('Recipe #6 — "construis X et Y": two tasks run in parallel, converge, done', async () => {
|
|
245
|
+
const session = baseSession();
|
|
246
|
+
const agent = buildTwoParallelTasksAgent();
|
|
247
|
+
|
|
248
|
+
const running = runRuntimeAgenticWorkflow(agent, session, 'construis X et Y', {
|
|
249
|
+
runId: 'recipe-6',
|
|
250
|
+
timeoutMs: 2000,
|
|
251
|
+
maxTurns: 3,
|
|
252
|
+
maxReplans: 1,
|
|
253
|
+
evaluate: false,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await waitFor(() => agent.started.includes('x') && agent.started.includes('y'));
|
|
257
|
+
assert.deepEqual(
|
|
258
|
+
session.headlessPlan.filter((step) => ['x', 'y'].includes(step.id)).map((step) => step.status),
|
|
259
|
+
['running', 'running'],
|
|
260
|
+
);
|
|
261
|
+
agent.release.x();
|
|
262
|
+
agent.release.y();
|
|
263
|
+
|
|
264
|
+
const result = await running;
|
|
265
|
+
|
|
266
|
+
assert.equal(result.ok, true);
|
|
267
|
+
assert.deepEqual(session.headlessPlan.map((step) => step.status), ['done', 'done']);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('Recipe #7 — "où en es-tu ?" during an active run: status in conversation, run continues, no new run', async (t) => {
|
|
271
|
+
const session = { workspace: 'juno', controlQueue: [] };
|
|
272
|
+
let runCount = 0;
|
|
273
|
+
let handle;
|
|
274
|
+
try {
|
|
275
|
+
handle = await startRuntimeServer({
|
|
276
|
+
host: '127.0.0.1',
|
|
277
|
+
port: 0,
|
|
278
|
+
store: {
|
|
279
|
+
dbPath: ':memory:',
|
|
280
|
+
getState: () => ({
|
|
281
|
+
status: 'running',
|
|
282
|
+
plan: [{ step: 1, description: 'Construire le livrable X', status: 'running' }],
|
|
283
|
+
queue: [],
|
|
284
|
+
approvals: [],
|
|
285
|
+
summary: null,
|
|
286
|
+
}),
|
|
287
|
+
listEvents: () => [],
|
|
288
|
+
},
|
|
289
|
+
getContext: async () => ({
|
|
290
|
+
workspace: 'juno',
|
|
291
|
+
session,
|
|
292
|
+
running: true,
|
|
293
|
+
currentAbortController: new AbortController(),
|
|
294
|
+
}),
|
|
295
|
+
run: async () => { runCount += 1; },
|
|
296
|
+
});
|
|
297
|
+
} catch (err) {
|
|
298
|
+
if (err?.code === 'EPERM') {
|
|
299
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
throw err;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
|
|
307
|
+
method: 'POST',
|
|
308
|
+
headers: { 'Content-Type': 'application/json' },
|
|
309
|
+
body: JSON.stringify({ action: 'message', input: 'où en es-tu ?' }),
|
|
310
|
+
});
|
|
311
|
+
assert.equal(response.status, 200);
|
|
312
|
+
const body = await response.json();
|
|
313
|
+
assert.equal(body.kind, 'observe');
|
|
314
|
+
assert.match(body.explanation, /Construire le livrable X/);
|
|
315
|
+
assert.equal(session.controlQueue.length, 0);
|
|
316
|
+
assert.equal(runCount, 0);
|
|
317
|
+
} finally {
|
|
318
|
+
await handle.close();
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('Recipe #8 — vague request gets clarification, never a job, never boilerplate', async () => {
|
|
323
|
+
const session = baseSession({
|
|
324
|
+
headlessPlan: [{ step: 1, id: 'task', description: 'Task', status: 'done' }],
|
|
325
|
+
llm: {
|
|
326
|
+
async completeWithTools({ system }) {
|
|
327
|
+
assert.match(system, /strict evaluator/);
|
|
328
|
+
return { content: '{"ok":false,"reason":"demande vague / objectif indefini","suggestedAction":"clarifier l objectif"}' };
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
const agent = {
|
|
333
|
+
async invoke({ session: turnSession }) {
|
|
334
|
+
if (turnSession.headlessPlan === null) {
|
|
335
|
+
dispatchAgentEvent(turnSession, createAgentEvent('plan_set', {
|
|
336
|
+
origin: 'tool',
|
|
337
|
+
payload: { steps: [{ step: 1, id: 'task', description: 'Task', status: 'done' }] },
|
|
338
|
+
}));
|
|
339
|
+
}
|
|
340
|
+
return { response: 'Terminé.' };
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const result = await runRuntimeAgenticWorkflow(agent, session, 'dis-moi une bêtise puis stop', {
|
|
345
|
+
runId: 'recipe-8',
|
|
346
|
+
timeoutMs: 1000,
|
|
347
|
+
maxTurns: 1,
|
|
348
|
+
maxReplans: 1,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
assert.equal(result.ok, true);
|
|
352
|
+
assert.equal(result.clarified, true);
|
|
353
|
+
const lastMessage = session.agentProjection.conversation.at(-1).content;
|
|
354
|
+
assert.ok(lastMessage.includes('clarifier'));
|
|
355
|
+
assert.doesNotMatch(lastMessage, /Donna is active|Plan is stalled/i);
|
|
356
|
+
const types = eventTypes(session);
|
|
357
|
+
assert.equal(types.includes('run_replanned'), false);
|
|
358
|
+
assert.equal(types.includes('tool_call_started'), false);
|
|
359
|
+
});
|
package/src/runtime/lifecycle.js
CHANGED
|
@@ -33,7 +33,7 @@ export async function assertRuntimeNode(executable = runtimeNodeExecutable()) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export async function ensureRuntime({
|
|
36
|
-
host = process.env.WIKI_MANAGER_RUNTIME_HOST ?? '
|
|
36
|
+
host = process.env.WIKI_MANAGER_RUNTIME_HOST ?? '127.0.0.1',
|
|
37
37
|
port = Number(process.env.WIKI_MANAGER_RUNTIME_PORT ?? 7788),
|
|
38
38
|
stateDir = process.env.WIKI_MANAGER_STATE_DIR ?? defaultRuntimeStateDir(),
|
|
39
39
|
url = process.env.WIKI_MANAGER_RUNTIME_URL ?? `http://127.0.0.1:${port}`,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { runRuntimeParallelPlan } from './runner.js';
|
|
4
|
+
|
|
5
|
+
// Regression guard for plan-0.11.4-stabilisation.md §3: build de 2 templates
|
|
6
|
+
// en séquentiel -> T1, en multi-agent -> T2, assert T2 < 0.65*T1. Exercises
|
|
7
|
+
// the scheduler (runRuntimeParallelPlan) directly with mocked, fixed-latency
|
|
8
|
+
// tasks — it proves the scheduler itself parallelizes ready tasks, not that a
|
|
9
|
+
// real llm-wiki build/provider round-trip shows the same margin.
|
|
10
|
+
const SIMULATED_BUILD_LATENCY_MS = 150;
|
|
11
|
+
const MAX_PARALLEL_TO_SEQUENTIAL_RATIO = 0.65;
|
|
12
|
+
|
|
13
|
+
function buildTwoTemplatePlan() {
|
|
14
|
+
return [
|
|
15
|
+
{ step: 1, id: 'template-a', description: 'Build template A', status: 'pending', dependsOn: [] },
|
|
16
|
+
{ step: 2, id: 'template-b', description: 'Build template B', status: 'pending', dependsOn: [] },
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function latencyAgent() {
|
|
21
|
+
return {
|
|
22
|
+
async invoke() {
|
|
23
|
+
await new Promise((resolve) => setTimeout(resolve, SIMULATED_BUILD_LATENCY_MS));
|
|
24
|
+
return { response: 'done' };
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function timedRun(concurrency, runId) {
|
|
30
|
+
const session = { activities: {}, headlessPlan: buildTwoTemplatePlan() };
|
|
31
|
+
const startedAt = Date.now();
|
|
32
|
+
const result = await runRuntimeParallelPlan(latencyAgent(), session, 'Build 2 templates', {
|
|
33
|
+
runId,
|
|
34
|
+
timeoutMs: 10_000,
|
|
35
|
+
maxTurns: 1,
|
|
36
|
+
concurrency,
|
|
37
|
+
});
|
|
38
|
+
return { result, durationMs: Date.now() - startedAt };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test('multi-agent scheduler beats sequential on 2 independent build tasks by the required margin (plan 0.11.4 §3 guard)', async () => {
|
|
42
|
+
const { result: sequentialResult, durationMs: sequentialDurationMs } = await timedRun(1, 'e2e-accel-sequential');
|
|
43
|
+
const { result: parallelResult, durationMs: parallelDurationMs } = await timedRun(2, 'e2e-accel-parallel');
|
|
44
|
+
|
|
45
|
+
assert.equal(sequentialResult.ok, true);
|
|
46
|
+
assert.equal(parallelResult.ok, true);
|
|
47
|
+
const thresholdMs = MAX_PARALLEL_TO_SEQUENTIAL_RATIO * sequentialDurationMs;
|
|
48
|
+
assert.ok(
|
|
49
|
+
parallelDurationMs < thresholdMs,
|
|
50
|
+
`expected multi-agent duration (${parallelDurationMs}ms) under ${MAX_PARALLEL_TO_SEQUENTIAL_RATIO * 100}% of sequential duration (${sequentialDurationMs}ms, threshold ${thresholdMs}ms)`,
|
|
51
|
+
);
|
|
52
|
+
});
|
package/src/runtime/runner.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
|
2
2
|
import { activityKey, sessionActivities, terminalFailures } from '../core/activity.js';
|
|
3
3
|
import { runAgenticLoop, throwIfAborted } from '../core/agentLoop.js';
|
|
4
4
|
import { formatPlanStatus } from '../core/plan.js';
|
|
5
|
-
import { formatReadyTaskPrompt, readyPlanTasks } from '../core/planPatch.js';
|
|
5
|
+
import { formatReadyTaskPrompt, readyPlanTasks, sanitizePlanForExecution } from '../core/planPatch.js';
|
|
6
6
|
import { emitRuntimeLog, pollActivitiesOnce } from './supervisor.js';
|
|
7
7
|
|
|
8
8
|
const DEFAULT_MAX_REPLANS = 2;
|
|
@@ -92,6 +92,7 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
|
|
|
92
92
|
let replansLeft = Math.max(0, Math.floor(Number(maxReplans) || 0));
|
|
93
93
|
|
|
94
94
|
while (true) {
|
|
95
|
+
sanitizeSessionPlanForExecution(session, runId);
|
|
95
96
|
const result = shouldUseParallelScheduler(session.headlessPlan)
|
|
96
97
|
? await runRuntimeParallelPlan(agent, session, input, {
|
|
97
98
|
signal,
|
|
@@ -134,7 +135,9 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
|
|
|
134
135
|
return { ok: false, result };
|
|
135
136
|
}
|
|
136
137
|
|
|
137
|
-
const evaluation =
|
|
138
|
+
const evaluation = session.headlessPlan
|
|
139
|
+
? await evaluateRuntimeRun(session, input, { runId, signal, evaluate })
|
|
140
|
+
: null;
|
|
138
141
|
if (evaluation) {
|
|
139
142
|
dispatchAgentEvent(session, createAgentEvent('run_evaluated', {
|
|
140
143
|
origin: 'runtime',
|
|
@@ -147,6 +150,19 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
|
|
|
147
150
|
},
|
|
148
151
|
}));
|
|
149
152
|
if (!evaluation.ok) {
|
|
153
|
+
if (isUndefinedObjectiveEvaluation(evaluation)) {
|
|
154
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_message', {
|
|
155
|
+
origin: 'runtime',
|
|
156
|
+
runId,
|
|
157
|
+
payload: { content: clarificationMessageForEvaluation(evaluation) },
|
|
158
|
+
}));
|
|
159
|
+
dispatchAgentEvent(session, createAgentEvent('run_done', {
|
|
160
|
+
origin: 'runtime',
|
|
161
|
+
runId,
|
|
162
|
+
payload: { runId },
|
|
163
|
+
}));
|
|
164
|
+
return { ok: true, evaluation, clarified: true };
|
|
165
|
+
}
|
|
150
166
|
if (replansLeft > 0) {
|
|
151
167
|
const trigger = {
|
|
152
168
|
kind: 'evaluation',
|
|
@@ -199,11 +215,17 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
199
215
|
const locks = new Set();
|
|
200
216
|
const failures = [];
|
|
201
217
|
const previousIdentity = session._currentRunIdentity;
|
|
218
|
+
const previousPlanUpdate = session._onPlanUpdate;
|
|
202
219
|
session._currentRunIdentity = {
|
|
203
220
|
...(previousIdentity ?? {}),
|
|
204
221
|
runId,
|
|
205
222
|
workspace: session.workspace ?? previousIdentity?.workspace ?? null,
|
|
206
223
|
};
|
|
224
|
+
session._onPlanUpdate = () => {
|
|
225
|
+
previousPlanUpdate?.();
|
|
226
|
+
abortCancelledActiveTasks(session, active);
|
|
227
|
+
};
|
|
228
|
+
sanitizeSessionPlanForExecution(session, runId);
|
|
207
229
|
ensurePlanProjection(session, runId);
|
|
208
230
|
emitRuntimeLog(session, `scheduler: parallel plan enabled (concurrency ${limit})`);
|
|
209
231
|
|
|
@@ -238,11 +260,15 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
238
260
|
}
|
|
239
261
|
|
|
240
262
|
const settled = await Promise.race([...active.values()].map((entry) => entry.promise));
|
|
263
|
+
const activeEntry = active.get(settled.taskId);
|
|
241
264
|
active.delete(settled.taskId);
|
|
265
|
+
activeEntry?.cleanup?.();
|
|
242
266
|
for (const lock of settled.locks) locks.delete(lock);
|
|
243
267
|
if (settled.cancelled) {
|
|
244
|
-
|
|
245
|
-
|
|
268
|
+
if (signal?.aborted) {
|
|
269
|
+
await drainActive(active, locks);
|
|
270
|
+
throwIfAborted(signal, 'Runtime run cancelled.');
|
|
271
|
+
}
|
|
246
272
|
continue;
|
|
247
273
|
}
|
|
248
274
|
if (!settled.ok) failures.push(settled);
|
|
@@ -250,16 +276,43 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
250
276
|
} finally {
|
|
251
277
|
if (previousIdentity) session._currentRunIdentity = previousIdentity;
|
|
252
278
|
else delete session._currentRunIdentity;
|
|
279
|
+
if (previousPlanUpdate) session._onPlanUpdate = previousPlanUpdate;
|
|
280
|
+
else delete session._onPlanUpdate;
|
|
253
281
|
}
|
|
254
282
|
}
|
|
255
283
|
|
|
284
|
+
function sanitizeSessionPlanForExecution(session, runId = null) {
|
|
285
|
+
if (!session.headlessPlan) return;
|
|
286
|
+
const sanitized = sanitizePlanForExecution(session.headlessPlan);
|
|
287
|
+
if (sanitized.warnings.length === 0) return;
|
|
288
|
+
session.headlessPlan = sanitized.plan;
|
|
289
|
+
dispatchAgentEvent(session, createAgentEvent('runtime_log', {
|
|
290
|
+
origin: 'runtime',
|
|
291
|
+
runId,
|
|
292
|
+
payload: {
|
|
293
|
+
message: `plan warning: ${sanitized.warnings.join('; ')}`,
|
|
294
|
+
},
|
|
295
|
+
}));
|
|
296
|
+
}
|
|
297
|
+
|
|
256
298
|
async function drainActive(active, locks) {
|
|
257
299
|
if (active.size === 0) return;
|
|
258
|
-
|
|
300
|
+
const entries = [...active.values()];
|
|
301
|
+
await Promise.all(entries.map((entry) => entry.promise));
|
|
302
|
+
for (const entry of entries) entry.cleanup?.();
|
|
259
303
|
active.clear();
|
|
260
304
|
locks.clear();
|
|
261
305
|
}
|
|
262
306
|
|
|
307
|
+
function abortCancelledActiveTasks(session, active) {
|
|
308
|
+
for (const [taskId, entry] of active.entries()) {
|
|
309
|
+
const current = (session.headlessPlan ?? []).find((step) => planTaskId(step) === taskId);
|
|
310
|
+
if (current?.status === 'cancelled' && !entry.signal?.aborted) {
|
|
311
|
+
entry.controller?.abort();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
263
316
|
// Scope the evaluator/replanner's view of "completed" activities to the
|
|
264
317
|
// tasks that actually failed, instead of every activity the whole run has
|
|
265
318
|
// ever recorded — a long-running sibling's stale (or unrelated, already
|
|
@@ -323,20 +376,44 @@ function startReadyTasks(agent, session, input, {
|
|
|
323
376
|
payload: { taskId, status: 'running' },
|
|
324
377
|
}));
|
|
325
378
|
emitRuntimeLog(session, `scheduler: starting task ${taskId}`);
|
|
379
|
+
const taskAbort = createTaskAbortSignal(signal);
|
|
326
380
|
const promise = runParallelTask(agent, session, input, task, {
|
|
327
|
-
signal,
|
|
381
|
+
signal: taskAbort.signal,
|
|
328
382
|
timeoutMs,
|
|
329
383
|
maxTurns,
|
|
330
384
|
runId,
|
|
331
385
|
pollBusy,
|
|
332
386
|
locks: taskLocks,
|
|
333
387
|
});
|
|
334
|
-
active.set(taskId, {
|
|
388
|
+
active.set(taskId, {
|
|
389
|
+
taskId,
|
|
390
|
+
locks: taskLocks,
|
|
391
|
+
promise,
|
|
392
|
+
controller: taskAbort.controller,
|
|
393
|
+
signal: taskAbort.signal,
|
|
394
|
+
cleanup: taskAbort.cleanup,
|
|
395
|
+
});
|
|
335
396
|
started += 1;
|
|
336
397
|
}
|
|
337
398
|
return started;
|
|
338
399
|
}
|
|
339
400
|
|
|
401
|
+
function createTaskAbortSignal(parentSignal) {
|
|
402
|
+
const controller = new AbortController();
|
|
403
|
+
if (!parentSignal) return { controller, signal: controller.signal, cleanup: null };
|
|
404
|
+
if (parentSignal.aborted) {
|
|
405
|
+
controller.abort();
|
|
406
|
+
return { controller, signal: controller.signal, cleanup: null };
|
|
407
|
+
}
|
|
408
|
+
const abort = () => controller.abort();
|
|
409
|
+
parentSignal.addEventListener('abort', abort, { once: true });
|
|
410
|
+
return {
|
|
411
|
+
controller,
|
|
412
|
+
signal: controller.signal,
|
|
413
|
+
cleanup: () => parentSignal.removeEventListener('abort', abort),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
340
417
|
async function runParallelTask(agent, parentSession, input, task, {
|
|
341
418
|
signal,
|
|
342
419
|
timeoutMs,
|
|
@@ -364,6 +441,9 @@ async function runParallelTask(agent, parentSession, input, task, {
|
|
|
364
441
|
}));
|
|
365
442
|
return { ok: false, taskId, locks, result };
|
|
366
443
|
}
|
|
444
|
+
if ((parentSession.headlessPlan ?? []).find((step) => planTaskId(step) === taskId)?.status === 'cancelled') {
|
|
445
|
+
return { ok: false, taskId, locks, cancelled: true };
|
|
446
|
+
}
|
|
367
447
|
dispatchAgentEvent(parentSession, createAgentEvent('plan_step_updated', {
|
|
368
448
|
origin: 'runtime',
|
|
369
449
|
runId,
|
|
@@ -521,7 +601,9 @@ export async function finishRuntimeRun(session, input, {
|
|
|
521
601
|
signal = null,
|
|
522
602
|
evaluate = true,
|
|
523
603
|
} = {}) {
|
|
524
|
-
const evaluation =
|
|
604
|
+
const evaluation = session.headlessPlan
|
|
605
|
+
? await evaluateRuntimeRun(session, input, { runId, signal, evaluate })
|
|
606
|
+
: null;
|
|
525
607
|
if (evaluation) {
|
|
526
608
|
dispatchAgentEvent(session, createAgentEvent('run_evaluated', {
|
|
527
609
|
origin: 'runtime',
|
|
@@ -534,6 +616,19 @@ export async function finishRuntimeRun(session, input, {
|
|
|
534
616
|
},
|
|
535
617
|
}));
|
|
536
618
|
if (!evaluation.ok) {
|
|
619
|
+
if (isUndefinedObjectiveEvaluation(evaluation)) {
|
|
620
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_message', {
|
|
621
|
+
origin: 'runtime',
|
|
622
|
+
runId,
|
|
623
|
+
payload: { content: clarificationMessageForEvaluation(evaluation) },
|
|
624
|
+
}));
|
|
625
|
+
dispatchAgentEvent(session, createAgentEvent('run_done', {
|
|
626
|
+
origin: 'runtime',
|
|
627
|
+
runId,
|
|
628
|
+
payload: { runId },
|
|
629
|
+
}));
|
|
630
|
+
return { ok: true, evaluation, clarified: true };
|
|
631
|
+
}
|
|
537
632
|
dispatchAgentEvent(session, createAgentEvent('run_error', {
|
|
538
633
|
origin: 'runtime',
|
|
539
634
|
runId,
|
|
@@ -623,6 +718,21 @@ function normalizeEvaluation(value) {
|
|
|
623
718
|
};
|
|
624
719
|
}
|
|
625
720
|
|
|
721
|
+
function isUndefinedObjectiveEvaluation(evaluation) {
|
|
722
|
+
const text = `${evaluation?.reason ?? ''} ${evaluation?.suggestedAction ?? ''}`
|
|
723
|
+
.toLowerCase()
|
|
724
|
+
.normalize('NFD')
|
|
725
|
+
.replace(/[\u0300-\u036f]/g, '');
|
|
726
|
+
return /\b(vague|undefined|indefini|unclear|clarif|ambiguous|missing objective|no objective)\b/.test(text);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function clarificationMessageForEvaluation(evaluation) {
|
|
730
|
+
const reason = String(evaluation?.reason ?? '').trim();
|
|
731
|
+
return reason
|
|
732
|
+
? `Je dois clarifier la demande avant d'agir : ${reason}`
|
|
733
|
+
: "Je dois clarifier la demande avant d'agir.";
|
|
734
|
+
}
|
|
735
|
+
|
|
626
736
|
function fallbackEvaluation(reason) {
|
|
627
737
|
return {
|
|
628
738
|
ok: true,
|
|
@@ -674,6 +784,10 @@ export async function replanRuntimeRun(session, input, trigger, {
|
|
|
674
784
|
steps: mergedSteps,
|
|
675
785
|
},
|
|
676
786
|
}));
|
|
787
|
+
if (hasMutatingReplanStep(steps)) {
|
|
788
|
+
session._runApprovalRequired = true;
|
|
789
|
+
session._runApprovalResolved = false;
|
|
790
|
+
}
|
|
677
791
|
return { ok: true, steps };
|
|
678
792
|
} catch (err) {
|
|
679
793
|
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
@@ -762,6 +876,10 @@ function normalizeReplan(steps) {
|
|
|
762
876
|
.slice(0, 12);
|
|
763
877
|
}
|
|
764
878
|
|
|
879
|
+
function hasMutatingReplanStep(steps) {
|
|
880
|
+
return steps.some((step) => /\b(build|copy|ingest|import|export|polish|pipeline|write|create|delete|update|send|deploy|publish|generate|construire|copier|importer|exporter|publier|envoyer|supprimer|modifier|creer|générer|generer)\b/i.test(step));
|
|
881
|
+
}
|
|
882
|
+
|
|
765
883
|
function mergeReplanWithCompleted(plan, steps) {
|
|
766
884
|
const completed = (plan ?? [])
|
|
767
885
|
.filter((step) => step.status === 'done')
|