@dotdrelle/wiki-manager 0.15.40 → 0.15.42
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/package.json +2 -2
- package/src/activity/activityAggregator.js +3 -3
- package/src/activity/progressCalculator.js +3 -4
- package/src/agent/graph.js +40 -1
- package/src/cli/wiki-manager.js +64 -38
- package/src/commands/slash.js +47 -9
- package/src/core/activity.js +3 -2
- package/src/core/agentEvents.js +68 -11
- package/src/core/agentEvents.test.js +76 -0
- package/src/core/agentLoop.js +32 -6
- package/src/core/buildInfo.json +2 -2
- package/src/core/compose.js +32 -0
- package/src/core/dockerCompose.test.js +32 -0
- package/src/core/jobQueue.js +9 -0
- package/src/core/mcp.js +1 -1
- package/src/core/otherWorkspacesRunning.test.js +51 -0
- package/src/core/plan.js +3 -2
- package/src/core/planPatch.js +2 -1
- package/src/core/profileServiceStatus.test.js +26 -2
- package/src/core/toolLoop.js +32 -7
- package/src/core/toolLoop.test.js +95 -0
- package/src/core/wikiSetup.js +50 -7
- package/src/core/wikiWorkspace.test.js +30 -0
- package/src/core/wikirc.test.js +121 -2
- package/src/core/workflow.js +11 -2
- package/src/core/workspaceInherit.js +149 -0
- package/src/core/workspaceInherit.test.js +181 -0
- package/src/graph/graphVisibilityPolicy.js +2 -2
- package/src/orchestrator/agentRegistry.js +50 -0
- package/src/orchestrator/agentRegistry.test.js +76 -1
- package/src/orchestrator/approvalPolicy.js +2 -2
- package/src/orchestrator/dependencyResolver.js +52 -12
- package/src/orchestrator/dispatcher.js +2 -5
- package/src/orchestrator/planIntegrator.js +5 -5
- package/src/orchestrator/resultAggregator.js +2 -1
- package/src/orchestrator/scheduler.test.js +62 -1
- package/src/orchestrator/taskStatuses.js +99 -0
- package/src/orchestrator/taskStatuses.test.js +112 -0
- package/src/runtime/delegation.js +158 -0
- package/src/runtime/delegation.test.js +281 -0
- package/src/runtime/recoveryManager.js +2 -5
- package/src/runtime/recoveryManager.test.js +5 -1
- package/src/runtime/runner.js +129 -15
- package/src/runtime/runner.test.js +153 -6
- package/src/runtime/server.test.js +28 -0
- package/src/runtime/store.js +16 -1
- package/src/runtime/store.test.js +50 -0
- package/src/shell/repl.js +21 -11
- package/src/shell/setupWizardModality.test.js +55 -0
- package/src/shell/tui.tsx +29 -9
- package/wiki-workspace +10 -4
package/src/core/agentLoop.js
CHANGED
|
@@ -3,6 +3,11 @@ import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
|
|
|
3
3
|
import { activitySnapshot, newNonTerminalActivities } from './activity.js';
|
|
4
4
|
import { formatCompletedActivities, formatPlanStatus } from './plan.js';
|
|
5
5
|
import { formatReadyTaskPrompt, nextReadyPlanTask, readyPlanTasks, sanitizePlanForExecution } from './planPatch.js';
|
|
6
|
+
import { isActive, isPending, normalizeTaskStatus } from '../orchestrator/taskStatuses.js';
|
|
7
|
+
|
|
8
|
+
// Les deux formes d'attente d'un accord humain. Distinguées de `pending` :
|
|
9
|
+
// approuver ne les rend pas prêtes de la même manière.
|
|
10
|
+
const isPendingApproval = (status) => ['pending_approval', 'waiting_approval'].includes(normalizeTaskStatus(status));
|
|
6
11
|
|
|
7
12
|
export function abortError(message = 'Agent run cancelled.') {
|
|
8
13
|
const err = new Error(message);
|
|
@@ -111,19 +116,40 @@ export async function runAgenticLoop(agent, session, initialInput, {
|
|
|
111
116
|
// integrated agent_plan fragment. Prose stays prose.
|
|
112
117
|
sanitizeSessionPlan(session, { runId });
|
|
113
118
|
|
|
119
|
+
/*
|
|
120
|
+
Une décision prise arrête la conversation.
|
|
121
|
+
|
|
122
|
+
Dès qu'un fragment structuré est intégré pendant ce tour — Donna vient de
|
|
123
|
+
déléguer —, la boucle conversationnelle n'a plus rien à décider : le plan
|
|
124
|
+
existe, validé par l'agent qui l'exécutera. Elle rendait pourtant la main
|
|
125
|
+
au modèle, parce que le seul critère de bascule était « au moins deux
|
|
126
|
+
tâches PRÊTES » et qu'un plan intégralement en attente d'approbation n'en
|
|
127
|
+
compte aucune. Le modèle repartait donc, l'évaluateur jugeait le plan
|
|
128
|
+
incomplet, le replanificateur relançait une délégation : cinq tâches,
|
|
129
|
+
puis dix, puis quinze, à l'identique.
|
|
130
|
+
|
|
131
|
+
L'attente d'approbation et l'exécution appartiennent au planificateur
|
|
132
|
+
parallèle, qui sait faire les deux. On lui rend la main tout de suite.
|
|
133
|
+
*/
|
|
134
|
+
if (parallelHandoff && session._structuredPlanIntegrated) {
|
|
135
|
+
session._structuredPlanIntegrated = false;
|
|
136
|
+
return { ok: true, handoff: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
114
139
|
const newPending = newNonTerminalActivities(snapshot, session);
|
|
115
140
|
if (newPending.length === 0) {
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
141
|
+
// Toute attente est du travail inachevé — approbation comprise. La liste
|
|
142
|
+
// énumérait `pending` et `pending_approval` mais oubliait
|
|
143
|
+
// `waiting_approval`, que produit justement l'intégration d'un fragment
|
|
144
|
+
// délégué : un plan entier en attente se lisait « plus rien à faire ».
|
|
145
|
+
const pending = (session.headlessPlan ?? []).filter((step) => isPending(step.status));
|
|
120
146
|
const pendingSteps = readyPlanTasks(session.headlessPlan);
|
|
121
147
|
if (pending.length === 0) {
|
|
122
148
|
onComplete?.();
|
|
123
149
|
return { ok: true };
|
|
124
150
|
}
|
|
125
151
|
if (pendingSteps.length === 0) {
|
|
126
|
-
const reason = pending.every((step) => step.status
|
|
152
|
+
const reason = pending.every((step) => isPendingApproval(step.status)) ? 'awaiting_approval' : 'no_ready_plan_task';
|
|
127
153
|
onPendingSteps?.({ pendingSteps: pending, blocked: true });
|
|
128
154
|
return { ok: false, stalled: true, reason };
|
|
129
155
|
}
|
|
@@ -150,7 +176,7 @@ export async function runAgenticLoop(agent, session, initialInput, {
|
|
|
150
176
|
const summary = formatCompletedActivities(completed);
|
|
151
177
|
onActivitiesCompleted?.({ completed, summary });
|
|
152
178
|
const unfinished = (session.headlessPlan ?? []).some((step) =>
|
|
153
|
-
|
|
179
|
+
isPending(step.status) || isActive(step.status));
|
|
154
180
|
if (deterministicTerminalSummary && !unfinished) {
|
|
155
181
|
return { ok: true, completed, summary, deterministicSummary: true };
|
|
156
182
|
}
|
package/src/core/buildInfo.json
CHANGED
package/src/core/compose.js
CHANGED
|
@@ -321,6 +321,38 @@ export async function serviceStates(session) {
|
|
|
321
321
|
return states;
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Names of OTHER registered workspaces that still have containers running.
|
|
326
|
+
*
|
|
327
|
+
* The external agents (cme, documents, connectors, mailer) are a single
|
|
328
|
+
* stack shared by every workspace — one Compose project, one set of
|
|
329
|
+
* containers. `/stop all` took them down unconditionally, so stopping one
|
|
330
|
+
* workspace silently cut the agents out from under all the others.
|
|
331
|
+
*
|
|
332
|
+
* Best effort by design: a workspace whose Compose project cannot be queried
|
|
333
|
+
* (registry entry pointing at a path that no longer exists, docker refusing)
|
|
334
|
+
* is reported as NOT running. Erring the other way would make the agents
|
|
335
|
+
* impossible to stop as soon as one stale registry entry existed.
|
|
336
|
+
*/
|
|
337
|
+
export async function otherWorkspacesRunning(session, workspaces = []) {
|
|
338
|
+
const current = session?.workspace ?? null;
|
|
339
|
+
const others = workspaces.filter((workspace) => workspace?.name && workspace.name !== current);
|
|
340
|
+
const results = await Promise.all(others.map(async (workspace) => {
|
|
341
|
+
try {
|
|
342
|
+
const states = await serviceStates({
|
|
343
|
+
workspace: workspace.name,
|
|
344
|
+
workspacePath: workspace.workspacePath,
|
|
345
|
+
workspaceEnvFile: workspace.envFile,
|
|
346
|
+
workspaceEnv: workspace.env,
|
|
347
|
+
});
|
|
348
|
+
return Object.values(states).some((state) => state.running) ? workspace.name : null;
|
|
349
|
+
} catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}));
|
|
353
|
+
return results.filter(Boolean);
|
|
354
|
+
}
|
|
355
|
+
|
|
324
356
|
export async function missingServiceImages(service) {
|
|
325
357
|
const aliases = serviceAliases();
|
|
326
358
|
const targets = service ? (aliases[service] ?? [service]) : COMPOSE_SERVICES;
|
|
@@ -150,3 +150,35 @@ test('missing-image checks ignore agents behind inactive Compose profiles', () =
|
|
|
150
150
|
['example/cme:latest', 'example/connectors:latest'],
|
|
151
151
|
);
|
|
152
152
|
});
|
|
153
|
+
|
|
154
|
+
// Trois variables DOCUMENT_* pour deux conteneurs, chacun n'en déclarant que
|
|
155
|
+
// deux : la question « à quoi ça sert » revient à chaque lecture. Ce test fige
|
|
156
|
+
// le partage pour que la doc reste vraie.
|
|
157
|
+
test('the document handoff keeps input shared and the rest separate', async () => {
|
|
158
|
+
const workspace = YAML.parse(await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8'));
|
|
159
|
+
const agents = YAML.parse(await readFile(new URL('../../agents.docker-compose.yml', import.meta.url), 'utf8'));
|
|
160
|
+
const envOf = (service) => Object.fromEntries(
|
|
161
|
+
(service.environment ?? []).map((entry) => String(entry).split('=', 2)),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const serve = envOf(workspace.services.serve);
|
|
165
|
+
const documents = envOf(agents.services.documents);
|
|
166
|
+
|
|
167
|
+
// `input` est le point de passage : serve y écrit, l'agent y lit.
|
|
168
|
+
assert.equal(serve.DOCUMENT_INPUT_DIR, '/documents/input');
|
|
169
|
+
assert.equal(documents.DOCUMENT_INPUT_DIR, '/documents/input');
|
|
170
|
+
assert.ok(workspace.services.serve.volumes.some((v) => String(v).endsWith(':/documents/input')));
|
|
171
|
+
assert.ok(agents.services.documents.volumes.some((v) => String(v).endsWith(':/documents/input')));
|
|
172
|
+
|
|
173
|
+
// Le manifeste des téléversements n'appartient qu'à serve ; la sortie de
|
|
174
|
+
// conversion n'appartient qu'à l'agent. Déclarer l'un chez l'autre laisserait
|
|
175
|
+
// croire à un partage qui n'existe pas.
|
|
176
|
+
assert.equal(serve.DOCUMENT_UPLOADS_DIR, '/documents/uploads');
|
|
177
|
+
assert.equal(documents.DOCUMENT_UPLOADS_DIR, undefined);
|
|
178
|
+
assert.equal(documents.DOCUMENT_OUTPUT_DIR, '/documents/output');
|
|
179
|
+
assert.equal(serve.DOCUMENT_OUTPUT_DIR, undefined);
|
|
180
|
+
|
|
181
|
+
// Le plafond est vérifié des deux côtés, donc déclaré des deux côtés.
|
|
182
|
+
assert.ok(serve.DOCUMENT_MAX_UPLOAD_BYTES);
|
|
183
|
+
assert.ok(documents.DOCUMENT_MAX_UPLOAD_BYTES);
|
|
184
|
+
});
|
package/src/core/jobQueue.js
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @statuses-vocabulary
|
|
3
|
+
*
|
|
4
|
+
* JOB queue statuses. A queued job has its own lifecycle, independent of
|
|
5
|
+
* the task that may have enqueued it.
|
|
6
|
+
*
|
|
7
|
+
* Declared here rather than in a central exception list so the waiver
|
|
8
|
+
* travels with the code it excuses (see orchestrator/taskStatuses.test.js).
|
|
9
|
+
*/
|
|
1
10
|
import { randomUUID } from 'node:crypto';
|
|
2
11
|
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
3
12
|
import { extractActivity, parseJsonText, sessionActivities } from './activity.js';
|
package/src/core/mcp.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
|
|
3
3
|
|
|
4
|
-
const WIKI_MANAGER_VERSION = '0.15.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.15.42';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { otherWorkspacesRunning } from './compose.js';
|
|
7
|
+
|
|
8
|
+
// `otherWorkspacesRunning` shells out to `docker compose ps` per workspace.
|
|
9
|
+
// Docker is not available in the test environment, so every probe throws —
|
|
10
|
+
// which is itself the behaviour worth pinning: a workspace that cannot be
|
|
11
|
+
// queried must read as NOT running, or one stale registry entry would make
|
|
12
|
+
// the shared agents impossible to stop for good.
|
|
13
|
+
|
|
14
|
+
function workspaceEntry(root, name) {
|
|
15
|
+
const registryPath = join(root, name);
|
|
16
|
+
mkdirSync(registryPath, { recursive: true });
|
|
17
|
+
const envFile = join(registryPath, '.env');
|
|
18
|
+
writeFileSync(envFile, `WORKSPACE_NAME=${name}\nWIKI_WORKSPACE_PATH=${registryPath}\n`, 'utf8');
|
|
19
|
+
return {
|
|
20
|
+
name,
|
|
21
|
+
registryPath,
|
|
22
|
+
envFile,
|
|
23
|
+
workspacePath: registryPath,
|
|
24
|
+
env: { WORKSPACE_NAME: name, WIKI_WORKSPACE_PATH: registryPath },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('the current workspace is never counted as another one', async () => {
|
|
29
|
+
const root = mkdtempSync(join(tmpdir(), 'ws-running-self-'));
|
|
30
|
+
const acpi = workspaceEntry(root, 'acpi');
|
|
31
|
+
|
|
32
|
+
const busy = await otherWorkspacesRunning({ workspace: 'acpi' }, [acpi]);
|
|
33
|
+
|
|
34
|
+
assert.deepEqual(busy, [], 'stopping a workspace must not be blocked by itself');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('an unqueryable workspace does not hold the shared agents hostage', async () => {
|
|
38
|
+
const root = mkdtempSync(join(tmpdir(), 'ws-running-unknown-'));
|
|
39
|
+
const workspaces = [workspaceEntry(root, 'acpi'), workspaceEntry(root, 'stale')];
|
|
40
|
+
|
|
41
|
+
const busy = await otherWorkspacesRunning({ workspace: 'acpi' }, workspaces);
|
|
42
|
+
|
|
43
|
+
assert.deepEqual(busy, []);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('a single workspace, or none at all, never blocks', async () => {
|
|
47
|
+
assert.deepEqual(await otherWorkspacesRunning({ workspace: 'acpi' }, []), []);
|
|
48
|
+
assert.deepEqual(await otherWorkspacesRunning({}, []), []);
|
|
49
|
+
// Entries without a name are registry noise, not workspaces.
|
|
50
|
+
assert.deepEqual(await otherWorkspacesRunning({ workspace: 'acpi' }, [{}, null]), []);
|
|
51
|
+
});
|
package/src/core/plan.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isUnsuccessfulTerminal } from '../orchestrator/taskStatuses.js';
|
|
1
2
|
export function ensurePlanFromActivity(session, activity) {
|
|
2
3
|
if (!activity) return;
|
|
3
4
|
const actKey = activity.key ?? null;
|
|
@@ -57,7 +58,7 @@ export function syncActivitiesToPlan(plan, activities) {
|
|
|
57
58
|
const contractTaskPlan = isContractTaskPlan(plan);
|
|
58
59
|
for (const activity of activities ?? []) {
|
|
59
60
|
const terminal = Boolean(activity.terminal);
|
|
60
|
-
const failed =
|
|
61
|
+
const failed = isUnsuccessfulTerminal(activity.status);
|
|
61
62
|
const actKey = activity.key ?? activity.id ?? activity.jobId ?? null;
|
|
62
63
|
const structuredMatch = findMatchingPlanStepByStructure(plan, activity);
|
|
63
64
|
const matched = structuredMatch ?? findMatchingPlanStep(plan, activity);
|
|
@@ -244,7 +245,7 @@ export function attachActivityToExistingPlan(plan, activity) {
|
|
|
244
245
|
if (!matched) return;
|
|
245
246
|
matched.activityKey = actKey;
|
|
246
247
|
if (!matched.ownerActivityKey) matched.ownerActivityKey = actKey;
|
|
247
|
-
const failed =
|
|
248
|
+
const failed = isUnsuccessfulTerminal(activity.status);
|
|
248
249
|
if (activity.terminal) {
|
|
249
250
|
matched.status = failed ? 'failed' : 'done';
|
|
250
251
|
return;
|
package/src/core/planPatch.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { validateContractInDev } from '../contracts/schemas.js';
|
|
2
|
+
import { isUnsuccessfulTerminal } from '../orchestrator/taskStatuses.js';
|
|
2
3
|
|
|
3
4
|
const PATCH_OPS = new Set([
|
|
4
5
|
'add_task',
|
|
@@ -164,7 +165,7 @@ export function sanitizePlanForExecution(plan) {
|
|
|
164
165
|
const done = new Set(tasks.filter((task) => task.status === 'done').map(taskId));
|
|
165
166
|
const terminalBlocked = new Set(
|
|
166
167
|
tasks
|
|
167
|
-
.filter((task) =>
|
|
168
|
+
.filter((task) => isUnsuccessfulTerminal(task.status))
|
|
168
169
|
.map(taskId),
|
|
169
170
|
);
|
|
170
171
|
const hasReady = pending.some((task) => task.dependsOn.every((dep) => done.has(String(dep))));
|
|
@@ -82,8 +82,32 @@ test('"all" means the same thing to start and to stop', () => {
|
|
|
82
82
|
// `/start all` démarrait les agents, `/stop all` ne les arrêtait pas : la
|
|
83
83
|
// séquence start/stop ne revenait donc pas à l'état de départ.
|
|
84
84
|
assert.match(slash, /const startsAgents = service === 'all';/);
|
|
85
|
-
assert.match(slash, /const stopsAgents = service === 'all';/);
|
|
86
|
-
|
|
85
|
+
assert.match(slash, /const stopsAgents = service === 'all' \|\| stopsEverything;/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('stopping one workspace never takes the shared agents from another', () => {
|
|
89
|
+
const slash = readFileSync(fileURLToPath(new URL('../commands/slash.js', import.meta.url)), 'utf8');
|
|
90
|
+
// Les agents externes sont UNE pile partagée. `/stop all` les arrêtait sans
|
|
91
|
+
// condition : couper un workspace coupait tous les autres, qui ne voyaient
|
|
92
|
+
// qu'une panne.
|
|
93
|
+
assert.match(slash, /await otherWorkspacesRunning\(context\.session, listWorkspaces\(\)\)/);
|
|
94
|
+
assert.match(slash, /if \(busy\.length > 0\) \{/);
|
|
95
|
+
// Le renoncement doit se dire : un saut silencieux ressemble exactement au
|
|
96
|
+
// bug d'origine.
|
|
97
|
+
assert.match(slash, /agents left running for \$\{busy\.join\(', '\)\}/);
|
|
98
|
+
assert.match(slash, /use \/stop everything to stop them anyway/);
|
|
99
|
+
// `everything` court-circuite la garde, et c'est son seul intérêt.
|
|
100
|
+
assert.match(slash, /const busy = stopsEverything\s*\n\s*\? \[\]/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('the everything escape hatch is discoverable', () => {
|
|
104
|
+
const slash = readFileSync(fileURLToPath(new URL('../commands/slash.js', import.meta.url)), 'utf8');
|
|
105
|
+
const repl = readFileSync(fileURLToPath(new URL('../shell/repl.js', import.meta.url)), 'utf8');
|
|
106
|
+
// Une porte de sortie qui n'apparaît ni dans l'aide ni dans la complétion
|
|
107
|
+
// n'existe pas pour l'opérateur qui en a besoin.
|
|
108
|
+
assert.match(slash, /\/stop \[all\|everything\|service\|agents\]/);
|
|
109
|
+
assert.match(repl, /command === '\/stop' && tokenIndex === 1\) return \['all', 'everything'/);
|
|
110
|
+
assert.match(repl, /if \(value === 'everything'\)/);
|
|
87
111
|
});
|
|
88
112
|
|
|
89
113
|
test('the addressable agents are read from the compose file, never hard-coded', async () => {
|
package/src/core/toolLoop.js
CHANGED
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
//
|
|
13
13
|
// `executeCall` may throw to abort the whole loop (e.g. an AbortError on
|
|
14
14
|
// cancel); anything it returns is treated as the tool result for that call.
|
|
15
|
+
/**
|
|
16
|
+
* @param onTextDelta appelé au fil de la génération. Une itération qui finit
|
|
17
|
+
* par des appels d'outils ne produit pas de réponse lisible : ses fragments
|
|
18
|
+
* sont donc rejetés a posteriori via `onTextReset`, pour ne pas afficher un
|
|
19
|
+
* raisonnement intermédiaire que le tour suivant remplacera.
|
|
20
|
+
* @param onTextReset appelé quand les fragments déjà émis sont à jeter.
|
|
21
|
+
*/
|
|
15
22
|
export async function runBoundedToolLoop({
|
|
16
23
|
llm,
|
|
17
24
|
system,
|
|
@@ -21,19 +28,37 @@ export async function runBoundedToolLoop({
|
|
|
21
28
|
maxIterations = 4,
|
|
22
29
|
signal,
|
|
23
30
|
onStep,
|
|
31
|
+
onTextDelta,
|
|
32
|
+
onTextReset,
|
|
24
33
|
} = {}) {
|
|
25
34
|
const cap = Math.max(1, Math.floor(maxIterations) || 1);
|
|
26
35
|
const convo = [...(messages ?? [])];
|
|
36
|
+
// `streamWithTools` accumule les appels d'outils exactement comme
|
|
37
|
+
// `completeWithTools` et renvoie la même forme : le seul écart est qu'il
|
|
38
|
+
// livre le texte au fil de l'eau. Sans lui, la réponse finale n'apparaissait
|
|
39
|
+
// qu'une fois complète — le tour paraissait figé pendant toute sa durée.
|
|
40
|
+
const canStream = typeof onTextDelta === 'function' && typeof llm?.streamWithTools === 'function';
|
|
27
41
|
for (let i = 0; i < cap; i += 1) {
|
|
28
42
|
onStep?.(i + 1, cap);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
let streamedText = false;
|
|
44
|
+
const result = canStream
|
|
45
|
+
? await llm.streamWithTools({
|
|
46
|
+
system,
|
|
47
|
+
tools,
|
|
48
|
+
messages: convo,
|
|
49
|
+
toolChoice: 'auto',
|
|
50
|
+
onTextDelta: (delta) => { streamedText = true; onTextDelta(delta); },
|
|
51
|
+
signal,
|
|
52
|
+
})
|
|
53
|
+
: await llm.completeWithTools({
|
|
54
|
+
system,
|
|
55
|
+
tools,
|
|
56
|
+
messages: convo,
|
|
57
|
+
toolChoice: 'auto',
|
|
58
|
+
signal,
|
|
59
|
+
});
|
|
36
60
|
const calls = result?.tool_calls ?? [];
|
|
61
|
+
if (calls.length > 0 && streamedText) onTextReset?.();
|
|
37
62
|
if (calls.length === 0) {
|
|
38
63
|
return {
|
|
39
64
|
content: result?.content ?? result?.message?.content ?? '',
|
|
@@ -86,3 +86,98 @@ test('propagates an abort thrown by executeCall', async () => {
|
|
|
86
86
|
/aborted/,
|
|
87
87
|
);
|
|
88
88
|
});
|
|
89
|
+
|
|
90
|
+
// Le tour paraissait figé pendant toute sa durée : la réponse n'apparaissait
|
|
91
|
+
// qu'une fois complète, parce que la boucle n'appelait que completeWithTools.
|
|
92
|
+
test('streams the answer when the caller asks for deltas', async () => {
|
|
93
|
+
const deltas = [];
|
|
94
|
+
const llm = {
|
|
95
|
+
async completeWithTools() { throw new Error('streamWithTools devait être préféré'); },
|
|
96
|
+
async streamWithTools({ onTextDelta }) {
|
|
97
|
+
onTextDelta('Le wiki ');
|
|
98
|
+
onTextDelta('contient 12 pages.');
|
|
99
|
+
return { content: 'Le wiki contient 12 pages.', tool_calls: [] };
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const out = await runBoundedToolLoop({
|
|
104
|
+
llm,
|
|
105
|
+
tools: [],
|
|
106
|
+
executeCall: async () => 'unused',
|
|
107
|
+
onTextDelta: (delta) => deltas.push(delta),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
assert.deepEqual(deltas, ['Le wiki ', 'contient 12 pages.']);
|
|
111
|
+
assert.equal(out.content, 'Le wiki contient 12 pages.');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('keeps the non-streaming path when no delta callback is given', async () => {
|
|
115
|
+
let streamed = false;
|
|
116
|
+
const llm = {
|
|
117
|
+
async completeWithTools() { return { content: 'plain', tool_calls: [] }; },
|
|
118
|
+
async streamWithTools() { streamed = true; return { content: 'streamed', tool_calls: [] }; },
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const out = await runBoundedToolLoop({ llm, tools: [], executeCall: async () => 'unused' });
|
|
122
|
+
|
|
123
|
+
assert.equal(streamed, false, 'sans onTextDelta, rien ne doit changer');
|
|
124
|
+
assert.equal(out.content, 'plain');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('discards text emitted by an iteration that ends in tool calls', async () => {
|
|
128
|
+
// Un modèle peut écrire un raisonnement puis décider d'appeler un outil. Ce
|
|
129
|
+
// texte est remplacé par le tour suivant : le laisser afficherait des
|
|
130
|
+
// paragraphes qui disparaissent, pire que pas de streaming du tout.
|
|
131
|
+
let round = 0;
|
|
132
|
+
const deltas = [];
|
|
133
|
+
let resets = 0;
|
|
134
|
+
const llm = {
|
|
135
|
+
async streamWithTools({ onTextDelta }) {
|
|
136
|
+
round += 1;
|
|
137
|
+
if (round === 1) {
|
|
138
|
+
onTextDelta('Je vais regarder…');
|
|
139
|
+
const calls = [toolCall('c1', 'wiki__wiki_list_pages')];
|
|
140
|
+
return { message: { role: 'assistant', content: '', tool_calls: calls }, tool_calls: calls };
|
|
141
|
+
}
|
|
142
|
+
onTextDelta('12 pages.');
|
|
143
|
+
return { content: '12 pages.', tool_calls: [] };
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const out = await runBoundedToolLoop({
|
|
148
|
+
llm,
|
|
149
|
+
tools: [],
|
|
150
|
+
executeCall: async () => 'ok',
|
|
151
|
+
onTextDelta: (delta) => deltas.push(delta),
|
|
152
|
+
onTextReset: () => { resets += 1; },
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
assert.deepEqual(deltas, ['Je vais regarder…', '12 pages.']);
|
|
156
|
+
assert.equal(resets, 1, 'le texte intermédiaire doit être annulé, une fois');
|
|
157
|
+
assert.equal(out.content, '12 pages.');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('never asks to discard text that was never emitted', async () => {
|
|
161
|
+
let resets = 0;
|
|
162
|
+
let round = 0;
|
|
163
|
+
const llm = {
|
|
164
|
+
async streamWithTools() {
|
|
165
|
+
round += 1;
|
|
166
|
+
if (round === 1) {
|
|
167
|
+
const calls = [toolCall('c1', 'wiki__wiki_list_pages')];
|
|
168
|
+
return { message: { role: 'assistant', content: '', tool_calls: calls }, tool_calls: calls };
|
|
169
|
+
}
|
|
170
|
+
return { content: 'fini', tool_calls: [] };
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
await runBoundedToolLoop({
|
|
175
|
+
llm,
|
|
176
|
+
tools: [],
|
|
177
|
+
executeCall: async () => 'ok',
|
|
178
|
+
onTextDelta: () => {},
|
|
179
|
+
onTextReset: () => { resets += 1; },
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
assert.equal(resets, 0);
|
|
183
|
+
});
|
package/src/core/wikiSetup.js
CHANGED
|
@@ -5,7 +5,8 @@ import { dirname, join } from 'node:path';
|
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import YAML from 'yaml';
|
|
7
7
|
import { checkMissingDockerImages } from './dockerImages.js';
|
|
8
|
-
import { patchWikircProfile } from './wikirc.js';
|
|
8
|
+
import { loadWikircProfile, patchWikircProfile } from './wikirc.js';
|
|
9
|
+
import { buildInheritedWikircPatch, copyCmeCredentials } from './workspaceInherit.js';
|
|
9
10
|
import { resolveAgentsComposeContext } from './agentsCompose.js';
|
|
10
11
|
import { managerEnvFile, managerMcpEndpointsFile, resolveAgentsDataDir } from './env.js';
|
|
11
12
|
import { createWorkspace, findWorkspace, isValidWorkspaceName, listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
|
|
@@ -208,20 +209,62 @@ export async function refreshRunningContainers(options = {}) {
|
|
|
208
209
|
return { skipped: false, refreshed, errors };
|
|
209
210
|
}
|
|
210
211
|
|
|
211
|
-
export async function createNewWorkspace(name, targetPath) {
|
|
212
|
+
export async function createNewWorkspace(name, targetPath, options = {}) {
|
|
212
213
|
try {
|
|
213
214
|
const output = await createWorkspace(name, targetPath, { timeout: 600_000 });
|
|
214
|
-
const workspace = finalizeCreatedWorkspace(name);
|
|
215
|
-
return { output, workspace };
|
|
215
|
+
const { workspace, inherited } = await finalizeCreatedWorkspace(name, options);
|
|
216
|
+
return { output, workspace, inherited };
|
|
216
217
|
} catch (err) {
|
|
217
218
|
throw wrapDockerError(err);
|
|
218
219
|
}
|
|
219
220
|
}
|
|
220
221
|
|
|
221
|
-
|
|
222
|
+
/**
|
|
223
|
+
* @param options.inheritFrom name of the workspace to copy LLM config and CME
|
|
224
|
+
* credentials from — normally the session's current workspace. Omitted, or
|
|
225
|
+
* unknown, means "scaffold defaults only", the previous behaviour.
|
|
226
|
+
*/
|
|
227
|
+
export async function finalizeCreatedWorkspace(name, options = {}) {
|
|
222
228
|
const workspace = findWorkspace(name);
|
|
223
|
-
if (workspace)
|
|
224
|
-
|
|
229
|
+
if (!workspace) return { workspace: null, inherited: [] };
|
|
230
|
+
// Order matters: the access key is this workspace's own credential and must
|
|
231
|
+
// land before anything else touches the file.
|
|
232
|
+
initializeWorkspaceWikirc(workspace);
|
|
233
|
+
const inherited = await inheritWorkspaceSetup(workspace, options.inheritFrom ?? null);
|
|
234
|
+
return { workspace, inherited };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Carry an existing workspace's proven setup over to a new one. Best effort by
|
|
239
|
+
* design: a workspace that exists and works must never fail to be created
|
|
240
|
+
* because the workspace next door has an unreadable config.
|
|
241
|
+
*/
|
|
242
|
+
export async function inheritWorkspaceSetup(workspace, sourceName) {
|
|
243
|
+
if (!workspace?.workspacePath || !sourceName || sourceName === workspace.name) return [];
|
|
244
|
+
const source = findWorkspace(sourceName);
|
|
245
|
+
if (!source?.workspacePath) return [];
|
|
246
|
+
|
|
247
|
+
const inherited = [];
|
|
248
|
+
try {
|
|
249
|
+
const { config: sourceConfig } = loadWikircProfile(source.workspacePath, 'default');
|
|
250
|
+
const { config: targetConfig } = loadWikircProfile(workspace.workspacePath, 'default');
|
|
251
|
+
const { patch, inherited: keys } = buildInheritedWikircPatch(sourceConfig, targetConfig);
|
|
252
|
+
if (keys.length > 0) {
|
|
253
|
+
patchWikircProfile(workspace.workspacePath, 'default', patch);
|
|
254
|
+
inherited.push(...keys);
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
// Unreadable or absent profile on either side — nothing to inherit.
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const copied = await copyCmeCredentials(resolveAgentsDataDir(), source.name, workspace.name);
|
|
262
|
+
if (copied) inherited.push('cme.app_data.json');
|
|
263
|
+
} catch {
|
|
264
|
+
// Credentials are a convenience, not a prerequisite.
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return inherited;
|
|
225
268
|
}
|
|
226
269
|
|
|
227
270
|
export function initializeWorkspaceWikirc(workspace) {
|
|
@@ -105,3 +105,33 @@ test('container refresh pulls and renews only services that are already running'
|
|
|
105
105
|
assert.match(script, /refresh_running_services 'No running external agent containers to refresh' 'Refreshed running agents: %s' _agents_dc/);
|
|
106
106
|
assert.match(script, /refresh_running_services "No running workspace containers to refresh: \$workspace" "Refreshed running workspace containers: \$workspace \(%s\)" compose_for_workspace "\$workspace"/);
|
|
107
107
|
});
|
|
108
|
+
|
|
109
|
+
// `${arr[@]}` on an empty array is an unbound-variable error under `set -u` in
|
|
110
|
+
// bash <= 4.3, and macOS still ships /bin/bash 3.2. `parse_global_options`
|
|
111
|
+
// tripped on it, so `wiki-workspace` with no global option died before reaching
|
|
112
|
+
// any subcommand.
|
|
113
|
+
//
|
|
114
|
+
// The execution check below cannot catch a regression on a CI running bash 5,
|
|
115
|
+
// where an empty array is legal — hence the source assertion that every
|
|
116
|
+
// optional array keeps the `${arr[@]+"${arr[@]}"}` guard. Both are needed.
|
|
117
|
+
test('optional arrays keep the empty-array guard required by bash 3.2', async () => {
|
|
118
|
+
const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
|
|
119
|
+
|
|
120
|
+
for (const name of ['parsed', 'PARSED_ARGS', 'log_args']) {
|
|
121
|
+
const unguarded = new RegExp(String.raw`(?<!\+)"\$\{${name}\[@\]\}"`);
|
|
122
|
+
assert.doesNotMatch(script, unguarded, `${name}[@] must use the \${name[@]+"..."} guard`);
|
|
123
|
+
}
|
|
124
|
+
assert.match(script, /set -- \$\{parsed\[@\]\+"\$\{parsed\[@\]\}"\}/);
|
|
125
|
+
assert.match(script, /set -- \$\{PARSED_ARGS\[@\]\+"\$\{PARSED_ARGS\[@\]\}"\}/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('wiki-workspace reaches its usage text with no arguments', async () => {
|
|
129
|
+
const { spawnSync } = await import('node:child_process');
|
|
130
|
+
const scriptPath = new URL('../../wiki-workspace', import.meta.url).pathname;
|
|
131
|
+
|
|
132
|
+
const result = spawnSync('bash', [scriptPath], { encoding: 'utf8' });
|
|
133
|
+
|
|
134
|
+
assert.doesNotMatch(`${result.stdout}${result.stderr}`, /unbound variable/);
|
|
135
|
+
assert.match(result.stdout, /^Usage:/);
|
|
136
|
+
assert.equal(result.status, 2);
|
|
137
|
+
});
|