@dotdrelle/wiki-manager 0.15.52 → 0.15.54
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 +1 -1
- package/docker-compose.override.example.yml +19 -0
- package/package.json +1 -1
- 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
package/README.md
CHANGED
|
@@ -118,7 +118,7 @@ and a *replaceable* toolbox of *external* MCP servers — to produce the **core
|
|
|
118
118
|
wiki** outputs, all driven by an agentic, multi-model orchestrator and grounded
|
|
119
119
|
in isolated workspaces.
|
|
120
120
|
|
|
121
|
-

|
|
122
122
|
|
|
123
123
|
## Quick start — your first wiki in ~5 minutes
|
|
124
124
|
|
|
@@ -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
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.54';
|
|
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
|
+
});
|
|
@@ -16,7 +16,7 @@ const SYMBOLS = {
|
|
|
16
16
|
skipped: '–',
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
-
const TERMINAL = new Set(['done', 'failed', 'cancelled', 'skipped']);
|
|
19
|
+
export const TERMINAL = new Set(['done', 'failed', 'cancelled', 'skipped']);
|
|
20
20
|
|
|
21
21
|
// Objectives are whole paragraphs; a chain view needs a line. Keep the first
|
|
22
22
|
// sentence, drop the parameter block the compiler appends, and never cut a word
|
|
@@ -39,19 +39,24 @@ export function projectSkillChains(controlQueue = []) {
|
|
|
39
39
|
byChain.get(item.chainId).push(item);
|
|
40
40
|
}
|
|
41
41
|
return [...byChain.entries()].map(([chainId, chainItems]) => {
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
42
|
+
const sorted = chainItems.slice().sort((a, b) => Number(a.chainSequence ?? 0) - Number(b.chainSequence ?? 0));
|
|
43
|
+
const total = sorted.length;
|
|
44
|
+
// Compiled objectives are private execution material and never reach the
|
|
45
|
+
// projection, so a step cannot be labelled with its intent prose. When the
|
|
46
|
+
// chain has several steps, the public input is identical for all of them —
|
|
47
|
+
// labelling them all "/skill args" is what read as six tasks with the same
|
|
48
|
+
// name. Distinguish them by position instead; the skill name already sits
|
|
49
|
+
// in the chain head.
|
|
50
|
+
const steps = sorted.map((item, index) => ({
|
|
51
|
+
id: item.id,
|
|
52
|
+
sequence: Number(item.chainSequence ?? 0),
|
|
53
|
+
label: total > 1 ? `Step ${index + 1}/${total}` : chainStepLabel(item.input),
|
|
54
|
+
status: String(item.status ?? 'queued'),
|
|
55
|
+
symbol: SYMBOLS[String(item.status ?? 'queued')] ?? '○',
|
|
56
|
+
optional: item.optional === true,
|
|
57
|
+
...(item.skipReason ? { skipReason: item.skipReason } : {}),
|
|
58
|
+
...(item.runId ? { runId: item.runId } : {}),
|
|
59
|
+
}));
|
|
55
60
|
return {
|
|
56
61
|
chainId,
|
|
57
62
|
skillName: chainItems.find((item) => item.skillName)?.skillName ?? null,
|
|
@@ -19,7 +19,7 @@ test('a chain reads as ordered steps with one short label each', () => {
|
|
|
19
19
|
assert.equal(chain.selectionKind, 'description_match');
|
|
20
20
|
assert.equal(chain.status, 'running');
|
|
21
21
|
assert.deepEqual(chain.steps.map((step) => step.symbol), ['✓', '●']);
|
|
22
|
-
assert.equal(chain.steps[0].label, '
|
|
22
|
+
assert.equal(chain.steps[0].label, 'Step 1/2');
|
|
23
23
|
assert.equal(chain.steps[1].runId, 'run-b');
|
|
24
24
|
});
|
|
25
25
|
|
|
@@ -40,7 +40,7 @@ test('after a cancel the chain shows the cancelled step and the skipped remainde
|
|
|
40
40
|
assert.equal(chain.status, 'cancelled');
|
|
41
41
|
assert.equal(
|
|
42
42
|
renderSkillChain(chain),
|
|
43
|
-
['wiki-sync', '', '✓
|
|
43
|
+
['wiki-sync', '', '✓ Step 1/3', ' done', '× Step 2/3', ' cancelled', '– Step 3/3', ' skipped · chain_cancelled'].join('\n'),
|
|
44
44
|
);
|
|
45
45
|
});
|
|
46
46
|
|
|
@@ -9,7 +9,7 @@ import { formatSkillsForAgent, inspectSkills } from './skills.js';
|
|
|
9
9
|
test('matchSkillInvocation resolves only a real workspace skill', () => {
|
|
10
10
|
const root = mkdtempSync(join(tmpdir(), 'skill-invocation-'));
|
|
11
11
|
mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
|
|
12
|
-
writeFileSync(join(root, '.wiki', 'skills', 'deliver.md'), '---\nname: deliver\nparams:\n -
|
|
12
|
+
writeFileSync(join(root, '.wiki', 'skills', 'deliver.md'), '---\nname: deliver\nparams:\n - deliverable\n - polish\n---\nDeliver.');
|
|
13
13
|
const match = matchSkillInvocation({ workspacePath: root }, '/deliver "architecture" "improve security"');
|
|
14
14
|
assert.equal(match.skill.name, 'deliver');
|
|
15
15
|
assert.equal(match.rawArgs, '"architecture" "improve security"');
|
|
@@ -18,7 +18,7 @@ test('matchSkillInvocation resolves only a real workspace skill', () => {
|
|
|
18
18
|
|
|
19
19
|
test('parseSkillArguments preserves one free-form argument and parses quoted multi params', () => {
|
|
20
20
|
assert.deepEqual(parseSkillArguments({ params: ['files'] }, 'document A.md document B.md'), { files: 'document A.md document B.md' });
|
|
21
|
-
assert.deepEqual(parseSkillArguments({ params: ['
|
|
21
|
+
assert.deepEqual(parseSkillArguments({ params: ['deliverable', 'polish'] }, '"architecture-juno" "améliorer la sécurité réseau"'), { deliverable: 'architecture-juno', polish: 'améliorer la sécurité réseau' });
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
test('legacy placeholders remain supported and are reported', () => {
|
|
@@ -66,8 +66,8 @@ test('catalog renders declared parameters and marks a missing description explic
|
|
|
66
66
|
const root = mkdtempSync(join(tmpdir(), 'skill-catalog-'));
|
|
67
67
|
const dir = join(root, '.wiki', 'skills');
|
|
68
68
|
mkdirSync(dir, { recursive: true });
|
|
69
|
-
writeFileSync(join(dir, 'deliver.md'), '---\nname: deliver\nparams:\n -
|
|
69
|
+
writeFileSync(join(dir, 'deliver.md'), '---\nname: deliver\nparams:\n - deliverable\n - polish\n---\nDeliver.');
|
|
70
70
|
const inspection = inspectSkills({ workspacePath: root });
|
|
71
71
|
assert.deepEqual(inspection.warnings.map((item) => item.reason), ['missing_description']);
|
|
72
|
-
assert.match(formatSkillsForAgent({ workspacePath: root }), /\/deliver \[<
|
|
72
|
+
assert.match(formatSkillsForAgent({ workspacePath: root }), /\/deliver \[<deliverable> <polish>\]: workflow skill \[explicit name only\]/);
|
|
73
73
|
});
|
package/src/runtime/runner.js
CHANGED
|
@@ -14,6 +14,7 @@ import { assertValidatedFragment } from '../orchestrator/planValidator.js';
|
|
|
14
14
|
import { createResultAggregator } from '../orchestrator/resultAggregator.js';
|
|
15
15
|
import { describePlanConcurrency, drainActive, startReadyTasks } from '../orchestrator/scheduler.js';
|
|
16
16
|
import { emitRuntimeLog, pollActivitiesOnce } from './supervisor.js';
|
|
17
|
+
import { shortLogId } from '../core/runtimeLog.js';
|
|
17
18
|
|
|
18
19
|
// 0 by default: automatic replans turn evaluator/replanner TEXT into
|
|
19
20
|
// executable pseudo-tasks (no capability, no operation) that stall at 0%
|
|
@@ -713,7 +714,7 @@ export function skipImpossibleTasks(session, runId, { maxPasses = 50 } = {}) {
|
|
|
713
714
|
taskId: skippedId,
|
|
714
715
|
payload: { taskId: skippedId, status: 'skipped', reason: `dependency_failed:${because}` },
|
|
715
716
|
}));
|
|
716
|
-
emitRuntimeLog(session, `scheduler: skipping ${skippedId} (dependency failed: ${because})`);
|
|
717
|
+
emitRuntimeLog(session, `scheduler: skipping ${shortLogId(skippedId)} (dependency failed: ${because.split(', ').map((dep) => shortLogId(dep)).join(', ')})`);
|
|
717
718
|
}
|
|
718
719
|
total += changed;
|
|
719
720
|
if (changed === 0) break;
|
|
@@ -1723,7 +1723,7 @@ test('POST /turn keeps informational skill and build questions conversational',
|
|
|
1723
1723
|
test('POST /run accepts named skill arguments and deduplicates an explicit retry key', async (t) => {
|
|
1724
1724
|
const root = mkdtempSync(join(tmpdir(), 'runtime-named-skill-'));
|
|
1725
1725
|
mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
|
|
1726
|
-
writeFileSync(join(root, '.wiki', 'skills', 'deliver.md'), '---\nname: deliver\nparams:\n -
|
|
1726
|
+
writeFileSync(join(root, '.wiki', 'skills', 'deliver.md'), '---\nname: deliver\nparams:\n - deliverable\n - polish\n---\nDeliver the output.');
|
|
1727
1727
|
const session = { workspace: 'acme', workspacePath: root, controlQueue: [] };
|
|
1728
1728
|
const context = { workspace: 'acme', session, running: false, currentAbortController: null };
|
|
1729
1729
|
const persisted = new Map();
|
|
@@ -1746,7 +1746,7 @@ test('POST /run accepts named skill arguments and deduplicates an explicit retry
|
|
|
1746
1746
|
try {
|
|
1747
1747
|
const request = () => fetch(`http://127.0.0.1:${handle.port}/run?workspace=acme`, {
|
|
1748
1748
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1749
|
-
body: JSON.stringify({ input: '/deliver', skillName: 'deliver', skillArguments: {
|
|
1749
|
+
body: JSON.stringify({ input: '/deliver', skillName: 'deliver', skillArguments: { deliverable: 'Quarterly report' }, idempotencyKey: 'retry-1' }),
|
|
1750
1750
|
});
|
|
1751
1751
|
const first = await (await request()).json();
|
|
1752
1752
|
const second = await (await request()).json();
|
|
@@ -1756,7 +1756,7 @@ test('POST /run accepts named skill arguments and deduplicates an explicit retry
|
|
|
1756
1756
|
assert.equal(session.controlQueue.length, 1);
|
|
1757
1757
|
assert.equal('input' in first.items[0], false);
|
|
1758
1758
|
assert.equal('objectives' in first, false);
|
|
1759
|
-
assert.equal(session.controlQueue[0].input, '/deliver
|
|
1759
|
+
assert.equal(session.controlQueue[0].input, '/deliver deliverable="Quarterly report"');
|
|
1760
1760
|
} finally {
|
|
1761
1761
|
context.currentAbortController?.abort();
|
|
1762
1762
|
await handle.close();
|
|
@@ -2,24 +2,24 @@ import assert from 'node:assert/strict';
|
|
|
2
2
|
import test from 'node:test';
|
|
3
3
|
import { formatPublicSkillInvocation, runSkillChain, validateNamedSkillArguments } from './skillRun.js';
|
|
4
4
|
|
|
5
|
-
const skill = { name: 'deliver', params: ['
|
|
5
|
+
const skill = { name: 'deliver', params: ['deliverable', 'polish'], body: 'Deliver the requested output.' };
|
|
6
6
|
|
|
7
7
|
test('named skill arguments preserve spaces and fill omitted declarations with empty strings', () => {
|
|
8
|
-
const args = validateNamedSkillArguments(skill, {
|
|
8
|
+
const args = validateNamedSkillArguments(skill, { deliverable: 'Quarterly report' });
|
|
9
9
|
assert.equal(Object.getPrototypeOf(args), null);
|
|
10
|
-
assert.deepEqual({ ...args }, {
|
|
10
|
+
assert.deepEqual({ ...args }, { deliverable: 'Quarterly report', polish: '' });
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
test('named skill arguments reject undeclared, non-string, and oversized values', () => {
|
|
14
14
|
assert.throws(() => validateNamedSkillArguments(skill, { target: 'x' }), { code: 'skill_arguments_invalid' });
|
|
15
|
-
assert.throws(() => validateNamedSkillArguments(skill, {
|
|
16
|
-
assert.throws(() => validateNamedSkillArguments(skill, {
|
|
15
|
+
assert.throws(() => validateNamedSkillArguments(skill, { deliverable: 42 }), { code: 'skill_arguments_invalid' });
|
|
16
|
+
assert.throws(() => validateNamedSkillArguments(skill, { deliverable: 'x'.repeat(2_001) }), { code: 'skill_arguments_invalid' });
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
test('runSkillChain enqueues named arguments without exposing a skill body field', async () => {
|
|
20
20
|
const queued = [];
|
|
21
21
|
const result = await runSkillChain({ session: {} }, skill, {
|
|
22
|
-
args: {
|
|
22
|
+
args: { deliverable: 'Quarterly report' },
|
|
23
23
|
enqueueControlRequest(_context, input, metadata) {
|
|
24
24
|
const item = { id: `item-${queued.length}`, input, status: 'queued', ...metadata };
|
|
25
25
|
queued.push(item);
|
|
@@ -28,15 +28,15 @@ test('runSkillChain enqueues named arguments without exposing a skill body field
|
|
|
28
28
|
drainControlQueue() {},
|
|
29
29
|
});
|
|
30
30
|
assert.equal(result.objectives, 1);
|
|
31
|
-
assert.match(queued[0].input, /
|
|
32
|
-
assert.equal(queued[0].publicInput, '/deliver
|
|
31
|
+
assert.match(queued[0].input, /deliverable: Quarterly report/);
|
|
32
|
+
assert.equal(queued[0].publicInput, '/deliver deliverable="Quarterly report"');
|
|
33
33
|
assert.equal(queued[0].skillExecution, 'orchestrated');
|
|
34
34
|
assert.equal('body' in queued[0], false);
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
test('public skill invocation contains arguments but never compiled objective prose', () => {
|
|
38
|
-
const rendered = formatPublicSkillInvocation('deliver', {
|
|
39
|
-
assert.equal(rendered, '/deliver
|
|
38
|
+
const rendered = formatPublicSkillInvocation('deliver', { deliverable: 'Quarterly report', polish: '' });
|
|
39
|
+
assert.equal(rendered, '/deliver deliverable="Quarterly report"');
|
|
40
40
|
assert.doesNotMatch(rendered, /Deliver the requested output/);
|
|
41
41
|
});
|
|
42
42
|
|
package/src/shell/LeftPane.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { useRenderer } from '@opentui/solid';
|
|
|
5
5
|
import { colorForRenderedLine, helpCommandParts, keyValueParts, renderPlainMarkdown } from './renderer';
|
|
6
6
|
import { httpLinkParts, wrapHttpLinks } from './externalLinks.js';
|
|
7
7
|
import { normalizeExternalUrl } from './openExternal.js';
|
|
8
|
+
import { ActivityPanel } from './RightPane';
|
|
8
9
|
|
|
9
10
|
const LEGACY_DONNA_ROLE = 'do' + 't';
|
|
10
11
|
|
|
@@ -806,6 +807,7 @@ export function LeftPane(props: {
|
|
|
806
807
|
busy: boolean;
|
|
807
808
|
chatMode: boolean;
|
|
808
809
|
chatFocused: boolean;
|
|
810
|
+
activities: any[];
|
|
809
811
|
setInput: (value: string) => void;
|
|
810
812
|
submit: (value?: string) => void;
|
|
811
813
|
conversationRows: number;
|
|
@@ -851,6 +853,15 @@ export function LeftPane(props: {
|
|
|
851
853
|
onOpenLink={props.onOpenLink}
|
|
852
854
|
/>
|
|
853
855
|
)}
|
|
856
|
+
{/*
|
|
857
|
+
Activity strip. The run status used to live only in the right pane,
|
|
858
|
+
one glance away from where the reader types. A compact 4-line strip
|
|
859
|
+
above the composer keeps the current job in view while composing; the
|
|
860
|
+
right pane keeps the full Plan/Queue/Logs detail.
|
|
861
|
+
*/}
|
|
862
|
+
<box flexShrink={0} height={4} flexDirection="column" overflow="hidden">
|
|
863
|
+
<ActivityPanel activities={props.activities} width={props.width - 2} />
|
|
864
|
+
</box>
|
|
854
865
|
<ChatInput
|
|
855
866
|
width={props.width}
|
|
856
867
|
prompt={props.prompt}
|
package/src/shell/RightPane.tsx
CHANGED
|
@@ -9,6 +9,7 @@ type QueueItem = {
|
|
|
9
9
|
workspace?: string | null;
|
|
10
10
|
status: string;
|
|
11
11
|
args?: Record<string, any>;
|
|
12
|
+
label?: string;
|
|
12
13
|
jobId?: string;
|
|
13
14
|
error?: string;
|
|
14
15
|
reason?: string;
|
|
@@ -170,9 +171,10 @@ function activityJobName(activity: any) {
|
|
|
170
171
|
}
|
|
171
172
|
|
|
172
173
|
function queueSummary(item: QueueItem) {
|
|
174
|
+
const label = item.label ? String(item.label).trim() : '';
|
|
173
175
|
const args = item.args ?? {};
|
|
174
176
|
const parts = [
|
|
175
|
-
args.type
|
|
177
|
+
label || args.type || 'production',
|
|
176
178
|
Array.isArray(args.steps) && args.steps.length ? args.steps.join('+') : null,
|
|
177
179
|
Array.isArray(args.templates) && args.templates.length ? `tpl:${args.templates.length}` : null,
|
|
178
180
|
Array.isArray(args.deliverables) && args.deliverables.length ? `del:${args.deliverables.length}` : null,
|
|
@@ -180,10 +182,24 @@ function queueSummary(item: QueueItem) {
|
|
|
180
182
|
return parts.join(' ');
|
|
181
183
|
}
|
|
182
184
|
|
|
185
|
+
// A queue id is a long UUID (optionally `control-`-prefixed). The panel shows a
|
|
186
|
+
// short, recognisable prefix instead of the full string, which pushed the
|
|
187
|
+
// status and summary off the line and made the queue unreadable.
|
|
188
|
+
function shortQueueId(id: string) {
|
|
189
|
+
const value = String(id ?? '').replace(/^control-/, '');
|
|
190
|
+
return value.length > 12 ? `${value.slice(0, 12)}…` : value;
|
|
191
|
+
}
|
|
192
|
+
|
|
183
193
|
export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: string; summary?: string | null; spinnerFrame?: string }) {
|
|
184
194
|
// Keep one column for the native vertical scrollbar when the plan is long.
|
|
185
195
|
const lineWidth = () => Math.max(8, props.width - 3);
|
|
186
196
|
const firstPending = () => props.plan.find((s) => s.status === 'pending')?.step ?? null;
|
|
197
|
+
// A running step carries a thick left border, which costs one column: the
|
|
198
|
+
// wrap width is therefore one less for it. The same function feeds both the
|
|
199
|
+
// row-count memo and the render, so the viewport height can never undercount
|
|
200
|
+
// a running step's wrapped lines and clip the last one.
|
|
201
|
+
const isRunningStep = (step: PlanStep) => String(step.status ?? '').toLowerCase() === 'running';
|
|
202
|
+
const stepTextWidth = (step: PlanStep) => lineWidth() - (isRunningStep(step) ? 1 : 0);
|
|
187
203
|
const icon = (rawStatus: string) => {
|
|
188
204
|
const status = String(rawStatus ?? '').toLowerCase();
|
|
189
205
|
if (DONE_STATUSES.includes(status)) return '[✓]';
|
|
@@ -195,7 +211,7 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
|
|
|
195
211
|
return status === 'running' ? `[${props.spinnerFrame ?? '…'}]` : '[ ]';
|
|
196
212
|
};
|
|
197
213
|
const visualRows = createMemo(() => props.plan.reduce((total, step) =>
|
|
198
|
-
total + wrapLine(`${icon(step.status)} ${step.step}. ${step.description}`,
|
|
214
|
+
total + wrapLine(`${icon(step.status)} ${step.step}. ${step.description}`, stepTextWidth(step)).slice(0, 2).length, 0));
|
|
199
215
|
const title = () => {
|
|
200
216
|
const label = props.jobName ? `Plan : ${props.jobName}` : 'Plan';
|
|
201
217
|
return visualRows() > PLAN_VIEWPORT_ROWS ? `${label} (${props.plan.length}) · scroll` : label;
|
|
@@ -225,12 +241,14 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
|
|
|
225
241
|
{(step) => {
|
|
226
242
|
// Wrap step descriptions over up to 2 lines instead of truncating —
|
|
227
243
|
// "Ingest des 39 documents raw/untrac…" hid the actual target.
|
|
228
|
-
const
|
|
244
|
+
const running = () => isRunningStep(step());
|
|
245
|
+
const textWidth = () => stepTextWidth(step());
|
|
246
|
+
const lines = () => wrapLine(`${icon(step().status)} ${step().step}. ${step().description}`, textWidth()).slice(0, 2);
|
|
229
247
|
return (
|
|
230
|
-
<box flexShrink={0} flexDirection="column">
|
|
231
|
-
<text width={
|
|
248
|
+
<box flexShrink={0} flexDirection="column" border={running() ? ['left'] : undefined} borderStyle="heavy" borderColor="#89B4FA">
|
|
249
|
+
<text width={textWidth()} fg={planStepColor(step(), firstPending())} content={lines()[0]} />
|
|
232
250
|
<Show when={lines()[1]}>
|
|
233
|
-
<text width={
|
|
251
|
+
<text width={textWidth()} fg={planStepColor(step(), firstPending())} content={` ${fit(lines()[1], Math.max(8, textWidth() - 4))}`} />
|
|
234
252
|
</Show>
|
|
235
253
|
</box>
|
|
236
254
|
);
|
|
@@ -438,12 +456,12 @@ export function QueuePanel(props: { items: QueueItem[]; info: QueueInfo; width:
|
|
|
438
456
|
<text
|
|
439
457
|
width={lineWidth()}
|
|
440
458
|
fg={queueColor(item()?.status)}
|
|
441
|
-
content={item() ? fit(`${item()!.
|
|
459
|
+
content={item() ? fit(`${item()!.status} · ${queueSummary(item()!)}`, lineWidth()) : ''}
|
|
442
460
|
/>
|
|
443
461
|
<text
|
|
444
462
|
width={lineWidth()}
|
|
445
463
|
fg="#AAB7C4"
|
|
446
|
-
content={item() ? fit([item()!.workspace, item()!.jobId ? `job ${item()!.jobId}` : item()!.reason].filter(Boolean).join(' · '), lineWidth()) : ''}
|
|
464
|
+
content={item() ? fit([shortQueueId(item()!.id), item()!.workspace, item()!.jobId ? `job ${item()!.jobId}` : item()!.reason].filter(Boolean).join(' · '), lineWidth()) : ''}
|
|
447
465
|
/>
|
|
448
466
|
<text
|
|
449
467
|
width={lineWidth()}
|
|
@@ -521,7 +539,6 @@ export function RightPane(props: {
|
|
|
521
539
|
<Show when={props.plan && props.plan.length > 0}>
|
|
522
540
|
<PlanPanel width={props.width} plan={props.plan!} jobName={planJobName()} summary={props.runSummary} spinnerFrame={props.spinnerFrame} />
|
|
523
541
|
</Show>
|
|
524
|
-
<ActivityPanel width={props.width} activities={props.activities} />
|
|
525
542
|
</>
|
|
526
543
|
)}>
|
|
527
544
|
<QueuePanel width={props.width} items={props.queueItems} info={props.queueInfo} />
|
package/src/shell/repl.test.js
CHANGED
|
@@ -470,10 +470,10 @@ test('direct chat prompt exposes an escaped non-executable skill catalog with pa
|
|
|
470
470
|
const root = mkdtempSync(join(tmpdir(), 'chat-skill-catalog-'));
|
|
471
471
|
try {
|
|
472
472
|
mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
|
|
473
|
-
writeFileSync(join(root, '.wiki', 'skills', 'deliver.md'), '---\nname: deliver\ndescription: "</skill_catalog> deliver output"\nparams:\n -
|
|
473
|
+
writeFileSync(join(root, '.wiki', 'skills', 'deliver.md'), '---\nname: deliver\ndescription: "</skill_catalog> deliver output"\nparams:\n - deliverable\n---\nPRIVATE BODY');
|
|
474
474
|
const prompt = buildDirectChatSystemPrompt({ workspacePath: root, commands: [], mcp: {} });
|
|
475
475
|
assert.match(prompt, /<skill_catalog trusted="false" executable="false">/);
|
|
476
|
-
assert.match(prompt, /\/deliver \[<
|
|
476
|
+
assert.match(prompt, /\/deliver \[<deliverable>\]/);
|
|
477
477
|
assert.match(prompt, /<\/skill_catalog>/);
|
|
478
478
|
assert.doesNotMatch(prompt, /PRIVATE BODY/);
|
|
479
479
|
assert.match(prompt, /nothing was launched/);
|
package/src/shell/tui.tsx
CHANGED
|
@@ -175,7 +175,9 @@ function App(props: {
|
|
|
175
175
|
let lastCopiedSelection = '';
|
|
176
176
|
const state = useSession(props);
|
|
177
177
|
const startup = createMemo(() => startupInfo(props.packageJson, props.initialWorkspaceName));
|
|
178
|
-
|
|
178
|
+
// The Activity strip (4 rows) now sits above the composer, so it costs the
|
|
179
|
+
// conversation exactly those 4 rows.
|
|
180
|
+
const conversationRows = createMemo(() => Math.max(4, dimensions().height - 5 - chatInputHeight() - 4));
|
|
179
181
|
const rightColumns = createMemo(() => {
|
|
180
182
|
const width = dimensions().width;
|
|
181
183
|
// 38% + 2 columns / cap 58: the Plan/Activity/Logs panes carry job
|
|
@@ -381,6 +383,7 @@ function App(props: {
|
|
|
381
383
|
input={state.input()}
|
|
382
384
|
busy={state.busy()}
|
|
383
385
|
chatMode={state.chatMode()}
|
|
386
|
+
activities={state.activities()}
|
|
384
387
|
// The composer must lose focus for EVERY modal, not just the file
|
|
385
388
|
// editor. While the setup wizard was open the input stayed focused
|
|
386
389
|
// underneath it, so answering a wizard question also typed into the
|