@dotdrelle/wiki-manager 0.15.50 → 0.15.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -992
- package/docker-compose.override.example.yml +19 -0
- package/package.json +10 -2
- package/src/agent/graph.js +36 -13
- package/src/agent/graph.test.js +38 -2
- package/src/cli/wiki-manager.js +19 -1
- package/src/cli/wiki-manager.test.js +31 -0
- package/src/commands/slash.js +2 -42
- package/src/commands/slash.test.js +9 -0
- package/src/core/agentEvents.js +40 -1
- package/src/core/agentEvents.test.js +29 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/googleGrants.test.js +13 -3
- package/src/core/mcp.js +1 -1
- package/src/core/runtimeLog.js +19 -3
- package/src/core/runtimeLog.test.js +27 -1
- package/src/core/skillChainView.js +19 -14
- package/src/core/skillChainView.test.js +2 -2
- package/src/core/skillInvocation.test.js +4 -4
- package/src/runtime/runner.js +2 -1
- package/src/runtime/server.test.js +3 -3
- package/src/runtime/skillRun.test.js +10 -10
- package/src/shell/LeftPane.tsx +11 -0
- package/src/shell/RightPane.tsx +26 -9
- package/src/shell/repl.test.js +2 -2
- package/src/shell/tui.tsx +4 -1
|
@@ -78,5 +78,24 @@
|
|
|
78
78
|
# resources:
|
|
79
79
|
# limits:
|
|
80
80
|
# cpus: '2.0'
|
|
81
|
+
#
|
|
82
|
+
# ── Host timezone (opt-in) ────────────────────────────────────────────────────
|
|
83
|
+
#
|
|
84
|
+
# Containers default to UTC, so runtime/production logs can read an hour or two
|
|
85
|
+
# off the host's wall clock. Mount the host's timezone to make them agree. This
|
|
86
|
+
# is deliberately NOT in the packaged file: the mount assumes /etc/localtime
|
|
87
|
+
# exists on the host (true on most Linux/macOS hosts, but not all), and a
|
|
88
|
+
# timezone is a property of the machine, not of the workspace.
|
|
89
|
+
#
|
|
90
|
+
# services:
|
|
91
|
+
# serve:
|
|
92
|
+
# volumes:
|
|
93
|
+
# - /etc/localtime:/etc/localtime:ro
|
|
94
|
+
# mcp-http:
|
|
95
|
+
# volumes:
|
|
96
|
+
# - /etc/localtime:/etc/localtime:ro
|
|
97
|
+
# production-mcp:
|
|
98
|
+
# volumes:
|
|
99
|
+
# - /etc/localtime:/etc/localtime:ro
|
|
81
100
|
|
|
82
101
|
services: {}
|
package/package.json
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotdrelle/wiki-manager",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.53",
|
|
4
4
|
"description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/dotdrelle/llm-wiki-manager.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/dotdrelle/llm-wiki-manager#readme",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/dotdrelle/llm-wiki-manager/issues"
|
|
12
|
+
},
|
|
5
13
|
"license": "PolyForm-Noncommercial-1.0.0",
|
|
6
|
-
"author": "
|
|
14
|
+
"author": "dotdrelle",
|
|
7
15
|
"type": "module",
|
|
8
16
|
"bin": {
|
|
9
17
|
"wiki-manager": "bin/wiki-manager",
|
package/src/agent/graph.js
CHANGED
|
@@ -70,7 +70,7 @@ const SHELL_RUN_COMMAND_TOOL = {
|
|
|
70
70
|
description: [
|
|
71
71
|
'Run a deterministic wiki-manager slash command inside the current shell session.',
|
|
72
72
|
'Allowed commands: /workspace list, /workspace init <name> [path], /use <workspace>, /config, /status, /services, /skills, /skills show <name>, /skills run <name>, /upload <path>, /upload convert <id|pending>.',
|
|
73
|
-
'Do not use for arbitrary system shell commands, /workspace delete, /
|
|
73
|
+
'Do not use for arbitrary system shell commands, /workspace delete, /wiki run, /start, /stop, /logs, or /exit.',
|
|
74
74
|
].join(' '),
|
|
75
75
|
parameters: {
|
|
76
76
|
type: 'object',
|
|
@@ -522,6 +522,27 @@ function delegationBlockerForDonna(rawFailure) {
|
|
|
522
522
|
});
|
|
523
523
|
}
|
|
524
524
|
|
|
525
|
+
function isUnresolvedTargetFailure(rawFailure) {
|
|
526
|
+
return /file does not exist|does not exist|no files match/i.test(rawFailure);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function unresolvedTargetForDonna(rawFailure) {
|
|
530
|
+
const cleaned = String(rawFailure ?? '')
|
|
531
|
+
.replace(/\b(?:provider|endpoint)=[^\s,]+/g, '')
|
|
532
|
+
.replace(/\[[^\]]*\.(?:agent_plan|agent_execute)\]/g, '')
|
|
533
|
+
.replace(/\s*Available capabilities:\s*[\s\S]*$/i, '')
|
|
534
|
+
.replace(/\s*<-\s*[\s\S]*$/s, '')
|
|
535
|
+
.replace(/\s{2,}/g, ' ')
|
|
536
|
+
.trim();
|
|
537
|
+
return JSON.stringify({
|
|
538
|
+
delegated: false,
|
|
539
|
+
blocker: 'unresolved_target',
|
|
540
|
+
reason: cleaned,
|
|
541
|
+
instruction:
|
|
542
|
+
'The target the user named does not match an existing file. Look up the available targets with the read-only list tools, then retry the delegation with the exact resolved path, or ask the user to confirm which target they meant. Never widen to an all-targets operation, and never expose exception names, tool names, or internal routing details.',
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
525
546
|
function summarizeToolArguments(rawArguments) {
|
|
526
547
|
if (!rawArguments || rawArguments === '{}') return '';
|
|
527
548
|
try {
|
|
@@ -869,17 +890,13 @@ export async function handleRuntimeControlTool(session, tool, args = {}) {
|
|
|
869
890
|
409 garde tout son sens.
|
|
870
891
|
*/
|
|
871
892
|
if (typeof session?._delegateWithinRun === 'function') {
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
});
|
|
880
|
-
} catch (err) {
|
|
881
|
-
return `Délégation refusée : ${err instanceof Error ? err.message : String(err)}`;
|
|
882
|
-
}
|
|
893
|
+
const inRun = await session._delegateWithinRun(objective);
|
|
894
|
+
return JSON.stringify({
|
|
895
|
+
delegated: true,
|
|
896
|
+
runId: inRun.runId,
|
|
897
|
+
summary: inRun.summary ?? null,
|
|
898
|
+
message: `Action lancée (${String(inRun.runId).slice(0, 8)}) après validation du plan réel : ${inRun.summary?.tasks ?? 0} tâche(s), ${inRun.summary?.agent ?? 'agent résolu'}. Exécution en cours.`,
|
|
899
|
+
});
|
|
883
900
|
}
|
|
884
901
|
const result = await postRuntimeDelegate(objective, { url, workspace });
|
|
885
902
|
return result?.runId
|
|
@@ -1877,7 +1894,8 @@ export function createAgentGraph(options = {}) {
|
|
|
1877
1894
|
if (tool === 'delegate' && /^Runtime control error \(delegate\):/i.test(resultText)) {
|
|
1878
1895
|
const delegationFailure = resultText
|
|
1879
1896
|
.replace(/^Runtime control error \(delegate\):\s*/i, '')
|
|
1880
|
-
.replace(/^Delegation failed during objective_resolution:\s*/i, '')
|
|
1897
|
+
.replace(/^Delegation failed during objective_resolution:\s*/i, '')
|
|
1898
|
+
.replace(/^Delegation failed during agent_plan:\s*/i, '');
|
|
1881
1899
|
const needsInput = delegationFailure.match(/^Delegation requires input:\s*(.+)$/i);
|
|
1882
1900
|
if (needsInput) {
|
|
1883
1901
|
// Missing provider-required fields are a conversational blocker,
|
|
@@ -1889,6 +1907,11 @@ export function createAgentGraph(options = {}) {
|
|
|
1889
1907
|
missingRequiredFields: needsInput[1].split(',').map((item) => item.trim()).filter(Boolean),
|
|
1890
1908
|
instruction: 'Ask the user for the missing required information. Do not expose internal validation details.',
|
|
1891
1909
|
});
|
|
1910
|
+
} else if (isUnresolvedTargetFailure(delegationFailure)) {
|
|
1911
|
+
// A named target that resolves to nothing is not an unsupported
|
|
1912
|
+
// action: Donna can look it up and retry (or ask), so the turn
|
|
1913
|
+
// must not be marked terminal here.
|
|
1914
|
+
resultText = unresolvedTargetForDonna(delegationFailure);
|
|
1892
1915
|
} else {
|
|
1893
1916
|
terminalFailure = delegationFailure;
|
|
1894
1917
|
resultText = delegationBlockerForDonna(delegationFailure);
|
package/src/agent/graph.test.js
CHANGED
|
@@ -476,7 +476,7 @@ test('an explicitly selected skill runs through the intra-runtime path with name
|
|
|
476
476
|
mainCalls += 1;
|
|
477
477
|
if (mainCalls === 1) return {
|
|
478
478
|
content: null, message: { role: 'assistant', content: null },
|
|
479
|
-
tool_calls: [{ id: 'skill', type: 'function', function: { name: 'runtime__run_skill', arguments: '{"skillName":"deliver","arguments":{"
|
|
479
|
+
tool_calls: [{ id: 'skill', type: 'function', function: { name: 'runtime__run_skill', arguments: '{"skillName":"deliver","arguments":{"deliverable":"Quarterly report"},"selectionKind":"explicit_name"}' } }],
|
|
480
480
|
};
|
|
481
481
|
return { content: 'Skill mis en file.', message: { role: 'assistant', content: 'Skill mis en file.' }, tool_calls: null };
|
|
482
482
|
},
|
|
@@ -489,7 +489,7 @@ test('an explicitly selected skill runs through the intra-runtime path with name
|
|
|
489
489
|
// quelles compétences sont déjà ouvertes au-dessus de lui.
|
|
490
490
|
assert.deepEqual(calls, [[
|
|
491
491
|
'deliver',
|
|
492
|
-
{
|
|
492
|
+
{ deliverable: 'Quarterly report' },
|
|
493
493
|
{ selectionKind: 'explicit_name', turnId: 'turn-skill-1', skillStack: [] },
|
|
494
494
|
]]);
|
|
495
495
|
});
|
|
@@ -1334,6 +1334,42 @@ test('a delegation missing required provider inputs returns to Donna for clarifi
|
|
|
1334
1334
|
}
|
|
1335
1335
|
});
|
|
1336
1336
|
|
|
1337
|
+
test('a delegation whose named target does not resolve returns to Donna to resolve, not as a terminal refusal', async () => {
|
|
1338
|
+
let calls = 0;
|
|
1339
|
+
const session = sessionBase({
|
|
1340
|
+
runtime: { url: 'http://runtime.test' },
|
|
1341
|
+
_delegateWithinRun: async () => {
|
|
1342
|
+
throw new Error('Delegation failed during agent_plan: provider=production endpoint=http://127.0.0.1:3000/mcp/ templates file does not exist: basic note');
|
|
1343
|
+
},
|
|
1344
|
+
llm: {
|
|
1345
|
+
async completeWithTools() {
|
|
1346
|
+
calls += 1;
|
|
1347
|
+
if (calls === 1) {
|
|
1348
|
+
return {
|
|
1349
|
+
content: null,
|
|
1350
|
+
message: { role: 'assistant', content: null },
|
|
1351
|
+
tool_calls: [{
|
|
1352
|
+
id: 'delegate-target',
|
|
1353
|
+
type: 'function',
|
|
1354
|
+
function: { name: 'runtime__delegate', arguments: '{"objective":"build basic note"}' },
|
|
1355
|
+
}],
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
return {
|
|
1359
|
+
content: 'Je n’ai trouvé aucun template « basic note ».',
|
|
1360
|
+
message: { role: 'assistant', content: 'Je n’ai trouvé aucun template « basic note ».' },
|
|
1361
|
+
tool_calls: null,
|
|
1362
|
+
};
|
|
1363
|
+
},
|
|
1364
|
+
},
|
|
1365
|
+
});
|
|
1366
|
+
|
|
1367
|
+
const result = await createAgentGraph().invoke({ input: 'build basic note', session });
|
|
1368
|
+
assert.equal(result.terminalToolFailure, false);
|
|
1369
|
+
assert.equal(result.response, 'Je n’ai trouvé aucun template « basic note ».');
|
|
1370
|
+
assert.doesNotMatch(result.response, /provider=|endpoint=|file does not exist|agent_plan/i);
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1337
1373
|
// Guard: the system prompt must never show a connected tool's bare name
|
|
1338
1374
|
// outside its qualified server__tool form. Bare mentions are what teach the
|
|
1339
1375
|
// model to emit unqualified tool calls (the cme_status incident). The bare
|
package/src/cli/wiki-manager.js
CHANGED
|
@@ -109,12 +109,20 @@ export function buildExecutorOnlyFragment({ objective, workspace, selection }) {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
/**
|
|
112
|
-
* Fill a
|
|
112
|
+
* Fill a task's structured arguments from the natural-language objective,
|
|
113
113
|
* generically — against the capability's own declared `inputSchema`, with no
|
|
114
114
|
* per-agent or per-provider knowledge in the manager. This lets Donna honour
|
|
115
115
|
* stated constraints ("les 10 derniers mails", "de LinkedIn") while keeping
|
|
116
116
|
* `runtime__delegate` agnostic (it still only carries the objective).
|
|
117
117
|
*
|
|
118
|
+
* Used on BOTH delegation branches:
|
|
119
|
+
* - executor-only (`singleTaskOnly`) agents: the extracted arguments become the
|
|
120
|
+
* single task's `arguments`;
|
|
121
|
+
* - planner (`canPlan`) agents: the extracted arguments are forwarded to
|
|
122
|
+
* `agent_plan` as its `arguments`, so a targeted selector (a template, a
|
|
123
|
+
* deliverable, a source) reaches the plan instead of widening to "all"
|
|
124
|
+
* (a `/wiki-build <template>` that built every template).
|
|
125
|
+
*
|
|
118
126
|
* Degrades gracefully (cf. provider compatibility): forced tool_choice first,
|
|
119
127
|
* then a JSON-text completion, then no arguments — the executor uses its own
|
|
120
128
|
* defaults. It never throws and never invents identifiers.
|
|
@@ -1234,6 +1242,13 @@ async function runRuntime(argv, agent) {
|
|
|
1234
1242
|
let fragment;
|
|
1235
1243
|
if (canPlan) {
|
|
1236
1244
|
try {
|
|
1245
|
+
const extractedArguments = await resolveExecutorArguments({
|
|
1246
|
+
llm: session.llm,
|
|
1247
|
+
objective,
|
|
1248
|
+
capability: provider.capability,
|
|
1249
|
+
workspace: session.workspace ?? context.workspace ?? '',
|
|
1250
|
+
signal: session._abortSignal,
|
|
1251
|
+
});
|
|
1237
1252
|
planResult = await callMcpTool(
|
|
1238
1253
|
session.mcp,
|
|
1239
1254
|
provider.serverName,
|
|
@@ -1242,6 +1257,9 @@ async function runRuntime(argv, agent) {
|
|
|
1242
1257
|
capability: selection.capability,
|
|
1243
1258
|
operation: selection.operation,
|
|
1244
1259
|
objective,
|
|
1260
|
+
...(extractedArguments && Object.keys(extractedArguments).length > 0
|
|
1261
|
+
? { arguments: extractedArguments }
|
|
1262
|
+
: {}),
|
|
1245
1263
|
workspace: { revision: String(Date.now()) },
|
|
1246
1264
|
constraints: {
|
|
1247
1265
|
maxConcurrency: resolveCapabilityConcurrency(
|
|
@@ -126,6 +126,37 @@ test('argument extraction falls back to a JSON-text completion', async () => {
|
|
|
126
126
|
assert.deepEqual(args, { query: 'from:linkedin.com' });
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
+
const BUILD_CAPABILITY = {
|
|
130
|
+
description: 'Build llm-wiki deliverables from templates in templates/.',
|
|
131
|
+
inputSchema: {
|
|
132
|
+
type: 'object',
|
|
133
|
+
additionalProperties: true,
|
|
134
|
+
properties: {
|
|
135
|
+
templates: { type: 'array', items: { type: 'string' } },
|
|
136
|
+
stabilize: { type: 'boolean' },
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
test('argument extraction maps a skill template selector to the templates array', async () => {
|
|
142
|
+
// Regression: a skill carries its `template` parameter as a natural-language
|
|
143
|
+
// "User parameters:" block. The delegation path must turn it back into the
|
|
144
|
+
// structured `templates` array, or a targeted build widens to every template.
|
|
145
|
+
const llm = {
|
|
146
|
+
completeWithTools: async () => ({
|
|
147
|
+
tool_calls: [{
|
|
148
|
+
function: { name: 'set_task_arguments', arguments: JSON.stringify({ templates: ['overview'] }) },
|
|
149
|
+
}],
|
|
150
|
+
}),
|
|
151
|
+
};
|
|
152
|
+
const args = await resolveExecutorArguments({
|
|
153
|
+
llm,
|
|
154
|
+
objective: 'Build deliverables from the current wiki within the exact scope requested by the template parameter.\n\nUser parameters:\ntemplate: overview',
|
|
155
|
+
capability: BUILD_CAPABILITY,
|
|
156
|
+
});
|
|
157
|
+
assert.deepEqual(args, { templates: ['overview'] });
|
|
158
|
+
});
|
|
159
|
+
|
|
129
160
|
test('argument extraction stays agnostic and safe when it cannot extract', async () => {
|
|
130
161
|
// No inputSchema → no extraction attempted at all.
|
|
131
162
|
assert.deepEqual(
|
package/src/commands/slash.js
CHANGED
|
@@ -32,9 +32,7 @@ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
|
32
32
|
import {
|
|
33
33
|
cancelQueueItem,
|
|
34
34
|
clearFinishedQueueItems,
|
|
35
|
-
enqueueProductionJob,
|
|
36
35
|
formatQueue,
|
|
37
|
-
productionLockBusy,
|
|
38
36
|
} from '../core/jobQueue.js';
|
|
39
37
|
import {
|
|
40
38
|
listWikircProfiles,
|
|
@@ -599,11 +597,6 @@ async function createWorkspaceCommand(context, workspaceName, targetPath) {
|
|
|
599
597
|
}
|
|
600
598
|
|
|
601
599
|
|
|
602
|
-
function formatMcpCallActivity(serverName, toolName, resultText) {
|
|
603
|
-
if (serverName === 'production') return null;
|
|
604
|
-
return formatActivitySummary(serverName, toolName, resultText);
|
|
605
|
-
}
|
|
606
|
-
|
|
607
600
|
function publishPayloadActivity(session, payload, context = {}) {
|
|
608
601
|
const activity = extractActivity(payload, context);
|
|
609
602
|
if (!activity) return null;
|
|
@@ -748,7 +741,7 @@ ${helpPair('/stop [all|everything|service|agents]', 'Stop service(s)', '/logs <s
|
|
|
748
741
|
${helpPair('/skills', 'List skills', '/skills show <n>', 'Show skill')}
|
|
749
742
|
${helpPair('/skills run <n>', 'Run skill guide', '/skills edit <n>', 'Edit skill')}
|
|
750
743
|
${helpPair('/mcp status', 'MCP status', '/mcp endpoints', 'MCP endpoints')}
|
|
751
|
-
${helpPair('/mcp tools [mcp]', 'MCP tools', '
|
|
744
|
+
${helpPair('/mcp tools [mcp]', 'MCP tools', '', '')}
|
|
752
745
|
${helpPair('/connector list', 'Connector auth status', '/connector auth <n>', 'Authorize connector')}
|
|
753
746
|
${helpPair('/upload <path>', 'Upload document', '/uploads', 'Uploaded docs')}
|
|
754
747
|
${helpPair('/upload convert pending', 'Convert pending', '/uploads clean', 'Clean uploads')}
|
|
@@ -1218,40 +1211,7 @@ export async function handleSlashCommand(line, context) {
|
|
|
1218
1211
|
}
|
|
1219
1212
|
return { output: formatMcpTools(context.session.mcp, filterName) };
|
|
1220
1213
|
}
|
|
1221
|
-
|
|
1222
|
-
const serverName = args[2];
|
|
1223
|
-
const toolName = args[3];
|
|
1224
|
-
if (!serverName || !toolName) {
|
|
1225
|
-
return { output: 'Usage: /mcp call <mcp> <tool> [json]' };
|
|
1226
|
-
}
|
|
1227
|
-
try {
|
|
1228
|
-
const rawArgs = args.slice(4).join(' ');
|
|
1229
|
-
let toolArgs = rawArgs ? JSON.parse(rawArgs) : {};
|
|
1230
|
-
if (serverName === 'production' && toolName === 'production_start_job' && context.session.workspace && !toolArgs.callerLabel) {
|
|
1231
|
-
toolArgs = { ...toolArgs, callerLabel: `${context.session.workspace}/wiki-manager` };
|
|
1232
|
-
}
|
|
1233
|
-
if (serverName === 'production' && toolName === 'production_start_job' && productionLockBusy(context.session)) {
|
|
1234
|
-
const item = enqueueProductionJob(context.session, toolArgs, 'production lock busy');
|
|
1235
|
-
return { output: `Queued ${item.id}: waiting ${item.workspace ?? 'no-workspace'} ${item.tool}` };
|
|
1236
|
-
}
|
|
1237
|
-
step(`MCP: calling ${serverName}.${toolName}…`);
|
|
1238
|
-
const result = await callMcpTool(context.session.mcp, serverName, toolName, toolArgs);
|
|
1239
|
-
const output = formatMcpToolResult(result);
|
|
1240
|
-
const payload = parseJsonText(output);
|
|
1241
|
-
if (serverName === 'production' && toolName === 'production_start_job' && payload?.ok === false && payload?.error === 'workspace_busy') {
|
|
1242
|
-
const item = enqueueProductionJob(context.session, toolArgs, 'workspace_busy');
|
|
1243
|
-
return { output: `Queued ${item.id}: waiting for production lock (${payload.activeJobId ?? 'active job'})` };
|
|
1244
|
-
}
|
|
1245
|
-
const activity = formatMcpCallActivity(serverName, toolName, output);
|
|
1246
|
-
if (activity) step(activity);
|
|
1247
|
-
return rawCommandResult(`/mcp call ${serverName} ${toolName}`, output);
|
|
1248
|
-
} catch (err) {
|
|
1249
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1250
|
-
step(formatActivityError(serverName, toolName, err));
|
|
1251
|
-
return { output: message };
|
|
1252
|
-
}
|
|
1253
|
-
}
|
|
1254
|
-
return { output: 'Usage: /mcp <status|endpoints|tools|call> [mcp]' };
|
|
1214
|
+
return { output: 'Usage: /mcp <status|endpoints|tools> [mcp]' };
|
|
1255
1215
|
}
|
|
1256
1216
|
case 'connector': {
|
|
1257
1217
|
const subcommand = args[1] ?? 'list';
|
|
@@ -34,6 +34,15 @@ test('/status MCP overview contains only connector name, port and status', () =>
|
|
|
34
34
|
assert.doesNotMatch(output, /tools|error|detail|http/i);
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
test('/mcp exposes diagnostics only and cannot directly execute arbitrary MCP tools', async () => {
|
|
38
|
+
const result = await handleSlashCommand('/mcp call production production_start_job {"step":"ingest"}', {
|
|
39
|
+
packageJson: { version: 'test' },
|
|
40
|
+
session: { mcp: {} },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
assert.equal(result.output, 'Usage: /mcp <status|endpoints|tools> [mcp]');
|
|
44
|
+
});
|
|
45
|
+
|
|
37
46
|
test('/status base URL displays only its domain while retaining the full link', () => {
|
|
38
47
|
assert.equal(
|
|
39
48
|
compactBaseUrl('https://albert.api.etalab.gouv.fr/v1'),
|
package/src/core/agentEvents.js
CHANGED
|
@@ -2,7 +2,7 @@ import { normalizeActivity } from './activity.js';
|
|
|
2
2
|
import { attachActivityToExistingPlan, syncActivitiesToPlan } from './plan.js';
|
|
3
3
|
import { applyPlanPatch, normalizePlanPatch, normalizePlanRevision, rebasePlanPatch } from './planPatch.js';
|
|
4
4
|
import { formatRuntimeLogPayload } from './runtimeLog.js';
|
|
5
|
-
import { projectSkillChains } from './skillChainView.js';
|
|
5
|
+
import { projectSkillChains, TERMINAL as CONTROL_TERMINAL_STATUSES } from './skillChainView.js';
|
|
6
6
|
import { projectWorkflow } from './workflow.js';
|
|
7
7
|
import { validateContractInDev } from '../contracts/schemas.js';
|
|
8
8
|
import { isTerminal, isSuccessful, isUnknownStatus, normalizeTaskStatus } from '../orchestrator/taskStatuses.js';
|
|
@@ -269,6 +269,7 @@ function applyEvent(state, event) {
|
|
|
269
269
|
state.planRevision = 0;
|
|
270
270
|
state.planPatches = [];
|
|
271
271
|
state.summary = null;
|
|
272
|
+
pruneTerminalControlItems(state.controlQueue);
|
|
272
273
|
return;
|
|
273
274
|
case 'user_message':
|
|
274
275
|
state.conversation.push({ role: 'user', content: String(event.payload?.content ?? '') });
|
|
@@ -711,6 +712,44 @@ function finishControlByRun(queue, runId, status, finishedAt) {
|
|
|
711
712
|
item.updatedAt = finishedAt;
|
|
712
713
|
}
|
|
713
714
|
|
|
715
|
+
/*
|
|
716
|
+
A new run makes the previous control items history.
|
|
717
|
+
|
|
718
|
+
`run_started` already resets plan, activities and logs, but the control queue
|
|
719
|
+
was left to accumulate: a cancelled or failed chain stayed in the CHAIN panel
|
|
720
|
+
across the next plan, and its terminal items kept counting in the queue ("Queue
|
|
721
|
+
(14)" over 4 live items). Prune here, not in the UI, so both projections agree.
|
|
722
|
+
|
|
723
|
+
A chain is dropped only once EVERY item is terminal — the active chain always
|
|
724
|
+
has a running/queued item and is therefore never pruned mid-flight. Standalone
|
|
725
|
+
control items (no chainId) are dropped as soon as they are terminal.
|
|
726
|
+
*/
|
|
727
|
+
function pruneTerminalControlItems(queue) {
|
|
728
|
+
const byChain = new Map();
|
|
729
|
+
const standalone = [];
|
|
730
|
+
for (const item of queue) {
|
|
731
|
+
if (item.chainId) {
|
|
732
|
+
if (!byChain.has(item.chainId)) byChain.set(item.chainId, []);
|
|
733
|
+
byChain.get(item.chainId).push(item);
|
|
734
|
+
} else {
|
|
735
|
+
standalone.push(item);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
const drop = new Set();
|
|
739
|
+
for (const items of byChain.values()) {
|
|
740
|
+
if (items.every((item) => CONTROL_TERMINAL_STATUSES.has(String(item.status ?? '').toLowerCase()))) {
|
|
741
|
+
for (const item of items) drop.add(item.id);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
for (const item of standalone) {
|
|
745
|
+
if (CONTROL_TERMINAL_STATUSES.has(String(item.status ?? '').toLowerCase())) drop.add(item.id);
|
|
746
|
+
}
|
|
747
|
+
if (!drop.size) return;
|
|
748
|
+
for (let i = queue.length - 1; i >= 0; i--) {
|
|
749
|
+
if (drop.has(queue[i].id)) queue.splice(i, 1);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
714
753
|
function appendAssistantDelta(state, delta) {
|
|
715
754
|
if (!delta) return;
|
|
716
755
|
const last = state.conversation.at(-1);
|
|
@@ -401,6 +401,35 @@ test('reduceAgentEvents: control queue is event sourced and follows run status',
|
|
|
401
401
|
assert.equal(projection.controlQueue[1].status, 'cancelled');
|
|
402
402
|
});
|
|
403
403
|
|
|
404
|
+
test('reduceAgentEvents: run_started prunes terminal control items and fully terminal chains', () => {
|
|
405
|
+
const ts = '2026-01-01T00:00:00.000Z';
|
|
406
|
+
const enqueue = (id, extra = {}) => createAgentEvent('control_enqueued', {
|
|
407
|
+
origin: 'runtime',
|
|
408
|
+
workspace: 'docs',
|
|
409
|
+
payload: { id, workspace: 'docs', input: 'objective', createdAt: ts, ...extra },
|
|
410
|
+
});
|
|
411
|
+
const projection = reduceAgentEvents([
|
|
412
|
+
// Fully terminal chain (done + skipped): becomes history, pruned.
|
|
413
|
+
enqueue('chain-old-1', { chainId: 'chain-old', chainSequence: 1 }),
|
|
414
|
+
enqueue('chain-old-2', { chainId: 'chain-old', chainSequence: 2 }),
|
|
415
|
+
createAgentEvent('control_started', { origin: 'runtime', runId: 'run-old-1', workspace: 'docs', payload: { id: 'chain-old-1', runId: 'run-old-1' } }),
|
|
416
|
+
createAgentEvent('run_done', { origin: 'runtime', runId: 'run-old-1', workspace: 'docs' }),
|
|
417
|
+
createAgentEvent('control_skipped', { origin: 'runtime', workspace: 'docs', payload: { id: 'chain-old-2', reason: 'required_predecessor_failed' } }),
|
|
418
|
+
// Standalone terminal item: pruned.
|
|
419
|
+
enqueue('standalone-old'),
|
|
420
|
+
createAgentEvent('control_cancelled', { origin: 'runtime', workspace: 'docs', payload: { id: 'standalone-old' } }),
|
|
421
|
+
// Active chain (done + queued): must survive the prune.
|
|
422
|
+
enqueue('chain-active-1', { chainId: 'chain-active', chainSequence: 1 }),
|
|
423
|
+
enqueue('chain-active-2', { chainId: 'chain-active', chainSequence: 2 }),
|
|
424
|
+
createAgentEvent('control_started', { origin: 'runtime', runId: 'run-active-1', workspace: 'docs', payload: { id: 'chain-active-1', runId: 'run-active-1' } }),
|
|
425
|
+
createAgentEvent('run_done', { origin: 'runtime', runId: 'run-active-1', workspace: 'docs' }),
|
|
426
|
+
// A new run starts: terminal relics are history, the active chain is not.
|
|
427
|
+
createAgentEvent('run_started', { origin: 'runtime', runId: 'run-new', workspace: 'docs' }),
|
|
428
|
+
]);
|
|
429
|
+
|
|
430
|
+
assert.deepEqual(projection.controlQueue.map((item) => item.id), ['chain-active-1', 'chain-active-2']);
|
|
431
|
+
});
|
|
432
|
+
|
|
404
433
|
test('reduceAgentEvents: control_enqueued preserves a structured capabilityPlan across replay', () => {
|
|
405
434
|
const capabilityPlan = {
|
|
406
435
|
capability: 'workspace.restore',
|
package/src/core/buildInfo.json
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { GOOGLE_GRANTS, GOOGLE_GRANT_LABELS, defaultGoogleGrants } from './googleGrants.js';
|
|
6
6
|
|
|
7
|
+
// `agent-connectors` n'est pas cloné par le CI du manager, qui ne tire que
|
|
8
|
+
// llm-wiki, agent-wiki-production, agent-cme et agent-wiki-documents (voir
|
|
9
|
+
// check-versions.js). Les contrôles de cohérence croisée ci-dessous lisent la
|
|
10
|
+
// source de l'agent connectors : sans elle, on les saute plutôt que d'échouer
|
|
11
|
+
// sur un ENOENT. Le flux de release complet (build-and-push.sh) la fournit,
|
|
12
|
+
// donc le contrôle y reste effectif.
|
|
13
|
+
const connectorsPresent = existsSync(
|
|
14
|
+
fileURLToPath(new URL('../../../agent-external/agent-connectors', import.meta.url)),
|
|
15
|
+
);
|
|
16
|
+
|
|
7
17
|
const connectorsSrc = (file) =>
|
|
8
18
|
readFileSync(fileURLToPath(new URL(`../../../agent-external/agent-connectors/src/${file}`, import.meta.url)), 'utf8');
|
|
9
19
|
|
|
10
|
-
test('the grant names mirror the agent, spelling included', () => {
|
|
20
|
+
test('the grant names mirror the agent, spelling included', { skip: !connectorsPresent }, () => {
|
|
11
21
|
// `modify` est le nom de Google (scope gmail.modify) et celui de l'agent.
|
|
12
22
|
// Un synonyme côté manager créerait une troisième orthographe à tenir à jour
|
|
13
23
|
// — le travers qui avait déjà donné une seconde paire de variables OAuth.
|
|
@@ -26,7 +36,7 @@ test('every grant is described in plain words, never left as a bare token', () =
|
|
|
26
36
|
}
|
|
27
37
|
});
|
|
28
38
|
|
|
29
|
-
test('the default asks for everything the agent can actually do', () => {
|
|
39
|
+
test('the default asks for everything the agent can actually do', { skip: !connectorsPresent }, () => {
|
|
30
40
|
// Un défaut plus étroit promet des actions que l'autorisation ne couvre pas :
|
|
31
41
|
// `/connector auth google` ne demandait que `read`, et l'envoi comme le
|
|
32
42
|
// marquage échouaient après coup, en ressemblant à des fonctions absentes.
|
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.53';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
package/src/core/runtimeLog.js
CHANGED
|
@@ -65,7 +65,7 @@ export function formatRuntimeLogPayload(payload = {}, ts = null) {
|
|
|
65
65
|
// lines ended up visually glued at the bottom of Logs/Trace, out of
|
|
66
66
|
// chronology with the shell's own timestamped lines.
|
|
67
67
|
if (payload?.message != null && !payload.event) {
|
|
68
|
-
return [timeLabel(ts), String(payload.message)].filter(Boolean).join(' ');
|
|
68
|
+
return [timeLabel(ts), shortenUuids(String(payload.message))].filter(Boolean).join(' ');
|
|
69
69
|
}
|
|
70
70
|
const time = timeLabel(ts);
|
|
71
71
|
const event = eventLabel(payload.event);
|
|
@@ -75,10 +75,26 @@ export function formatRuntimeLogPayload(payload = {}, ts = null) {
|
|
|
75
75
|
if (payload.status != null) fields.push(formatField('status', payload.status));
|
|
76
76
|
if (payload.percent != null) fields.push(formatField('percent', payload.percent));
|
|
77
77
|
if (payload.outputs != null) fields.push(formatField('outputs', payload.outputs));
|
|
78
|
-
if (payload.detail != null && payload.detail !== '') fields.push(`detail=${quoteIfNeeded(payload.detail)}`);
|
|
78
|
+
if (payload.detail != null && payload.detail !== '') fields.push(`detail=${quoteIfNeeded(shortenUuids(payload.detail))}`);
|
|
79
79
|
return [time, event, ...fields].filter(Boolean).join(' ');
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// Runtime/agent ids are long UUIDs (run, task, attempt, agent instance). A full
|
|
83
|
+
// UUID pushed the readable fields off the line and wrapped mid-id, which is what
|
|
84
|
+
// made the Logs/Trace panel illegible. Collapse the UUID to its first 8 hex
|
|
85
|
+
// characters — the same disambiguating prefix every UI already shows — and cap
|
|
86
|
+
// over-long slugs so a single field never monopolises the line.
|
|
87
|
+
const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
|
|
88
|
+
|
|
89
|
+
function shortenUuids(text) {
|
|
90
|
+
return String(text ?? '').replace(UUID_RE, (uuid) => `${uuid.slice(0, 8)}…`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function shortLogId(value, { maxLength = 40 } = {}) {
|
|
94
|
+
const shortened = shortenUuids(value);
|
|
95
|
+
return shortened.length > maxLength ? `${shortened.slice(0, maxLength - 1)}…` : shortened;
|
|
96
|
+
}
|
|
97
|
+
|
|
82
98
|
export function runtimeLogMatchesFilter(line, filter = '') {
|
|
83
99
|
const query = String(filter ?? '').trim();
|
|
84
100
|
if (!query) return true;
|
|
@@ -122,7 +138,7 @@ function eventLabel(event) {
|
|
|
122
138
|
|
|
123
139
|
function formatField(key, value) {
|
|
124
140
|
if (value == null || value === '') return null;
|
|
125
|
-
return `${key}=${quoteIfNeeded(value)}`;
|
|
141
|
+
return `${key}=${quoteIfNeeded(shortenUuids(value))}`;
|
|
126
142
|
}
|
|
127
143
|
|
|
128
144
|
function quoteIfNeeded(value) {
|
|
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
|
|
2
2
|
import test from 'node:test';
|
|
3
3
|
|
|
4
4
|
import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
|
|
5
|
-
import { compactRuntimeLogForDisplay, formatRuntimeLogPayload } from './runtimeLog.js';
|
|
5
|
+
import { compactRuntimeLogForDisplay, formatRuntimeLogPayload, shortLogId } from './runtimeLog.js';
|
|
6
6
|
import { emitRuntimeLog } from '../runtime/supervisor.js';
|
|
7
7
|
|
|
8
8
|
const CYCLE_EVENTS = [
|
|
@@ -98,3 +98,29 @@ test('runtime display compaction leaves other log entries unchanged', () => {
|
|
|
98
98
|
const line = '09:25:35 trace: ERROR retrieval failed message="broken"';
|
|
99
99
|
assert.equal(compactRuntimeLogForDisplay(line), line);
|
|
100
100
|
});
|
|
101
|
+
|
|
102
|
+
test('long UUIDs collapse to a short prefix so log lines stay on one line', () => {
|
|
103
|
+
const uuid = '7fadad27-0be6-4d08-96e5-664fe7ee841e';
|
|
104
|
+
const line = formatRuntimeLogPayload({
|
|
105
|
+
event: 'task.ready',
|
|
106
|
+
runId: uuid,
|
|
107
|
+
taskId: `${uuid}:taxonomy-synthesis`,
|
|
108
|
+
attemptId: `attempt-${uuid}`,
|
|
109
|
+
agentInstanceId: `production-${uuid}`,
|
|
110
|
+
capability: 'document.build',
|
|
111
|
+
operation: 'build',
|
|
112
|
+
}, '2026-07-08T14:42:18.000Z');
|
|
113
|
+
|
|
114
|
+
assert.match(line, /run=7fadad27…/);
|
|
115
|
+
assert.match(line, /task=7fadad27…:taxonomy-synthesis/);
|
|
116
|
+
assert.match(line, /attempt=attempt-7fadad27…/);
|
|
117
|
+
assert.match(line, /agentInstance=production-7fadad27…/);
|
|
118
|
+
assert.doesNotMatch(line, /7fadad27-0be6-4d08-96e5-664fe7ee841e/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('shortLogId caps an over-long task slug while shortening embedded UUIDs', () => {
|
|
122
|
+
const long = `${'x'.repeat(48)}-deadbeef`;
|
|
123
|
+
assert.match(shortLogId(long), /…$/);
|
|
124
|
+
assert.ok(shortLogId(long).length <= 40);
|
|
125
|
+
assert.equal(shortLogId('7fadad27-0be6-4d08-96e5-664fe7ee841e'), '7fadad27…');
|
|
126
|
+
});
|