@dotdrelle/wiki-manager 0.15.94 → 0.15.96
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 +39 -28
- package/package.json +2 -2
- package/src/agent/graph.js +36 -18
- package/src/agent/graph.test.js +4 -1
- package/src/cli/wiki-manager.js +35 -17
- package/src/core/agentEvents.js +1 -3
- package/src/core/buildInfo.json +2 -2
- package/src/core/googleGrants.js +0 -3
- package/src/core/json.js +9 -0
- package/src/core/mcp.js +1 -1
- package/src/core/plan.js +0 -4
- package/src/core/progressNotes.js +0 -4
- package/src/core/skillChainView.js +3 -1
- package/src/core/toolLoop.js +56 -2
- package/src/core/toolLoop.test.js +35 -4
- package/src/orchestrator/agentRegistry.js +1 -3
- package/src/orchestrator/dependencyResolver.js +0 -3
- package/src/orchestrator/planValidator.js +1 -3
- package/src/orchestrator/providers/runtimeProvider.js +0 -14
- package/src/orchestrator/taskStatuses.js +8 -0
- package/src/runtime/client.js +0 -16
- package/src/runtime/controlDrain.js +6 -3
- package/src/runtime/deltaCoalescer.js +53 -0
- package/src/runtime/deltaCoalescer.test.js +56 -0
- package/src/runtime/loginPage.js +0 -3
- package/src/runtime/loginSession.js +1 -4
- package/src/runtime/runner.js +17 -3
- package/src/runtime/runner.test.js +32 -1
- package/src/runtime/server.js +34 -0
- package/src/runtime/server.test.js +87 -0
- package/src/runtime/skillRun.js +1 -1
- package/src/runtime/skillRun.test.js +3 -0
- package/src/shell/repl.js +7 -3
- package/src/orchestrator/.fuse_hidden0000001c00000001 +0 -316
|
@@ -28,6 +28,14 @@ export const PENDING_STATUSES_LIST = Object.freeze(['pending', 'pending_approval
|
|
|
28
28
|
/** En cours : un agent y travaille en ce moment. */
|
|
29
29
|
export const ACTIVE_STATUSES = Object.freeze(['running', 'in_progress', 'started', 'starting']);
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Terminal, réduit à ses quatre formes canoniques (les alias sont normalisés
|
|
33
|
+
* avant comparaison). Les modules qui recopiaient `['done','failed',
|
|
34
|
+
* 'cancelled','skipped']` dans un `Set` importent celui-ci à la place.
|
|
35
|
+
*/
|
|
36
|
+
export const TERMINAL_STATUSES = Object.freeze(['done', 'failed', 'cancelled', 'skipped']);
|
|
37
|
+
export const TERMINAL_STATUS_SET = new Set(TERMINAL_STATUSES);
|
|
38
|
+
|
|
31
39
|
const ALIASES = new Map([
|
|
32
40
|
...SUCCESS_STATUSES.map((status) => [status, 'done']),
|
|
33
41
|
...FAILURE_STATUSES.map((status) => [status, 'failed']),
|
package/src/runtime/client.js
CHANGED
|
@@ -238,19 +238,6 @@ export async function postRuntimeShutdown({
|
|
|
238
238
|
return response.json();
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
export async function postRuntimeResume({
|
|
242
|
-
url = runtimeUrlFromEnv(),
|
|
243
|
-
token = runtimeToken(),
|
|
244
|
-
workspace = null,
|
|
245
|
-
} = {}) {
|
|
246
|
-
const response = await fetch(runtimeEndpoint(url, '/resume', workspace), {
|
|
247
|
-
method: 'POST',
|
|
248
|
-
headers: runtimeHeaders(token),
|
|
249
|
-
});
|
|
250
|
-
if (!response.ok) throw new Error(`Runtime resume failed: HTTP ${response.status}`);
|
|
251
|
-
return response.json();
|
|
252
|
-
}
|
|
253
|
-
|
|
254
241
|
export async function postRuntimeApprove({
|
|
255
242
|
url = runtimeUrlFromEnv(),
|
|
256
243
|
token = runtimeToken(),
|
|
@@ -331,6 +318,3 @@ export async function* streamRuntimeEvents({
|
|
|
331
318
|
}
|
|
332
319
|
}
|
|
333
320
|
|
|
334
|
-
export function runtimeFetchOptions(token = runtimeToken()) {
|
|
335
|
-
return { headers: runtimeHeaders(token) };
|
|
336
|
-
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @statuses-vocabulary
|
|
3
3
|
* Control items are queued run requests, not orchestrator tasks. Their
|
|
4
|
-
* terminal vocabulary
|
|
5
|
-
*
|
|
4
|
+
* terminal vocabulary is the same four canonical statuses as a task's
|
|
5
|
+
* (`TERMINAL_STATUS_SET`), including chain-level `skipped`; the projection is
|
|
6
|
+
* still done by core/agentEvents.js, this only shares the vocabulary.
|
|
6
7
|
*/
|
|
7
|
-
|
|
8
|
+
import { TERMINAL_STATUS_SET } from '../orchestrator/taskStatuses.js';
|
|
9
|
+
|
|
10
|
+
const TERMINAL = TERMINAL_STATUS_SET;
|
|
8
11
|
|
|
9
12
|
export function reconcileControlQueue(context, { startItem, skipItem } = {}) {
|
|
10
13
|
if (!context?.session || context.running || context.controlDrainActive) return false;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Coalesces streaming text fragments before they are persisted and pushed.
|
|
3
|
+
*
|
|
4
|
+
* The runtime persisted one SQLite row (plus one SSE write) per streamed token.
|
|
5
|
+
* When a tool pulled a lot of content into the thread, the answer narrating it
|
|
6
|
+
* grew long and those synchronous writes stalled the event loop: both chats
|
|
7
|
+
* (serve and ShellUI) froze while the answer was still being produced. Buffering
|
|
8
|
+
* the fragments and flushing them at a bounded rate turns thousands of writes
|
|
9
|
+
* into a handful without changing what the reader sees.
|
|
10
|
+
*
|
|
11
|
+
* Ordering matters: `flush()` must be called before any non-delta event, or a
|
|
12
|
+
* final message could overtake the fragments that precede it. `reset()` drops
|
|
13
|
+
* buffered text that turned out to be provisional narration (a tool-call
|
|
14
|
+
* iteration), matching `assistant_delta_reset`.
|
|
15
|
+
*/
|
|
16
|
+
export function createDeltaCoalescer(flush, { intervalMs = 80 } = {}) {
|
|
17
|
+
if (typeof flush !== 'function') throw new TypeError('createDeltaCoalescer requires a flush callback.');
|
|
18
|
+
const delay = Math.max(1, Math.floor(intervalMs) || 80);
|
|
19
|
+
let buffer = '';
|
|
20
|
+
let timer = null;
|
|
21
|
+
const emit = () => {
|
|
22
|
+
if (timer) {
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
timer = null;
|
|
25
|
+
}
|
|
26
|
+
if (!buffer) return;
|
|
27
|
+
const delta = buffer;
|
|
28
|
+
buffer = '';
|
|
29
|
+
flush(delta);
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
push(delta) {
|
|
33
|
+
const text = String(delta ?? '');
|
|
34
|
+
if (!text) return;
|
|
35
|
+
buffer += text;
|
|
36
|
+
if (!timer) timer = setTimeout(emit, delay);
|
|
37
|
+
},
|
|
38
|
+
flush: emit,
|
|
39
|
+
reset() {
|
|
40
|
+
buffer = '';
|
|
41
|
+
if (timer) {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
timer = null;
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
dispose() {
|
|
47
|
+
if (timer) {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
timer = null;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { createDeltaCoalescer } from './deltaCoalescer.js';
|
|
4
|
+
|
|
5
|
+
const tick = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
6
|
+
|
|
7
|
+
test('coalesces fragments pushed within the interval into one flush', async () => {
|
|
8
|
+
const flushed = [];
|
|
9
|
+
const coalescer = createDeltaCoalescer((delta) => flushed.push(delta), { intervalMs: 20 });
|
|
10
|
+
coalescer.push('Bon');
|
|
11
|
+
coalescer.push('jour ');
|
|
12
|
+
coalescer.push('le monde.');
|
|
13
|
+
assert.deepEqual(flushed, [], 'rien ne doit partir avant l\'intervalle');
|
|
14
|
+
await tick(35);
|
|
15
|
+
assert.deepEqual(flushed, ['Bonjour le monde.']);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('flush() emits the buffered fragments immediately, in order', () => {
|
|
19
|
+
const flushed = [];
|
|
20
|
+
const coalescer = createDeltaCoalescer((delta) => flushed.push(delta), { intervalMs: 1000 });
|
|
21
|
+
coalescer.push('a');
|
|
22
|
+
coalescer.push('b');
|
|
23
|
+
coalescer.flush();
|
|
24
|
+
assert.deepEqual(flushed, ['ab']);
|
|
25
|
+
// Nothing left to flush twice.
|
|
26
|
+
coalescer.flush();
|
|
27
|
+
assert.deepEqual(flushed, ['ab']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('reset() drops buffered provisional narration', () => {
|
|
31
|
+
const flushed = [];
|
|
32
|
+
const coalescer = createDeltaCoalescer((delta) => flushed.push(delta), { intervalMs: 1000 });
|
|
33
|
+
coalescer.push('je vais regarder…');
|
|
34
|
+
coalescer.reset();
|
|
35
|
+
coalescer.flush();
|
|
36
|
+
assert.deepEqual(flushed, []);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('a single flush covers fragments that arrive after a first flush', async () => {
|
|
40
|
+
const flushed = [];
|
|
41
|
+
const coalescer = createDeltaCoalescer((delta) => flushed.push(delta), { intervalMs: 20 });
|
|
42
|
+
coalescer.push('un ');
|
|
43
|
+
await tick(30);
|
|
44
|
+
coalescer.push('deux');
|
|
45
|
+
await tick(30);
|
|
46
|
+
assert.deepEqual(flushed, ['un ', 'deux']);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('dispose() stops the pending timer without emitting', async () => {
|
|
50
|
+
const flushed = [];
|
|
51
|
+
const coalescer = createDeltaCoalescer((delta) => flushed.push(delta), { intervalMs: 20 });
|
|
52
|
+
coalescer.push('perdu');
|
|
53
|
+
coalescer.dispose();
|
|
54
|
+
await tick(40);
|
|
55
|
+
assert.deepEqual(flushed, []);
|
|
56
|
+
});
|
package/src/runtime/loginPage.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chmodSync,
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join, resolve } from 'node:path';
|
|
3
3
|
import { randomBytes } from 'node:crypto';
|
|
4
4
|
import { defaultRuntimeStateDir } from '../core/env.js';
|
|
@@ -221,6 +221,3 @@ export function pruneLoginAttempts() {
|
|
|
221
221
|
}
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
export function sessionExists() {
|
|
225
|
-
return existsSync(sessionPath());
|
|
226
|
-
}
|
package/src/runtime/runner.js
CHANGED
|
@@ -303,12 +303,13 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
|
|
|
303
303
|
// instead of the client streaming a per-job line for every task. Uses the
|
|
304
304
|
// workspace LLM to phrase it, degrading to a plain templated fact line if the
|
|
305
305
|
// LLM is unavailable or errors — the run must never block on this summary.
|
|
306
|
-
async function announceRunOutcome(session, { runId, ok, signal = null } = {}) {
|
|
306
|
+
export async function announceRunOutcome(session, { runId, ok, signal = null } = {}) {
|
|
307
307
|
const plan = Array.isArray(session.headlessPlan) ? session.headlessPlan : [];
|
|
308
308
|
if (plan.length === 0) return;
|
|
309
309
|
let failed = 0;
|
|
310
310
|
let cancelled = 0;
|
|
311
311
|
let completed = 0;
|
|
312
|
+
let pending = 0;
|
|
312
313
|
let firstError = null;
|
|
313
314
|
for (const step of plan) {
|
|
314
315
|
const status = String(step?.status ?? '').toLowerCase();
|
|
@@ -322,12 +323,24 @@ async function announceRunOutcome(session, { runId, ok, signal = null } = {}) {
|
|
|
322
323
|
cancelled += 1;
|
|
323
324
|
} else if (isSuccessful(status)) {
|
|
324
325
|
completed += 1;
|
|
326
|
+
} else if (isPending(status) || !isTerminal(status)) {
|
|
327
|
+
// pending_approval, waiting_approval, running, unknown: the work has NOT
|
|
328
|
+
// happened. Counting these as neither success nor failure is what made a
|
|
329
|
+
// run that had only *planned* its mutations announce a success (LLM
|
|
330
|
+
// rephrasing "0/N réussie" into "le livrable a bien été publié") before
|
|
331
|
+
// the approval that would actually run it.
|
|
332
|
+
pending += 1;
|
|
325
333
|
}
|
|
326
334
|
}
|
|
327
335
|
const total = plan.length;
|
|
328
|
-
const
|
|
336
|
+
const finished = ok && failed === 0 && cancelled === 0 && pending === 0 && completed === total;
|
|
337
|
+
const factLine = finished
|
|
329
338
|
? `Plan terminé avec succès — ${completed}/${total} tâche(s) réussie(s).`
|
|
330
|
-
: `Plan terminé
|
|
339
|
+
: `Plan non terminé — ${completed}/${total} tâche(s) réussie(s)` +
|
|
340
|
+
`${pending ? `, ${pending} en attente (approbation ou exécution)` : ''}` +
|
|
341
|
+
`${failed ? `, ${failed} en erreur` : ''}` +
|
|
342
|
+
`${cancelled ? `, ${cancelled} annulée(s)` : ''}.` +
|
|
343
|
+
`${firstError ? ` Première erreur : ${firstError}.` : ''}`;
|
|
331
344
|
let content = factLine;
|
|
332
345
|
const llm = session.llm;
|
|
333
346
|
if (llm && typeof llm.completeWithTools === 'function') {
|
|
@@ -337,6 +350,7 @@ async function announceRunOutcome(session, { runId, ok, signal = null } = {}) {
|
|
|
337
350
|
'You are Donna, an orchestration assistant reporting a run result to the user.',
|
|
338
351
|
'Rephrase the outcome facts in ONE short, natural sentence, in the same language as the facts.',
|
|
339
352
|
'No lists, no headers, no raw job ids — just a concise human summary.',
|
|
353
|
+
'If the facts say the plan is NOT finished, say so plainly and name what is still pending or failed: never claim the work was completed, published or successful.',
|
|
340
354
|
].join('\n'),
|
|
341
355
|
tools: [],
|
|
342
356
|
messages: [{ role: 'user', content: `Run outcome facts:\n${factLine}` }],
|
|
@@ -4,7 +4,7 @@ import { createAgentEvent, dispatchAgentEvent, reduceAgentEvents } from '../core
|
|
|
4
4
|
import { tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
|
|
5
5
|
import { isTerminal } from '../orchestrator/taskStatuses.js';
|
|
6
6
|
import { readyPlanTasks } from '../core/planPatch.js';
|
|
7
|
-
import { skipImpossibleTasks, structuredPlanEvaluation, ensurePlanProjection, evaluateRuntimeRun, finishRuntimeRun, materializeTaskInputs, replanRuntimeRun, runRuntimeAgenticWorkflow, runRuntimeParallelPlan, shouldUseParallelScheduler } from './runner.js';
|
|
7
|
+
import { skipImpossibleTasks, structuredPlanEvaluation, ensurePlanProjection, evaluateRuntimeRun, finishRuntimeRun, materializeTaskInputs, replanRuntimeRun, runRuntimeAgenticWorkflow, runRuntimeParallelPlan, shouldUseParallelScheduler, announceRunOutcome } from './runner.js';
|
|
8
8
|
|
|
9
9
|
test('ensurePlanProjection re-projects when the chained plan changes shape (step 2)', () => {
|
|
10
10
|
const session = { agentEvents: [], agentProjection: null };
|
|
@@ -1288,3 +1288,34 @@ test('rejouer les événements redonne exactement les mêmes statuts', () => {
|
|
|
1288
1288
|
);
|
|
1289
1289
|
assert.deepEqual(replayed.plan.map((step) => step.status), ['failed', 'skipped']);
|
|
1290
1290
|
});
|
|
1291
|
+
|
|
1292
|
+
test('announceRunOutcome never calls a plan with pending tasks a success', async () => {
|
|
1293
|
+
// The outcome summary counted only failed/cancelled/successful, so a run
|
|
1294
|
+
// whose single mutating task was still `pending_approval` produced
|
|
1295
|
+
// "Plan terminé avec succès — 0/1 réussie", which the model rephrased into
|
|
1296
|
+
// "le livrable a bien été publié" — before the approval that would run it.
|
|
1297
|
+
const session = {
|
|
1298
|
+
agentEvents: [],
|
|
1299
|
+
agentProjection: null,
|
|
1300
|
+
headlessPlan: [{ id: 'a', description: 'Build TechSections', status: 'pending_approval' }],
|
|
1301
|
+
};
|
|
1302
|
+
await announceRunOutcome(session, { runId: 'run-1', ok: true });
|
|
1303
|
+
const message = session.agentEvents.find((event) => event.type === 'assistant_message');
|
|
1304
|
+
assert.match(message.payload.content, /non terminé/i);
|
|
1305
|
+
assert.match(message.payload.content, /en attente/);
|
|
1306
|
+
assert.doesNotMatch(message.payload.content, /succès/i);
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
test('announceRunOutcome reports success only when every task finished', async () => {
|
|
1310
|
+
const session = {
|
|
1311
|
+
agentEvents: [],
|
|
1312
|
+
agentProjection: null,
|
|
1313
|
+
headlessPlan: [
|
|
1314
|
+
{ id: 'a', description: 'Build TechSections', status: 'done' },
|
|
1315
|
+
{ id: 'b', description: 'Export', status: 'success' },
|
|
1316
|
+
],
|
|
1317
|
+
};
|
|
1318
|
+
await announceRunOutcome(session, { runId: 'run-2', ok: true });
|
|
1319
|
+
const message = session.agentEvents.find((event) => event.type === 'assistant_message');
|
|
1320
|
+
assert.match(message.payload.content, /succès/);
|
|
1321
|
+
});
|
package/src/runtime/server.js
CHANGED
|
@@ -529,6 +529,20 @@ export function startRuntimeServer({
|
|
|
529
529
|
}
|
|
530
530
|
return;
|
|
531
531
|
}
|
|
532
|
+
// A run/job status question is answered by the runtime itself, whatever
|
|
533
|
+
// the mode and whether or not a run is active. Left to the model it
|
|
534
|
+
// confused the runtime runId with a production job id ("job not
|
|
535
|
+
// found"); in chat mode it had no runtime status tool at all.
|
|
536
|
+
if (asksForRunStatus(input)) {
|
|
537
|
+
const status = controlStatus(context, store);
|
|
538
|
+
sendJson(response, 200, {
|
|
539
|
+
accepted: true,
|
|
540
|
+
kind: 'observe',
|
|
541
|
+
...status,
|
|
542
|
+
explanation: explainControlState(status),
|
|
543
|
+
});
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
532
546
|
if (context.running && !readOnlyChat) {
|
|
533
547
|
// Agent-mode message while a run is active. Classify once: control
|
|
534
548
|
// verbs and new tasks go to the control lane, plain conversation is
|
|
@@ -1632,6 +1646,18 @@ function rejectPlanPatch(context, store, patchId, reason) {
|
|
|
1632
1646
|
};
|
|
1633
1647
|
}
|
|
1634
1648
|
|
|
1649
|
+
// A question about the run/job currently executing. Deliberately narrow — a
|
|
1650
|
+
// status word AND a run/job noun — so it never hijacks an ordinary "explain how
|
|
1651
|
+
// X works" question. Such a question must be answered by the runtime itself:
|
|
1652
|
+
// left to the model, a runtime runId was mistaken for a production job id and
|
|
1653
|
+
// reported as "not found", and a read-only chat turn had no runtime status tool.
|
|
1654
|
+
function asksForRunStatus(input) {
|
|
1655
|
+
const text = String(input ?? '');
|
|
1656
|
+
const statusWord = /\b(status|statut|progression|progress|avancement|o[uù] en est|o[uù] en sont)\b/i;
|
|
1657
|
+
const runNoun = /\b(job|run|t[aâ]che|task|build|ingest|pipeline|export|polish|traitement)\b/i;
|
|
1658
|
+
return statusWord.test(text) && runNoun.test(text);
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1635
1661
|
// Classifier for the control lane's free-text messages. The classification is
|
|
1636
1662
|
// LLM-backed: the only deterministic matches left are the runtime's own
|
|
1637
1663
|
// control verbs (cancel, an explicit "later/queue", status and plan-change
|
|
@@ -1668,6 +1694,14 @@ async function classifyControlMessage(input, status, { forcedIntent = null, llm
|
|
|
1668
1694
|
if (/\b(o[uù] en es[t-]|status|statut|progress|progression|logs?|explique|explain|inspect|show|montre|quoi de neuf)\b/i.test(lower)) {
|
|
1669
1695
|
return { kind: 'observe', confidence: 0.86, reason: 'status_or_explanation_request' };
|
|
1670
1696
|
}
|
|
1697
|
+
// A bare "yes" answers the runtime's own last prompt (the launch
|
|
1698
|
+
// acknowledgement used to end on "check progress or cancel?"). While a run is
|
|
1699
|
+
// active, the only thing the runtime can act on is a status check: treating
|
|
1700
|
+
// the word as ordinary conversation made the read-only chat fallback lecture
|
|
1701
|
+
// the user about switching modes instead of answering.
|
|
1702
|
+
if (status.running && /^\s*(oui|yes|yep|ok|okay|vas[- ]?y|d'accord|daccord|entendu)\b/i.test(lower)) {
|
|
1703
|
+
return { kind: 'observe', confidence: 0.7, reason: 'confirmation_of_runtime_prompt' };
|
|
1704
|
+
}
|
|
1671
1705
|
if (status.running && /\b(ajoute|add|change|modifie|modify|remplace|replace|retire|remove|skip|ignore|apr[eè]s|before|after|chaque|each|plan|step|t[aâ]che)\b/i.test(lower)) {
|
|
1672
1706
|
return { kind: 'modify_run', confidence: 0.78, reason: 'active_run_change_request' };
|
|
1673
1707
|
}
|
|
@@ -1733,6 +1733,93 @@ test('POST /turn keeps informational skill and build questions conversational',
|
|
|
1733
1733
|
}
|
|
1734
1734
|
});
|
|
1735
1735
|
|
|
1736
|
+
test('POST /turn answers a run status question from the runtime instead of the model', async (t) => {
|
|
1737
|
+
const session = { workspace: 'acme', controlQueue: [] };
|
|
1738
|
+
const context = { workspace: 'acme', session, running: true, currentAbortController: null };
|
|
1739
|
+
const status = {
|
|
1740
|
+
status: 'running',
|
|
1741
|
+
running: true,
|
|
1742
|
+
plan: [{ step: 1, description: 'Build TechSections', status: 'running' }],
|
|
1743
|
+
queue: [],
|
|
1744
|
+
controlQueue: [],
|
|
1745
|
+
approvals: [],
|
|
1746
|
+
conversation: [],
|
|
1747
|
+
};
|
|
1748
|
+
let turns = 0;
|
|
1749
|
+
let handle;
|
|
1750
|
+
try {
|
|
1751
|
+
handle = await startRuntimeServer({
|
|
1752
|
+
host: '127.0.0.1', port: 0,
|
|
1753
|
+
store: { dbPath: ':memory:', getState: () => status, listEvents: () => [] },
|
|
1754
|
+
getContext: async () => context,
|
|
1755
|
+
run: async () => new Promise(() => {}),
|
|
1756
|
+
turn: async () => { turns += 1; return { ok: true }; },
|
|
1757
|
+
});
|
|
1758
|
+
} catch (err) {
|
|
1759
|
+
if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
|
|
1760
|
+
throw err;
|
|
1761
|
+
}
|
|
1762
|
+
try {
|
|
1763
|
+
// The model once mistook the runtime runId for a production job id and
|
|
1764
|
+
// answered "job not found". The runtime answers its own status.
|
|
1765
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/turn?workspace=acme`, {
|
|
1766
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1767
|
+
body: JSON.stringify({ input: 'donne le status du job en cours', mode: 'agent' }),
|
|
1768
|
+
});
|
|
1769
|
+
const body = await response.json();
|
|
1770
|
+
assert.equal(response.status, 200);
|
|
1771
|
+
assert.equal(body.kind, 'observe');
|
|
1772
|
+
assert.match(body.explanation, /Build TechSections/);
|
|
1773
|
+
assert.equal(turns, 0);
|
|
1774
|
+
} finally {
|
|
1775
|
+
context.currentAbortController?.abort();
|
|
1776
|
+
await handle.close();
|
|
1777
|
+
}
|
|
1778
|
+
});
|
|
1779
|
+
|
|
1780
|
+
test('POST /turn treats a bare confirmation during a run as a status check', async (t) => {
|
|
1781
|
+
const session = { workspace: 'acme', controlQueue: [] };
|
|
1782
|
+
const context = { workspace: 'acme', session, running: true, currentAbortController: null };
|
|
1783
|
+
const status = {
|
|
1784
|
+
status: 'running',
|
|
1785
|
+
running: true,
|
|
1786
|
+
plan: [{ step: 1, description: 'Rebuild the wiki', status: 'running' }],
|
|
1787
|
+
queue: [],
|
|
1788
|
+
controlQueue: [],
|
|
1789
|
+
approvals: [],
|
|
1790
|
+
conversation: [],
|
|
1791
|
+
};
|
|
1792
|
+
let turns = 0;
|
|
1793
|
+
let handle;
|
|
1794
|
+
try {
|
|
1795
|
+
handle = await startRuntimeServer({
|
|
1796
|
+
host: '127.0.0.1', port: 0,
|
|
1797
|
+
store: { dbPath: ':memory:', getState: () => status, listEvents: () => [] },
|
|
1798
|
+
getContext: async () => context,
|
|
1799
|
+
run: async () => new Promise(() => {}),
|
|
1800
|
+
turn: async () => { turns += 1; return { ok: true }; },
|
|
1801
|
+
});
|
|
1802
|
+
} catch (err) {
|
|
1803
|
+
if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
|
|
1804
|
+
throw err;
|
|
1805
|
+
}
|
|
1806
|
+
try {
|
|
1807
|
+
// "oui" answers the launch acknowledgement. It must reach the runtime's
|
|
1808
|
+
// status, not a read-only chat turn that lectures about switching modes.
|
|
1809
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/turn?workspace=acme`, {
|
|
1810
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1811
|
+
body: JSON.stringify({ input: 'oui', mode: 'agent' }),
|
|
1812
|
+
});
|
|
1813
|
+
const body = await response.json();
|
|
1814
|
+
assert.equal(response.status, 200);
|
|
1815
|
+
assert.equal(body.kind, 'observe');
|
|
1816
|
+
assert.equal(turns, 0);
|
|
1817
|
+
} finally {
|
|
1818
|
+
context.currentAbortController?.abort();
|
|
1819
|
+
await handle.close();
|
|
1820
|
+
}
|
|
1821
|
+
});
|
|
1822
|
+
|
|
1736
1823
|
test('POST /run accepts named skill arguments and deduplicates an explicit retry key', async (t) => {
|
|
1737
1824
|
const root = mkdtempSync(join(tmpdir(), 'runtime-named-skill-'));
|
|
1738
1825
|
mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
|
package/src/runtime/skillRun.js
CHANGED
|
@@ -165,7 +165,7 @@ export async function generateSkillAcknowledgment(session, { publicInput, object
|
|
|
165
165
|
// slow provider must not block the skill-launch HTTP response forever.
|
|
166
166
|
const reply = await llm.complete({
|
|
167
167
|
system: 'You are Donna, the workspace assistant. You acknowledge a launched workflow in the user\'s language. Be concise: exactly one short sentence.',
|
|
168
|
-
input: `The user just launched the workspace skill ${publicInput}. It was compiled into ${count} step(s) and is now running.\n\nWrite ONE short sentence in ${language} that confirms the launch, echoes the skill and its arguments, and says progress will be reported. Return only that sentence, nothing else.`,
|
|
168
|
+
input: `The user just launched the workspace skill ${publicInput}. It was compiled into ${count} step(s) and is now running.\n\nWrite ONE short sentence in ${language} that confirms the launch, echoes the skill and its arguments, and says progress will be reported. Do not ask a question, do not propose options, and do not offer to check, monitor or cancel anything: the runtime reports progress on its own and this acknowledgement is not a decision point. Return only that sentence, nothing else.`,
|
|
169
169
|
signal: AbortSignal.timeout(8_000),
|
|
170
170
|
});
|
|
171
171
|
const text = String(reply ?? '').trim();
|
|
@@ -94,6 +94,9 @@ test('generateSkillAcknowledgment asks Donna in the session language and echoes
|
|
|
94
94
|
assert.equal(calls.length, 1);
|
|
95
95
|
assert.match(calls[0].input, /es/);
|
|
96
96
|
assert.match(calls[0].input, /\/deliver deliverable="Informe"/);
|
|
97
|
+
// The acknowledgement is not a decision point: it must not invite the user
|
|
98
|
+
// into a dialog the runtime cannot act on.
|
|
99
|
+
assert.match(calls[0].input, /Do not ask a question/);
|
|
97
100
|
});
|
|
98
101
|
|
|
99
102
|
test('generateSkillAcknowledgment degrades to a neutral message without an LLM client', async () => {
|
package/src/shell/repl.js
CHANGED
|
@@ -1583,13 +1583,17 @@ async function runChatToolLoop({ input, session, history, donnaMessage, onUpdate
|
|
|
1583
1583
|
executeCall,
|
|
1584
1584
|
maxIterations: Math.min(8, Number(session?.chatAccess?.maxToolIterations) || 4),
|
|
1585
1585
|
signal: session._abortSignal,
|
|
1586
|
-
onStep: (
|
|
1586
|
+
onStep: () => onStep?.('Chat: consulting…'),
|
|
1587
1587
|
onTextDelta,
|
|
1588
1588
|
onTextReset,
|
|
1589
1589
|
});
|
|
1590
|
-
|
|
1590
|
+
// A capped turn now asks the model for a final answer without tools, so an
|
|
1591
|
+
// answer may exist even when the loop hit its limit: show it. Only fall back
|
|
1592
|
+
// to the honest limit notice when there is genuinely nothing to show.
|
|
1593
|
+
const answer = stripDsmlArtifacts(content).trim();
|
|
1594
|
+
donnaMessage.content = answer || (capped
|
|
1591
1595
|
? 'Could not finish within the chat mode iteration limit. Switch to /agent if needed.'
|
|
1592
|
-
:
|
|
1596
|
+
: formatLlmUnavailableMessage('empty response'));
|
|
1593
1597
|
onUpdate?.();
|
|
1594
1598
|
}
|
|
1595
1599
|
|