@dotdrelle/wiki-manager 0.11.4 → 0.11.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents.docker-compose.yml +3 -3
- package/package.json +2 -2
- package/src/agent/graph.js +99 -3
- package/src/agent/graph.test.js +81 -0
- package/src/cli/wiki-manager.js +9 -2
- package/src/core/agentLoop.js +20 -5
- package/src/core/agentLoop.test.js +19 -1
- package/src/core/mcp.js +1 -1
- package/src/core/planPatch.js +39 -1
- package/src/core/planPatch.test.js +23 -1
- package/src/runtime/client.js +12 -0
- package/src/runtime/donna-contract.test.js +359 -0
- package/src/runtime/runner.js +72 -3
- package/src/runtime/runner.test.js +113 -3
- package/src/runtime/server.js +27 -1
- package/src/runtime/server.test.js +38 -0
- package/src/shell/LeftPane.tsx +2 -1
- package/src/shell/StartupScreen.tsx +101 -21
- package/src/shell/repl.js +60 -15
- package/src/shell/repl.test.js +33 -1
- package/src/shell/tui.tsx +90 -75
- package/src/shell/useAgent.ts +14 -4
- package/src/shell/useSession.ts +92 -17
|
@@ -95,7 +95,7 @@ services:
|
|
|
95
95
|
- MAILER_REQUIRE_CONFIRMATION=${MAILER_REQUIRE_CONFIRMATION:-true}
|
|
96
96
|
- MAILER_DRY_RUN=${MAILER_DRY_RUN:-false}
|
|
97
97
|
- MCP_AUTH_TOKEN=${MAILER_MCP_AUTH_TOKEN:-}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
# Optional: mount a CA bundle and set MAILERSEND_CA_CERT to its container path.
|
|
99
|
+
# volumes:
|
|
100
|
+
# - ${AGENTS_DATA_DIR:-./.agents-data}/certs:/certs:ro
|
|
101
101
|
restart: unless-stopped
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotdrelle/wiki-manager",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.6",
|
|
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/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/agentEvents.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/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/commands/slash.test.js src/shell/repl.test.js src/runtime/store.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/auth.test.js",
|
|
14
|
+
"test": "node --test src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/agentEvents.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/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/commands/slash.test.js src/shell/repl.test.js src/runtime/store.test.js src/runtime/server.test.js src/runtime/supervisor.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/agent/graph.js
CHANGED
|
@@ -14,6 +14,37 @@ import { enqueueProductionJob, ensureJobQueue, formatQueue, productionLockBusy }
|
|
|
14
14
|
|
|
15
15
|
const MAX_TOOL_ITERATIONS = 80;
|
|
16
16
|
const MAX_SPINNER_ARG_LENGTH = 96;
|
|
17
|
+
|
|
18
|
+
// Deterministic guard: on the first turn of a fresh /agent input, only bind
|
|
19
|
+
// job-starting/mutating MCP tools when the raw text actually looks like an
|
|
20
|
+
// action request. Plain chat (greetings, thanks, small talk) never sees those
|
|
21
|
+
// tools bound, so the LLM cannot start a production job/plan from a "salut" —
|
|
22
|
+
// prompting alone was not reliable enough (see plan-directeur history).
|
|
23
|
+
const ACTION_INTENT_RE = /\b(lance|lancer|lancez|d[ée]marre|d[ée]marrer|d[ée]marrez|ex[ée]cute|ex[ée]cuter|exporte|exporter|export|importe|importer|import|g[ée]n[èe]re|g[ée]n[ée]rer|g[ée]n[ée]ration|cr[ée]e|cr[ée]er|construis|build|run|start|launch|deploy|d[ée]ploie|d[ée]ployer|d[ée]ploiement|publie|publier|publish|publication|synchronise|synchroniser|sync|convertis|convertir|convert|envoie|envoyer|send|configure|configurer|ajoute|ajouter|add|planifie|planifier|schedule|ingest|ing[èe]re|ing[èe]rer|ingestion|polish|pipeline|skill|job|t[âa]che|workflow|refais|relance|retente|retry)\b/i;
|
|
24
|
+
const READ_ONLY_TOOL_NAME_RE = /(^|_)(list|status|get|read|describe|show|search|find)(_|$)/i;
|
|
25
|
+
|
|
26
|
+
function isReadOnlyToolName(name) {
|
|
27
|
+
return READ_ONLY_TOOL_NAME_RE.test(String(name ?? ''));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function filterToolsForChatOnlyTurn(tools) {
|
|
31
|
+
return [
|
|
32
|
+
SHELL_READ_COMMAND_TOOL,
|
|
33
|
+
...tools.filter((tool) => isReadOnlyToolName(tool?.function?.name ?? '')),
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toolsForDonnaTurn(input, tools, iterations) {
|
|
38
|
+
if (iterations !== 0) return tools;
|
|
39
|
+
const text = String(input ?? '');
|
|
40
|
+
if (ACTION_INTENT_RE.test(text)) return tools;
|
|
41
|
+
// Plain chat (no action, no read intent) still gets the minimal read-only
|
|
42
|
+
// set, never an empty array: the system prompt unconditionally describes
|
|
43
|
+
// the connected MCP tools regardless of what's actually bound this turn,
|
|
44
|
+
// and sending zero tools while the prompt talks about them confuses some
|
|
45
|
+
// models into an empty/malformed completion instead of a plain reply.
|
|
46
|
+
return filterToolsForChatOnlyTurn(tools);
|
|
47
|
+
}
|
|
17
48
|
const AGENT_SLASH_COMMANDS = new Set([
|
|
18
49
|
'help',
|
|
19
50
|
'version',
|
|
@@ -51,6 +82,29 @@ const SHELL_RUN_COMMAND_TOOL = {
|
|
|
51
82
|
},
|
|
52
83
|
};
|
|
53
84
|
|
|
85
|
+
const SHELL_READ_COMMAND_TOOL = {
|
|
86
|
+
type: 'function',
|
|
87
|
+
function: {
|
|
88
|
+
name: 'shell__read_command',
|
|
89
|
+
description: [
|
|
90
|
+
'Run a read-only deterministic wiki-manager slash command inside the current shell session.',
|
|
91
|
+
'Allowed commands: /help, /version, /config, /config list, /config status, /status, /services, /skills, /skills list, /skills show <name>, /uploads, /uploads list, /queue.',
|
|
92
|
+
'Do not use for workspace creation/deletion, uploads conversion, service start/stop, MCP calls, wiki runs, or any mutation.',
|
|
93
|
+
].join(' '),
|
|
94
|
+
parameters: {
|
|
95
|
+
type: 'object',
|
|
96
|
+
additionalProperties: false,
|
|
97
|
+
properties: {
|
|
98
|
+
command: {
|
|
99
|
+
type: 'string',
|
|
100
|
+
description: 'Read-only slash command to run, for example "/status", "/config status", or "/services".',
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
required: ['command'],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
54
108
|
const WIKI_PLAN_SET_TOOL = {
|
|
55
109
|
type: 'function',
|
|
56
110
|
function: {
|
|
@@ -238,6 +292,24 @@ function assertAgentSlashCommandAllowed(commandLine) {
|
|
|
238
292
|
}
|
|
239
293
|
}
|
|
240
294
|
|
|
295
|
+
function assertAgentReadSlashCommandAllowed(commandLine) {
|
|
296
|
+
const parts = commandLine.slice(1).trim().split(/\s+/).filter(Boolean);
|
|
297
|
+
const command = parts[0] ?? '';
|
|
298
|
+
const subcommand = parts[1] ?? '';
|
|
299
|
+
const allowed =
|
|
300
|
+
command === 'help' ||
|
|
301
|
+
command === 'version' ||
|
|
302
|
+
command === 'status' ||
|
|
303
|
+
command === 'services' ||
|
|
304
|
+
command === 'queue' ||
|
|
305
|
+
(command === 'config' && ['', 'list', 'status'].includes(subcommand)) ||
|
|
306
|
+
(command === 'skills' && ['', 'list', 'show'].includes(subcommand)) ||
|
|
307
|
+
(command === 'uploads' && ['', 'list'].includes(subcommand));
|
|
308
|
+
if (!allowed) {
|
|
309
|
+
throw new Error(`Read-only command is not available to the agent: /${parts.join(' ')}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
241
313
|
function withActiveWorkspaceForExternalTool(session, server, tool, args) {
|
|
242
314
|
const needsWorkspace =
|
|
243
315
|
(server === 'documents' && tool.startsWith('documents_') && tool !== 'documents_status') ||
|
|
@@ -269,6 +341,21 @@ async function runShellCommandTool(session, commandLine) {
|
|
|
269
341
|
return result.output ?? 'Command completed.';
|
|
270
342
|
}
|
|
271
343
|
|
|
344
|
+
async function runShellReadCommandTool(session, commandLine) {
|
|
345
|
+
const command = normalizeShellCommand(commandLine);
|
|
346
|
+
assertAgentReadSlashCommandAllowed(command);
|
|
347
|
+
session._onStep?.(`Shell: ${command}`);
|
|
348
|
+
const result = await handleSlashCommand(command, {
|
|
349
|
+
packageJson: session.packageJson ?? { version: '0.0.0' },
|
|
350
|
+
session,
|
|
351
|
+
onStep: session._onStep,
|
|
352
|
+
});
|
|
353
|
+
if (result.exit) {
|
|
354
|
+
throw new Error('/exit is not available to the agent.');
|
|
355
|
+
}
|
|
356
|
+
return result.output ?? 'Command completed.';
|
|
357
|
+
}
|
|
358
|
+
|
|
272
359
|
function rememberProductionProgress(session, payload, label) {
|
|
273
360
|
const job = payload?.job;
|
|
274
361
|
const jobId = payload?.jobId ?? job?.jobId;
|
|
@@ -434,6 +521,7 @@ export function buildAgentSystemPrompt(state) {
|
|
|
434
521
|
const agentContext = [
|
|
435
522
|
'You are Donna, the terminal orchestrator agent for llm-wiki-manager.',
|
|
436
523
|
'The shell is agent-first: every input without a leading slash is routed to you.',
|
|
524
|
+
'Default to a plain conversational reply with no tool call. Only call a tool, create a plan, or start a job when the user\'s message clearly requests an action (ingest, build, export, configure, run a skill, check a concrete status, etc.). Greetings, small talk, thanks, and general questions do not warrant starting a job or calling a tool — just answer in text.',
|
|
437
525
|
'Commands starting with / are deterministic primitives. You may run a safe subset through shell__run_command.',
|
|
438
526
|
`Reply language: ${language}.`,
|
|
439
527
|
`Current workspace: ${workspace}.`,
|
|
@@ -512,12 +600,17 @@ export function buildLimitedAgentResponse(state, reason = 'no workspace loaded w
|
|
|
512
600
|
].join('\n');
|
|
513
601
|
}
|
|
514
602
|
|
|
603
|
+
export function formatLlmUnavailableMessage(reason) {
|
|
604
|
+
const clean = String(reason ?? 'raison inconnue').replace(/\s+/g, ' ').trim();
|
|
605
|
+
return `⚠ LLM injoignable : ${clean || 'raison inconnue'}`;
|
|
606
|
+
}
|
|
607
|
+
|
|
515
608
|
export function createAgentGraph(options = {}) {
|
|
516
609
|
async function orchestratorNode(state) {
|
|
517
610
|
const llm = state.session.llm ?? options.llm ?? null;
|
|
518
611
|
|
|
519
612
|
if (!llm) {
|
|
520
|
-
return { response:
|
|
613
|
+
return { response: formatLlmUnavailableMessage('aucun client LLM configure'), pendingToolCalls: null, readyToStream: false };
|
|
521
614
|
}
|
|
522
615
|
|
|
523
616
|
const iterations = state.toolIterations ?? 0;
|
|
@@ -535,12 +628,13 @@ export function createAgentGraph(options = {}) {
|
|
|
535
628
|
state.session._onStep?.('Agent: planning next action…');
|
|
536
629
|
}
|
|
537
630
|
|
|
538
|
-
const
|
|
631
|
+
const allTools = [
|
|
539
632
|
SHELL_RUN_COMMAND_TOOL,
|
|
540
633
|
WIKI_PLAN_SET_TOOL,
|
|
541
634
|
WIKI_PLAN_DONE_TOOL,
|
|
542
635
|
...buildLlmTools(state.session.mcp),
|
|
543
636
|
];
|
|
637
|
+
const tools = toolsForDonnaTurn(state.input, allTools, iterations);
|
|
544
638
|
const system = buildAgentSystemPrompt(state);
|
|
545
639
|
|
|
546
640
|
// On iteration 0: prior history is in state.messages, user input must be appended.
|
|
@@ -619,7 +713,7 @@ export function createAgentGraph(options = {}) {
|
|
|
619
713
|
} catch (err) {
|
|
620
714
|
if (err.name === 'AbortError') throw err;
|
|
621
715
|
const message = err instanceof Error ? err.message : String(err);
|
|
622
|
-
return { response:
|
|
716
|
+
return { response: formatLlmUnavailableMessage(message), pendingToolCalls: null, readyToStream: false };
|
|
623
717
|
}
|
|
624
718
|
}
|
|
625
719
|
|
|
@@ -663,6 +757,8 @@ export function createAgentGraph(options = {}) {
|
|
|
663
757
|
} else if (server === 'shell' && tool === 'run_command') {
|
|
664
758
|
await awaitRunApproval(state.session, { runId, tool: toolName });
|
|
665
759
|
resultText = await runShellCommandTool(state.session, args.command);
|
|
760
|
+
} else if (server === 'shell' && tool === 'read_command') {
|
|
761
|
+
resultText = await runShellReadCommandTool(state.session, args.command);
|
|
666
762
|
} else if (server !== 'shell') {
|
|
667
763
|
await awaitRunApproval(state.session, { runId, tool: toolName });
|
|
668
764
|
await awaitToolApproval(state.session, {
|
package/src/agent/graph.test.js
CHANGED
|
@@ -99,6 +99,87 @@ test('agent graph waits for run-level approval before first MCP action', async (
|
|
|
99
99
|
}
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
+
test('agent graph reports LLM unavailable without Donna active boilerplate', async () => {
|
|
103
|
+
const agent = createAgentGraph();
|
|
104
|
+
const result = await agent.invoke({ input: 'salut', session: sessionBase({ llm: null }) });
|
|
105
|
+
|
|
106
|
+
assert.equal(result.response, '⚠ LLM injoignable : aucun client LLM configure');
|
|
107
|
+
assert.doesNotMatch(result.response, /Donna is active/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('agent graph binds only the minimal read-only tool set for plain discussion, never an empty array', async () => {
|
|
111
|
+
const seenTools = [];
|
|
112
|
+
const session = sessionBase({
|
|
113
|
+
llm: {
|
|
114
|
+
async completeWithTools({ tools }) {
|
|
115
|
+
seenTools.push(...tools.map((tool) => tool.function.name));
|
|
116
|
+
return {
|
|
117
|
+
content: 'Salut, je suis là.',
|
|
118
|
+
message: { role: 'assistant', content: 'Salut, je suis là.' },
|
|
119
|
+
tool_calls: null,
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const agent = createAgentGraph();
|
|
126
|
+
const result = await agent.invoke({ input: 'salut', session });
|
|
127
|
+
|
|
128
|
+
assert.equal(result.response, 'Salut, je suis là.');
|
|
129
|
+
// Never literally empty: the system prompt unconditionally describes the
|
|
130
|
+
// connected MCP tools, and sending zero tools while the prompt talks about
|
|
131
|
+
// them risks an empty/malformed completion from some models instead of a
|
|
132
|
+
// plain reply — this is what actually broke plain "salut" discussion.
|
|
133
|
+
assert.ok(seenTools.length > 0);
|
|
134
|
+
assert.deepEqual(seenTools, ['shell__read_command']);
|
|
135
|
+
assert.equal(session.headlessPlan ?? null, null);
|
|
136
|
+
assert.equal(Object.keys(session.activities ?? {}).length, 0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('agent graph binds read-only tools for config questions without plan tools', async () => {
|
|
140
|
+
const seenTools = [];
|
|
141
|
+
const session = sessionBase({
|
|
142
|
+
mcp: {
|
|
143
|
+
production: {
|
|
144
|
+
status: 'connected',
|
|
145
|
+
url: 'http://127.0.0.1:3000/mcp/',
|
|
146
|
+
tools: [
|
|
147
|
+
{
|
|
148
|
+
name: 'production_start_job',
|
|
149
|
+
description: 'Start production job',
|
|
150
|
+
inputSchema: { type: 'object', properties: { type: { type: 'string' } } },
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: 'production_job_status',
|
|
154
|
+
description: 'Read production job status',
|
|
155
|
+
inputSchema: { type: 'object', properties: { jobId: { type: 'string' } } },
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
llm: {
|
|
161
|
+
async completeWithTools({ tools }) {
|
|
162
|
+
seenTools.push(...tools.map((tool) => tool.function.name));
|
|
163
|
+
return {
|
|
164
|
+
content: 'Le profil actif est docs.',
|
|
165
|
+
message: { role: 'assistant', content: 'Le profil actif est docs.' },
|
|
166
|
+
tool_calls: null,
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const agent = createAgentGraph();
|
|
173
|
+
const result = await agent.invoke({ input: 'quel est le profil actif ?', session });
|
|
174
|
+
|
|
175
|
+
assert.equal(result.response, 'Le profil actif est docs.');
|
|
176
|
+
assert.ok(seenTools.includes('shell__read_command'));
|
|
177
|
+
assert.ok(seenTools.includes('production__production_job_status'));
|
|
178
|
+
assert.equal(seenTools.includes('wiki__plan_set'), false);
|
|
179
|
+
assert.equal(seenTools.includes('production__production_start_job'), false);
|
|
180
|
+
assert.equal(seenTools.includes('shell__run_command'), false);
|
|
181
|
+
});
|
|
182
|
+
|
|
102
183
|
test('agent graph waits for tool-level approval configured on endpoint', async () => {
|
|
103
184
|
const originalFetch = globalThis.fetch;
|
|
104
185
|
globalThis.fetch = async () => ({
|
package/src/cli/wiki-manager.js
CHANGED
|
@@ -29,6 +29,11 @@ function valueAfter(argv, flag) {
|
|
|
29
29
|
return argv[index + 1];
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function unavailableRuntime(err) {
|
|
33
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
34
|
+
return { url: null, error: reason };
|
|
35
|
+
}
|
|
36
|
+
|
|
32
37
|
function createSession() {
|
|
33
38
|
return {
|
|
34
39
|
workspace: null,
|
|
@@ -745,7 +750,8 @@ export async function runCli(argv) {
|
|
|
745
750
|
const { ensureRuntime } = await import('../runtime/lifecycle.js');
|
|
746
751
|
runtime = await ensureRuntime();
|
|
747
752
|
} catch (err) {
|
|
748
|
-
|
|
753
|
+
runtime = unavailableRuntime(err);
|
|
754
|
+
console.error(`Runtime unavailable: ${runtime.error}`);
|
|
749
755
|
}
|
|
750
756
|
await runOpenTuiShell({ agent, packageJson, runtime });
|
|
751
757
|
return;
|
|
@@ -757,7 +763,8 @@ export async function runCli(argv) {
|
|
|
757
763
|
const { ensureRuntime } = await import('../runtime/lifecycle.js');
|
|
758
764
|
runtime = await ensureRuntime();
|
|
759
765
|
} catch (err) {
|
|
760
|
-
|
|
766
|
+
runtime = unavailableRuntime(err);
|
|
767
|
+
console.error(`Runtime unavailable: ${runtime.error}`);
|
|
761
768
|
}
|
|
762
769
|
}
|
|
763
770
|
await runShell({ agent, packageJson, runtime });
|
package/src/core/agentLoop.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { buildAgentSystemPrompt,
|
|
1
|
+
import { buildAgentSystemPrompt, formatLlmUnavailableMessage } from '../agent/graph.js';
|
|
2
2
|
import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
|
|
3
3
|
import { activitySnapshot, newNonTerminalActivities } from './activity.js';
|
|
4
4
|
import { extractHeadlessPlan, formatCompletedActivities, formatPlanStatus } from './plan.js';
|
|
5
|
-
import { formatReadyTaskPrompt, nextReadyPlanTask, readyPlanTasks } from './planPatch.js';
|
|
5
|
+
import { formatReadyTaskPrompt, nextReadyPlanTask, readyPlanTasks, sanitizePlanForExecution } from './planPatch.js';
|
|
6
6
|
|
|
7
7
|
export function abortError(message = 'Agent run cancelled.') {
|
|
8
8
|
const err = new Error(message);
|
|
@@ -31,7 +31,7 @@ export async function runAgentTurn(agent, session, input, {
|
|
|
31
31
|
if (session._abortSignal === signal) delete session._abortSignal;
|
|
32
32
|
}
|
|
33
33
|
if (result.streamedInline) {
|
|
34
|
-
return streamedContent.trim() ||
|
|
34
|
+
return streamedContent.trim() || formatLlmUnavailableMessage('flux vide');
|
|
35
35
|
}
|
|
36
36
|
if (result.response != null) return result.response;
|
|
37
37
|
if (result.readyToStream && session.llm?.stream) {
|
|
@@ -44,9 +44,9 @@ export async function runAgentTurn(agent, session, input, {
|
|
|
44
44
|
})) {
|
|
45
45
|
content += delta;
|
|
46
46
|
}
|
|
47
|
-
return content.trim() ||
|
|
47
|
+
return content.trim() || formatLlmUnavailableMessage('flux vide');
|
|
48
48
|
}
|
|
49
|
-
return
|
|
49
|
+
return formatLlmUnavailableMessage('reponse vide');
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
export async function runAgenticLoop(agent, session, initialInput, {
|
|
@@ -109,6 +109,7 @@ export async function runAgenticLoop(agent, session, initialInput, {
|
|
|
109
109
|
onPlanAlreadySet?.({ steps: session.headlessPlan });
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
|
+
sanitizeSessionPlan(session, { runId });
|
|
112
113
|
|
|
113
114
|
const newPending = newNonTerminalActivities(snapshot, session);
|
|
114
115
|
if (newPending.length === 0) {
|
|
@@ -158,6 +159,20 @@ export async function runAgenticLoop(agent, session, initialInput, {
|
|
|
158
159
|
return { ok: false, maxTurns: true };
|
|
159
160
|
}
|
|
160
161
|
|
|
162
|
+
function sanitizeSessionPlan(session, { runId = null } = {}) {
|
|
163
|
+
if (!session.headlessPlan) return;
|
|
164
|
+
const sanitized = sanitizePlanForExecution(session.headlessPlan);
|
|
165
|
+
if (sanitized.warnings.length === 0) return;
|
|
166
|
+
session.headlessPlan = sanitized.plan;
|
|
167
|
+
dispatchAgentEvent(session, createAgentEvent('runtime_log', {
|
|
168
|
+
origin: 'runtime',
|
|
169
|
+
runId,
|
|
170
|
+
payload: {
|
|
171
|
+
message: `plan warning: ${sanitized.warnings.join('; ')}`,
|
|
172
|
+
},
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
|
|
161
176
|
function pendingStepsPrompt(initialInput, plan, readyTask) {
|
|
162
177
|
return [
|
|
163
178
|
'Original task:',
|
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
3
|
import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
|
|
4
|
-
import { runAgenticLoop } from './agentLoop.js';
|
|
4
|
+
import { runAgentTurn, runAgenticLoop } from './agentLoop.js';
|
|
5
|
+
|
|
6
|
+
test('runAgentTurn returns a one-line LLM error on empty stream', async () => {
|
|
7
|
+
const session = {
|
|
8
|
+
commands: [],
|
|
9
|
+
llm: {
|
|
10
|
+
async *stream() {},
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
const agent = {
|
|
14
|
+
async invoke() {
|
|
15
|
+
return { readyToStream: true, streamContext: { messages: [] } };
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const response = await runAgentTurn(agent, session, 'salut');
|
|
20
|
+
|
|
21
|
+
assert.equal(response, '⚠ LLM injoignable : flux vide');
|
|
22
|
+
});
|
|
5
23
|
|
|
6
24
|
test('runAgenticLoop waits for new activities and continues with a completion summary', async () => {
|
|
7
25
|
const session = {
|
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.11.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.11.6';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
package/src/core/planPatch.js
CHANGED
|
@@ -86,7 +86,7 @@ export function rebasePlanPatch(patch, { currentRevision = 0 } = {}) {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
export function readyPlanTasks(plan) {
|
|
89
|
-
const
|
|
89
|
+
const { plan: tasks } = sanitizePlanForExecution(plan);
|
|
90
90
|
const done = new Set(tasks.filter((task) => task.status === 'done').map(taskId));
|
|
91
91
|
return tasks
|
|
92
92
|
.filter((task) => task.status === 'pending')
|
|
@@ -125,6 +125,36 @@ export function normalizeTask(raw, index = 0) {
|
|
|
125
125
|
};
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
export function sanitizePlanForExecution(plan) {
|
|
129
|
+
const tasks = (plan ?? []).map((step, index) => normalizeTask(step, index));
|
|
130
|
+
const warnings = [];
|
|
131
|
+
const ids = new Set(tasks.map(taskId));
|
|
132
|
+
for (const task of tasks) {
|
|
133
|
+
const before = task.dependsOn;
|
|
134
|
+
task.dependsOn = before.filter((dep) => ids.has(String(dep)));
|
|
135
|
+
if (task.dependsOn.length !== before.length) {
|
|
136
|
+
warnings.push(`unknown dependency removed from task ${taskId(task)}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const pending = tasks.filter((task) => task.status === 'pending');
|
|
140
|
+
const done = new Set(tasks.filter((task) => task.status === 'done').map(taskId));
|
|
141
|
+
const terminalBlocked = new Set(
|
|
142
|
+
tasks
|
|
143
|
+
.filter((task) => ['failed', 'cancelled', 'canceled'].includes(String(task.status).toLowerCase()))
|
|
144
|
+
.map(taskId),
|
|
145
|
+
);
|
|
146
|
+
const hasReady = pending.some((task) => task.dependsOn.every((dep) => done.has(String(dep))));
|
|
147
|
+
if (hasDependencyCycle(tasks)) {
|
|
148
|
+
warnings.push('dependency cycle broken by sequential fallback');
|
|
149
|
+
return { plan: sequentialize(tasks), warnings };
|
|
150
|
+
}
|
|
151
|
+
if (pending.length > 0 && !hasReady && !pending.some((task) => task.dependsOn.some((dep) => terminalBlocked.has(String(dep))))) {
|
|
152
|
+
warnings.push('no ready task after dependency cleanup; using sequential fallback');
|
|
153
|
+
return { plan: sequentialize(tasks), warnings };
|
|
154
|
+
}
|
|
155
|
+
return { plan: tasks, warnings };
|
|
156
|
+
}
|
|
157
|
+
|
|
128
158
|
function normalizePatchOperation(raw) {
|
|
129
159
|
if (!raw || typeof raw !== 'object') return null;
|
|
130
160
|
const op = String(raw.op ?? '');
|
|
@@ -198,6 +228,14 @@ function resequence(plan) {
|
|
|
198
228
|
});
|
|
199
229
|
}
|
|
200
230
|
|
|
231
|
+
function sequentialize(plan) {
|
|
232
|
+
const next = plan.map((task) => ({ ...task, dependsOn: [] }));
|
|
233
|
+
for (let index = 1; index < next.length; index += 1) {
|
|
234
|
+
next[index].dependsOn = [taskId(next[index - 1])];
|
|
235
|
+
}
|
|
236
|
+
return next;
|
|
237
|
+
}
|
|
238
|
+
|
|
201
239
|
function hasDependencyCycle(plan) {
|
|
202
240
|
const tasks = new Map(plan.map((task) => [taskId(task), task]));
|
|
203
241
|
const visiting = new Set();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import { applyPlanPatch, nextReadyPlanTask, readyPlanTasks, rebasePlanPatch } from './planPatch.js';
|
|
3
|
+
import { applyPlanPatch, nextReadyPlanTask, readyPlanTasks, rebasePlanPatch, sanitizePlanForExecution } from './planPatch.js';
|
|
4
4
|
|
|
5
5
|
test('applyPlanPatch adds a task and increments the plan revision', () => {
|
|
6
6
|
const result = applyPlanPatch([
|
|
@@ -61,3 +61,25 @@ test('applyPlanPatch rejects dependency cycles', () => {
|
|
|
61
61
|
assert.equal(result.ok, false);
|
|
62
62
|
assert.equal(result.reason, 'dependency_cycle');
|
|
63
63
|
});
|
|
64
|
+
|
|
65
|
+
test('sanitizePlanForExecution removes unknown dependencies and keeps execution sequential', () => {
|
|
66
|
+
const result = sanitizePlanForExecution([
|
|
67
|
+
{ step: 1, id: 'a', description: 'A', status: 'pending', dependsOn: ['missing'] },
|
|
68
|
+
{ step: 2, id: 'b', description: 'B', status: 'pending', dependsOn: ['a'] },
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
assert.deepEqual(result.plan.map((task) => task.dependsOn), [[], ['a']]);
|
|
72
|
+
assert.match(result.warnings.join('\n'), /unknown dependency/);
|
|
73
|
+
assert.deepEqual(readyPlanTasks(result.plan).map((task) => task.id), ['a']);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('sanitizePlanForExecution breaks cycles with declaration-order fallback', () => {
|
|
77
|
+
const result = sanitizePlanForExecution([
|
|
78
|
+
{ step: 1, id: 'a', description: 'A', status: 'pending', dependsOn: ['b'] },
|
|
79
|
+
{ step: 2, id: 'b', description: 'B', status: 'pending', dependsOn: ['a'] },
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
assert.deepEqual(result.plan.map((task) => task.dependsOn), [[], ['a']]);
|
|
83
|
+
assert.match(result.warnings.join('\n'), /cycle/);
|
|
84
|
+
assert.deepEqual(readyPlanTasks(result.plan).map((task) => task.id), ['a']);
|
|
85
|
+
});
|
package/src/runtime/client.js
CHANGED
|
@@ -97,6 +97,18 @@ export async function postRuntimeCancel({
|
|
|
97
97
|
return response.json();
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
export async function postRuntimeShutdown({
|
|
101
|
+
url = runtimeUrlFromEnv(),
|
|
102
|
+
token = runtimeToken(),
|
|
103
|
+
} = {}) {
|
|
104
|
+
const response = await fetch(runtimeEndpoint(url, '/shutdown'), {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: runtimeHeaders(token),
|
|
107
|
+
});
|
|
108
|
+
if (!response.ok) throw new Error(`Runtime shutdown failed: HTTP ${response.status}`);
|
|
109
|
+
return response.json();
|
|
110
|
+
}
|
|
111
|
+
|
|
100
112
|
export async function postRuntimeResume({
|
|
101
113
|
url = runtimeUrlFromEnv(),
|
|
102
114
|
token = runtimeToken(),
|