@dotdrelle/wiki-manager 0.15.41 → 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/cli/wiki-manager.js +35 -2
- package/src/commands/slash.js +36 -8
- package/src/core/agentEvents.js +23 -0
- package/src/core/agentEvents.test.js +32 -0
- package/src/core/buildInfo.json +1 -1
- package/src/core/compose.js +32 -0
- package/src/core/dockerCompose.test.js +32 -0
- package/src/core/mcp.js +1 -1
- package/src/core/otherWorkspacesRunning.test.js +51 -0
- 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/workspaceInherit.js +149 -0
- package/src/core/workspaceInherit.test.js +181 -0
- package/src/runtime/server.test.js +28 -0
- package/src/shell/repl.js +17 -6
- package/src/shell/setupWizardModality.test.js +55 -0
- package/src/shell/tui.tsx +29 -9
- package/wiki-workspace +10 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotdrelle/wiki-manager",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.42",
|
|
4
4
|
"description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
|
|
5
5
|
"license": "PolyForm-Noncommercial-1.0.0",
|
|
6
6
|
"author": "dotrelle",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
13
|
"start": "bun ./bin/wiki-manager.js",
|
|
14
|
-
"test": "node --test src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/profileServiceStatus.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/googleGrants.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/taskStatuses.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/shell/setupWizardDiscovery.test.js src/shell/wrapText.test.js src/runtime/lifecycle.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/delegation.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
|
|
14
|
+
"test": "node --test src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/profileServiceStatus.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/otherWorkspacesRunning.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/workspaceInherit.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/googleGrants.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/taskStatuses.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardModality.test.js src/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/shell/setupWizardDiscovery.test.js src/shell/wrapText.test.js src/runtime/lifecycle.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/delegation.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
|
|
15
15
|
"check-versions": "node scripts/check-versions.js",
|
|
16
16
|
"prepack": "node scripts/check-versions.js",
|
|
17
17
|
"prepublishOnly": "node scripts/check-versions.js",
|
package/src/cli/wiki-manager.js
CHANGED
|
@@ -306,11 +306,28 @@ export function createInteractiveSession(context, { runtimeUrl, turnId, signal =
|
|
|
306
306
|
return session;
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
/*
|
|
310
|
+
Un tour interactif se termine TOUJOURS par un assistant_message.
|
|
311
|
+
|
|
312
|
+
C'est la condition de fin que les deux interfaces attendent : côté `serve`, la
|
|
313
|
+
bulle « Request received · Donna is preparing… » n'est retirée que lorsqu'un
|
|
314
|
+
message assistant non vide arrive. La garde `!content` renvoyait donc `false`
|
|
315
|
+
en silence quand le tour ne produisait rien — modèle qui répond vide, boucle
|
|
316
|
+
d'outils qui s'arrête sans conclure — et le point d'attente tournait
|
|
317
|
+
indéfiniment, sans erreur nulle part.
|
|
318
|
+
|
|
319
|
+
Une réponse vide est un résultat, pas une raison de ne rien dire.
|
|
320
|
+
*/
|
|
309
321
|
export function ensureInteractiveAssistantMessage(session, response, { turnId, workspace } = {}) {
|
|
322
|
+
if (session.agentEvents.some((event) => event.type === 'assistant_message')) return false;
|
|
310
323
|
const content = String(response ?? '').trim();
|
|
311
|
-
if (!content || session.agentEvents.some((event) => event.type === 'assistant_message')) return false;
|
|
312
324
|
dispatchAgentEvent(session, createAgentEvent('assistant_message', {
|
|
313
|
-
origin: 'runtime_turn',
|
|
325
|
+
origin: 'runtime_turn',
|
|
326
|
+
turnId,
|
|
327
|
+
workspace,
|
|
328
|
+
payload: {
|
|
329
|
+
content: content || 'No answer was produced for this turn. The model returned nothing — try rephrasing, or switch to /agent if the request needs an action.',
|
|
330
|
+
},
|
|
314
331
|
}));
|
|
315
332
|
return true;
|
|
316
333
|
}
|
|
@@ -1365,6 +1382,22 @@ async function runRuntime(argv, agent) {
|
|
|
1365
1382
|
response = await runHeadlessChatTurn(ephemeral, input, {
|
|
1366
1383
|
history,
|
|
1367
1384
|
onStep: ephemeral._onStep,
|
|
1385
|
+
// Fragments de réponse publiés au fil de l'eau. Le réducteur les
|
|
1386
|
+
// agrège dans la dernière entrée de conversation (`assistant_delta`),
|
|
1387
|
+
// que `assistant_message` vient ensuite figer : les deux interfaces
|
|
1388
|
+
// voient la réponse s'écrire, au lieu d'attendre le tour complet.
|
|
1389
|
+
onTextDelta: (delta) => dispatchAgentEvent(ephemeral, createAgentEvent('assistant_delta', {
|
|
1390
|
+
origin: 'runtime_turn',
|
|
1391
|
+
turnId,
|
|
1392
|
+
workspace: context.workspace ?? null,
|
|
1393
|
+
payload: { delta },
|
|
1394
|
+
})),
|
|
1395
|
+
onTextReset: () => dispatchAgentEvent(ephemeral, createAgentEvent('assistant_delta_reset', {
|
|
1396
|
+
origin: 'runtime_turn',
|
|
1397
|
+
turnId,
|
|
1398
|
+
workspace: context.workspace ?? null,
|
|
1399
|
+
payload: {},
|
|
1400
|
+
})),
|
|
1368
1401
|
// UI context from `wiki serve`: up to five selected wiki or raw
|
|
1369
1402
|
// documents. Only paths are prompted; Donna reads through tools.
|
|
1370
1403
|
openWikiPages: body.context?.openWikiPages ?? body.context?.openWikiPage,
|
package/src/commands/slash.js
CHANGED
|
@@ -12,7 +12,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
|
12
12
|
import { openExternalUrl } from '../shell/openExternal.js';
|
|
13
13
|
import { classifyCommandFailure, failureHint, rawFailureText } from '../core/commandFailure.js';
|
|
14
14
|
import { join, relative } from 'node:path';
|
|
15
|
-
import { composeServices, listServices, runWikiCli, serviceLogs, serviceNames, serviceStates, startService, stopService } from '../core/compose.js';
|
|
15
|
+
import { composeServices, listServices, otherWorkspacesRunning, runWikiCli, serviceLogs, serviceNames, serviceStates, startService, stopService } from '../core/compose.js';
|
|
16
16
|
import { agentServiceNames, profileServiceStatus } from '../core/agentsCompose.js';
|
|
17
17
|
import { GOOGLE_GRANTS, GOOGLE_GRANT_LABELS, defaultGoogleGrants } from '../core/googleGrants.js';
|
|
18
18
|
import {
|
|
@@ -568,12 +568,23 @@ async function createWorkspaceCommand(context, workspaceName, targetPath) {
|
|
|
568
568
|
try {
|
|
569
569
|
context.onStep?.(`Workspace: creating ${workspaceName}…`);
|
|
570
570
|
const output = await createWorkspace(workspaceName, targetPath, { timeout: 600_000 });
|
|
571
|
-
|
|
571
|
+
// Seed the new workspace from the one in use: the LLM endpoint, key and
|
|
572
|
+
// model are almost always the same, and re-entering them by hand was the
|
|
573
|
+
// first thing to do after every /new.
|
|
574
|
+
const { inherited } = await finalizeCreatedWorkspace(workspaceName, {
|
|
575
|
+
inheritFrom: context.session?.workspace ?? null,
|
|
576
|
+
});
|
|
572
577
|
return {
|
|
573
578
|
output: [
|
|
574
579
|
output,
|
|
575
580
|
'',
|
|
576
581
|
`Workspace created: ${workspaceName}`,
|
|
582
|
+
// State what was carried over. Inheriting silently would make a wrong
|
|
583
|
+
// endpoint look like a scaffold default and send the operator hunting
|
|
584
|
+
// in the wrong file.
|
|
585
|
+
inherited.length > 0
|
|
586
|
+
? `Inherited from ${context.session.workspace}: ${inherited.join(', ')}`
|
|
587
|
+
: null,
|
|
577
588
|
`Use /use ${workspaceName} to load it.`,
|
|
578
589
|
].filter(Boolean).join('\n'),
|
|
579
590
|
};
|
|
@@ -725,7 +736,7 @@ ${helpPair('/use <workspace>', 'Use workspace', '/status', 'Session status')}
|
|
|
725
736
|
${helpPair('/config list', 'Config profiles', '/config use <n>', 'Use config')}
|
|
726
737
|
${helpPair('/config edit <n>', 'Edit config', '/workspace delete <n>', 'Delete workspace')}
|
|
727
738
|
${helpPair('/services', 'Services', '/start [all|agents|services]', 'all = services + agents')}
|
|
728
|
-
${helpPair('/stop [all|service|agents]', 'Stop service(s)', '/logs <service>', 'Service logs')}
|
|
739
|
+
${helpPair('/stop [all|everything|service|agents]', 'Stop service(s)', '/logs <service>', 'Service logs')}
|
|
729
740
|
${helpPair('/skills', 'List skills', '/skills show <n>', 'Show skill')}
|
|
730
741
|
${helpPair('/skills run <n>', 'Run skill guide', '/skills edit <n>', 'Edit skill')}
|
|
731
742
|
${helpPair('/mcp status', 'MCP status', '/mcp endpoints', 'MCP endpoints')}
|
|
@@ -1128,14 +1139,31 @@ export async function handleSlashCommand(line, context) {
|
|
|
1128
1139
|
// compris. Il ne stoppait que les services du workspace et laissait les
|
|
1129
1140
|
// agents debout — donc `/start all` puis `/stop all` ne revenait pas à
|
|
1130
1141
|
// l'état de départ.
|
|
1131
|
-
|
|
1132
|
-
|
|
1142
|
+
//
|
|
1143
|
+
// Cette symétrie ne tient que tant qu'un seul workspace tourne. Les
|
|
1144
|
+
// agents externes sont UNE pile partagée : les arrêter depuis un
|
|
1145
|
+
// workspace coupait les autres, qui n'avaient rien demandé et ne
|
|
1146
|
+
// voyaient qu'une panne. « all » reste donc « toute ma pile », et les
|
|
1147
|
+
// agents ne tombent que s'ils ne servent plus personne. `/stop
|
|
1148
|
+
// everything` garde la coupure franche, explicitement demandée.
|
|
1149
|
+
const stopsEverything = service === 'everything';
|
|
1150
|
+
const stopsAgents = service === 'all' || stopsEverything;
|
|
1151
|
+
const stopTarget = service === 'services' || stopsEverything ? undefined : service;
|
|
1133
1152
|
try {
|
|
1134
|
-
step(`Services: stopping ${service ?? 'workspace services'}…`);
|
|
1153
|
+
step(`Services: stopping ${stopsEverything ? 'all workspaces and agents' : (service ?? 'workspace services')}…`);
|
|
1135
1154
|
await stopService(context.session, stopTarget);
|
|
1136
1155
|
if (stopsAgents) {
|
|
1137
|
-
const
|
|
1138
|
-
|
|
1156
|
+
const busy = stopsEverything
|
|
1157
|
+
? []
|
|
1158
|
+
: await otherWorkspacesRunning(context.session, listWorkspaces());
|
|
1159
|
+
if (busy.length > 0) {
|
|
1160
|
+
// Say who is holding them, and how to override. A silent skip
|
|
1161
|
+
// would look exactly like the bug we just fixed.
|
|
1162
|
+
step(`Services: agents left running for ${busy.join(', ')} — use /stop everything to stop them anyway.`);
|
|
1163
|
+
} else {
|
|
1164
|
+
const agentsResult = await runAgentCommand(stopAgents, 'stop');
|
|
1165
|
+
if (agentsResult?.failed) return agentsResult;
|
|
1166
|
+
}
|
|
1139
1167
|
}
|
|
1140
1168
|
step('Services: refreshing MCP runtime…');
|
|
1141
1169
|
await refreshMcpRuntimeStatus(context.session);
|
package/src/core/agentEvents.js
CHANGED
|
@@ -268,6 +268,9 @@ function applyEvent(state, event) {
|
|
|
268
268
|
case 'assistant_delta':
|
|
269
269
|
appendAssistantDelta(state, String(event.payload?.delta ?? ''));
|
|
270
270
|
return;
|
|
271
|
+
case 'assistant_delta_reset':
|
|
272
|
+
discardStreamingAssistantMessage(state);
|
|
273
|
+
return;
|
|
271
274
|
case 'tool_call_started':
|
|
272
275
|
state.chain.push({
|
|
273
276
|
type: 'tool',
|
|
@@ -666,6 +669,26 @@ function appendAssistantDelta(state, delta) {
|
|
|
666
669
|
}
|
|
667
670
|
}
|
|
668
671
|
|
|
672
|
+
/*
|
|
673
|
+
Jeter une réponse en cours d'écriture, sans retirer son entrée.
|
|
674
|
+
|
|
675
|
+
Une itération de la boucle d'outils peut produire du texte puis décider
|
|
676
|
+
d'appeler un outil : ce texte est un raisonnement intermédiaire que le tour
|
|
677
|
+
suivant remplace, il ne doit pas rester à l'écran.
|
|
678
|
+
|
|
679
|
+
L'entrée est vidée, jamais dépilée. La réconciliation de `serve`
|
|
680
|
+
(`chatHtml.ts`) suppose une conversation en ajout seul — « le serveur ne mute
|
|
681
|
+
que la dernière entrée, tout ce qui précède est acquis » — et n'indexe la
|
|
682
|
+
boucle que sur la longueur croissante. Un `pop` la ferait passer sous le
|
|
683
|
+
nombre de références déjà rendues : l'élément DOM en trop resterait affiché
|
|
684
|
+
avec le texte qu'on voulait justement effacer, et tous les messages suivants
|
|
685
|
+
se décaleraient d'un cran.
|
|
686
|
+
*/
|
|
687
|
+
function discardStreamingAssistantMessage(state) {
|
|
688
|
+
const last = state.conversation.at(-1);
|
|
689
|
+
if (last?.role === 'assistant' && last.streaming) last.content = '';
|
|
690
|
+
}
|
|
691
|
+
|
|
669
692
|
function finalizeAssistantMessage(state, content) {
|
|
670
693
|
const last = state.conversation.at(-1);
|
|
671
694
|
if (last?.role === 'assistant' && last.streaming) {
|
|
@@ -591,3 +591,35 @@ test('reduceAgentEvents: les alias de statut tombent sur le canonique', () => {
|
|
|
591
591
|
assert.equal(projection.plan[2].status, 'done');
|
|
592
592
|
assert.equal(projection.logs.some((line) => /unknown status/.test(line)), false);
|
|
593
593
|
});
|
|
594
|
+
|
|
595
|
+
// La bulle « Request received · Donna is preparing… » n'est retirée côté serve
|
|
596
|
+
// que lorsqu'un message assistant NON VIDE arrive. Un tour sans réponse ne
|
|
597
|
+
// publiait rien : le point d'attente tournait jusqu'au rechargement de la page.
|
|
598
|
+
test('a discarded stream is emptied, never popped', () => {
|
|
599
|
+
const projection = reduceAgentEvents([
|
|
600
|
+
createAgentEvent('user_message', { origin: 'user', payload: { content: 'question' } }),
|
|
601
|
+
createAgentEvent('assistant_delta', { origin: 'runtime', payload: { delta: 'Je vais regarder…' } }),
|
|
602
|
+
createAgentEvent('assistant_delta_reset', { origin: 'runtime', payload: {} }),
|
|
603
|
+
]);
|
|
604
|
+
|
|
605
|
+
// La réconciliation de serve suppose une conversation en ajout seul : dépiler
|
|
606
|
+
// la ferait passer sous le nombre d'éléments déjà rendus, laissant à l'écran
|
|
607
|
+
// le texte qu'on voulait effacer et décalant tous les messages suivants.
|
|
608
|
+
assert.equal(projection.conversation.length, 2);
|
|
609
|
+
assert.deepEqual(projection.conversation[1], { role: 'assistant', content: '', streaming: true });
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test('a stream resumed after a discard carries only the final text', () => {
|
|
613
|
+
const projection = reduceAgentEvents([
|
|
614
|
+
createAgentEvent('user_message', { origin: 'user', payload: { content: 'question' } }),
|
|
615
|
+
createAgentEvent('assistant_delta', { origin: 'runtime', payload: { delta: 'Je vais regarder…' } }),
|
|
616
|
+
createAgentEvent('assistant_delta_reset', { origin: 'runtime', payload: {} }),
|
|
617
|
+
createAgentEvent('assistant_delta', { origin: 'runtime', payload: { delta: '12 ' } }),
|
|
618
|
+
createAgentEvent('assistant_delta', { origin: 'runtime', payload: { delta: 'pages.' } }),
|
|
619
|
+
createAgentEvent('assistant_message', { origin: 'runtime', payload: { content: '12 pages.' } }),
|
|
620
|
+
]);
|
|
621
|
+
|
|
622
|
+
assert.equal(projection.conversation.length, 2);
|
|
623
|
+
assert.equal(projection.conversation[1].content, '12 pages.');
|
|
624
|
+
assert.equal(projection.conversation[1].streaming, undefined, 'le message doit être figé');
|
|
625
|
+
});
|
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/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
|
+
});
|
|
@@ -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) {
|