@dotdrelle/wiki-manager 0.12.0 → 0.12.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/activity/activityAggregator.js +34 -3
- package/src/activity/activityAggregator.test.js +32 -0
- package/src/agent/graph.js +307 -29
- package/src/agent/graph.test.js +404 -1
- package/src/cli/wiki-manager.js +24 -1
- package/src/commands/slash.js +74 -1
- package/src/commands/slash.test.js +36 -0
- package/src/contracts/schemas.test.js +2 -2
- package/src/core/activity.js +4 -0
- package/src/core/activity.test.js +9 -1
- package/src/core/agentEvents.js +30 -0
- package/src/core/agentEvents.test.js +42 -1
- package/src/core/buildInfo.js +58 -0
- package/src/core/buildInfo.json +4 -0
- package/src/core/buildInfo.test.js +14 -0
- package/src/core/mcp.js +50 -3
- package/src/core/mcp.test.js +94 -1
- package/src/core/runtimeLog.js +6 -1
- package/src/core/runtimeLog.test.js +3 -1
- package/src/runtime/client.js +22 -0
- package/src/runtime/controlMessages.js +43 -0
- package/src/runtime/controlMessages.test.js +21 -0
- package/src/runtime/donna-contract.test.js +3 -3
- package/src/runtime/recoveryManager.js +54 -0
- package/src/runtime/recoveryManager.test.js +73 -0
- package/src/runtime/runner.js +49 -13
- package/src/runtime/runner.test.js +196 -0
- package/src/runtime/server.js +73 -13
- package/src/runtime/server.test.js +284 -67
- package/src/runtime/store.js +48 -3
- package/src/runtime/store.test.js +56 -24
- package/src/runtime/supervisor.js +77 -1
- package/src/shell/RightPane.tsx +124 -31
- package/src/shell/SetupWizard.tsx +13 -1
- package/src/shell/repl.js +81 -11
- package/src/shell/repl.test.js +115 -2
- package/src/shell/tui.tsx +4 -1
- package/src/shell/useAgent.ts +37 -4
- package/src/shell/useSession.ts +51 -10
package/src/core/agentEvents.js
CHANGED
|
@@ -39,6 +39,7 @@ const SESSION_PROJECTION_EVENTS = new Set([
|
|
|
39
39
|
'approval.rejected',
|
|
40
40
|
'control_enqueued',
|
|
41
41
|
'control_started',
|
|
42
|
+
'control_cancelled',
|
|
42
43
|
'agent.registered',
|
|
43
44
|
'agent.health_changed',
|
|
44
45
|
'run_done',
|
|
@@ -479,6 +480,11 @@ function applyEvent(state, event) {
|
|
|
479
480
|
case 'run_cancelled':
|
|
480
481
|
state.status = 'cancelled';
|
|
481
482
|
state.logs.push(String(event.payload?.message ?? 'Agent run cancelled.'));
|
|
483
|
+
// A cancelled run must not leave its plan steps "running/pending" and
|
|
484
|
+
// its activities spinning in the panels: mark every non-terminal one
|
|
485
|
+
// cancelled so the display reflects reality immediately.
|
|
486
|
+
cancelPendingPlanSteps(state.plan);
|
|
487
|
+
cancelActiveActivities(state.activities, event.ts);
|
|
482
488
|
finishControlByRun(state.controlQueue, event.runId ?? event.payload?.runId ?? null, 'cancelled', event.ts);
|
|
483
489
|
return;
|
|
484
490
|
case 'run_error':
|
|
@@ -505,6 +511,14 @@ function applyEvent(state, event) {
|
|
|
505
511
|
updatedAt: event.ts,
|
|
506
512
|
});
|
|
507
513
|
return;
|
|
514
|
+
case 'control_cancelled':
|
|
515
|
+
upsertControlItem(state.controlQueue, {
|
|
516
|
+
id: event.payload?.id ?? null,
|
|
517
|
+
status: 'cancelled',
|
|
518
|
+
finishedAt: event.payload?.finishedAt ?? event.ts,
|
|
519
|
+
updatedAt: event.ts,
|
|
520
|
+
});
|
|
521
|
+
return;
|
|
508
522
|
case 'agent.registered':
|
|
509
523
|
upsertAgent(state, event.payload?.agent, event.ts);
|
|
510
524
|
return;
|
|
@@ -652,6 +666,22 @@ function markCoveredApprovalsApproved(approvals, grant, ts) {
|
|
|
652
666
|
}
|
|
653
667
|
}
|
|
654
668
|
|
|
669
|
+
function cancelPendingPlanSteps(plan) {
|
|
670
|
+
for (const step of plan ?? []) {
|
|
671
|
+
if (!['done', 'failed', 'cancelled'].includes(String(step.status ?? ''))) step.status = 'cancelled';
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function cancelActiveActivities(activities, ts) {
|
|
676
|
+
for (const activity of Object.values(activities ?? {})) {
|
|
677
|
+
if (activity && activity.terminal !== true) {
|
|
678
|
+
activity.status = 'cancelled';
|
|
679
|
+
activity.terminal = true;
|
|
680
|
+
activity.updatedAt = ts ?? activity.updatedAt;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
655
685
|
function finishPendingPlanSteps(plan) {
|
|
656
686
|
for (const step of plan ?? []) {
|
|
657
687
|
if (step.status === 'running' || step.status === 'pending') {
|
|
@@ -279,6 +279,16 @@ test('reduceAgentEvents: control queue is event sourced and follows run status',
|
|
|
279
279
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
280
280
|
},
|
|
281
281
|
}),
|
|
282
|
+
createAgentEvent('control_enqueued', {
|
|
283
|
+
origin: 'runtime',
|
|
284
|
+
workspace: 'docs',
|
|
285
|
+
payload: {
|
|
286
|
+
id: 'control-2',
|
|
287
|
+
workspace: 'docs',
|
|
288
|
+
input: 'Never run',
|
|
289
|
+
createdAt: '2026-01-01T00:00:01.000Z',
|
|
290
|
+
},
|
|
291
|
+
}),
|
|
282
292
|
createAgentEvent('control_started', {
|
|
283
293
|
origin: 'runtime',
|
|
284
294
|
runId: 'run-control-1',
|
|
@@ -290,12 +300,19 @@ test('reduceAgentEvents: control queue is event sourced and follows run status',
|
|
|
290
300
|
runId: 'run-control-1',
|
|
291
301
|
workspace: 'docs',
|
|
292
302
|
}),
|
|
303
|
+
createAgentEvent('control_cancelled', {
|
|
304
|
+
origin: 'runtime',
|
|
305
|
+
workspace: 'docs',
|
|
306
|
+
payload: { id: 'control-2' },
|
|
307
|
+
}),
|
|
293
308
|
]);
|
|
294
309
|
|
|
295
|
-
assert.equal(projection.controlQueue.length,
|
|
310
|
+
assert.equal(projection.controlQueue.length, 2);
|
|
296
311
|
assert.equal(projection.controlQueue[0].id, 'control-1');
|
|
297
312
|
assert.equal(projection.controlQueue[0].status, 'done');
|
|
298
313
|
assert.equal(projection.controlQueue[0].runId, 'run-control-1');
|
|
314
|
+
assert.equal(projection.controlQueue[1].id, 'control-2');
|
|
315
|
+
assert.equal(projection.controlQueue[1].status, 'cancelled');
|
|
299
316
|
});
|
|
300
317
|
|
|
301
318
|
test('reduceAgentEvents: activity-owned plan is used when no orchestrator plan exists', () => {
|
|
@@ -399,3 +416,27 @@ test('reduceAgentEvents: plan patches are proposed, approved and applied with re
|
|
|
399
416
|
assert.equal(projection.plan[1].status, 'pending');
|
|
400
417
|
assert.equal(projection.planPatches[0].status, 'applied');
|
|
401
418
|
});
|
|
419
|
+
|
|
420
|
+
test('streamed narration split across tool iterations yields separate conversation entries', () => {
|
|
421
|
+
// graph.js finalizes the streaming entry (assistant_message content:'')
|
|
422
|
+
// before each tool batch so per-iteration narrations do not glue together
|
|
423
|
+
// into one wall of text.
|
|
424
|
+
const session = {};
|
|
425
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_delta', { origin: 'llm', payload: { delta: 'Analyse des jobs récents.' } }));
|
|
426
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_message', { origin: 'llm', payload: { content: '' } }));
|
|
427
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_delta', { origin: 'llm', payload: { delta: 'Voyons les logs.' } }));
|
|
428
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_message', { origin: 'llm', payload: { content: 'Voyons les logs.' } }));
|
|
429
|
+
|
|
430
|
+
const conversation = session.agentProjection.conversation;
|
|
431
|
+
assert.equal(conversation.length, 2);
|
|
432
|
+
assert.equal(conversation[0].content, 'Analyse des jobs récents.');
|
|
433
|
+
assert.equal(conversation[0].streaming ?? false, false);
|
|
434
|
+
assert.equal(conversation[1].content, 'Voyons les logs.');
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test('empty assistant_message finalize is a no-op without a streaming entry', () => {
|
|
438
|
+
const session = {};
|
|
439
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_message', { origin: 'llm', payload: { content: 'Réponse finale.' } }));
|
|
440
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_message', { origin: 'llm', payload: { content: '' } }));
|
|
441
|
+
assert.equal(session.agentProjection.conversation.length, 1);
|
|
442
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const packageRoot = resolve(here, '..', '..');
|
|
8
|
+
|
|
9
|
+
let cachedCommit;
|
|
10
|
+
|
|
11
|
+
// Short git commit identifying the code actually running. Resolution order:
|
|
12
|
+
// 1. Live git HEAD when running from the development repository — accurate
|
|
13
|
+
// even between releases (dirty trees still show the base commit).
|
|
14
|
+
// 2. buildInfo.json generated at pack time (scripts/check-versions.js) —
|
|
15
|
+
// what a published/global install carries.
|
|
16
|
+
// 3. null — displayed as "+dev" so an untraceable build is visible at a
|
|
17
|
+
// glance instead of silently pretending to match the repo.
|
|
18
|
+
export function buildCommit() {
|
|
19
|
+
if (cachedCommit !== undefined) return cachedCommit;
|
|
20
|
+
cachedCommit = liveGitCommit() ?? packagedCommit();
|
|
21
|
+
return cachedCommit;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function versionWithBuild(packageJson) {
|
|
25
|
+
const version = String(packageJson?.version ?? '').trim();
|
|
26
|
+
const commit = buildCommit();
|
|
27
|
+
return commit ? `${version}+${commit}` : `${version}+dev`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function liveGitCommit() {
|
|
31
|
+
try {
|
|
32
|
+
// Guard against walking up into an unrelated parent repository when the
|
|
33
|
+
// package is installed under a directory that happens to be git-tracked.
|
|
34
|
+
const toplevel = git(['rev-parse', '--show-toplevel']);
|
|
35
|
+
if (!toplevel || resolve(toplevel) !== packageRoot) return null;
|
|
36
|
+
return git(['rev-parse', '--short', 'HEAD']);
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function git(args) {
|
|
43
|
+
const output = execFileSync('git', args, {
|
|
44
|
+
cwd: packageRoot,
|
|
45
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
46
|
+
timeout: 2000,
|
|
47
|
+
}).toString().trim();
|
|
48
|
+
return output || null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function packagedCommit() {
|
|
52
|
+
try {
|
|
53
|
+
const info = JSON.parse(readFileSync(join(here, 'buildInfo.json'), 'utf8'));
|
|
54
|
+
return info?.commit ? String(info.commit) : null;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { buildCommit, versionWithBuild } from './buildInfo.js';
|
|
4
|
+
|
|
5
|
+
test('versionWithBuild always exposes a provenance suffix', () => {
|
|
6
|
+
const formatted = versionWithBuild({ version: '9.9.9' });
|
|
7
|
+
// From the development repo the live git short sha is used; from a packed
|
|
8
|
+
// install the buildInfo.json commit; '+dev' only when neither is available.
|
|
9
|
+
assert.match(formatted, /^9\.9\.9\+(?:[0-9a-f]{4,40}|dev)$/);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('buildCommit is stable across calls (cached)', () => {
|
|
13
|
+
assert.equal(buildCommit(), buildCommit());
|
|
14
|
+
});
|
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.12.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.12.7';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
|
@@ -174,7 +174,7 @@ function clarifyToolDescription(serverName, toolName, description) {
|
|
|
174
174
|
if (serverName === 'production' && toolName === 'production_start_job') {
|
|
175
175
|
return compactDescription([
|
|
176
176
|
base,
|
|
177
|
-
'Production export means wiki deliverable/publication export only. Do not use type=export for Confluence/CME/source export; use
|
|
177
|
+
'Production export means wiki deliverable/publication export only. Do not use type=export for Confluence/CME/source export; use cme__cme_export_run instead.',
|
|
178
178
|
].filter(Boolean).join(' '));
|
|
179
179
|
}
|
|
180
180
|
return base;
|
|
@@ -302,6 +302,26 @@ export function formatMcpToolResult(result) {
|
|
|
302
302
|
.trim() || 'No result.';
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
+
const DEFAULT_TOOL_RESULT_MAX_CHARS = 16000;
|
|
306
|
+
|
|
307
|
+
function toolResultMaxChars() {
|
|
308
|
+
const parsed = Number(process.env.WIKI_MANAGER_TOOL_RESULT_MAX_CHARS);
|
|
309
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : DEFAULT_TOOL_RESULT_MAX_CHARS;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Bound what a tool result injects into the LLM context and the conversation
|
|
313
|
+
// display. Apply this ONLY at those two exit points — never before payload
|
|
314
|
+
// parsing (extractActivity/_activity detection needs the full text).
|
|
315
|
+
// Head + tail are kept because errors and job ids often live at either end.
|
|
316
|
+
export function truncateToolResult(text, maxChars = toolResultMaxChars()) {
|
|
317
|
+
const full = String(text ?? '');
|
|
318
|
+
if (full.length <= maxChars) return full;
|
|
319
|
+
const headLength = Math.floor(maxChars * 0.7);
|
|
320
|
+
const tailLength = Math.floor(maxChars * 0.2);
|
|
321
|
+
const omitted = full.length - headLength - tailLength;
|
|
322
|
+
return `${full.slice(0, headLength)}\n\n[… ${omitted} caractères tronqués — résultat complet dans les logs runtime …]\n\n${full.slice(-tailLength)}`;
|
|
323
|
+
}
|
|
324
|
+
|
|
305
325
|
let _cachedEnvRetryPolicy = null;
|
|
306
326
|
function getEnvRetryPolicy() {
|
|
307
327
|
if (!_cachedEnvRetryPolicy) {
|
|
@@ -469,7 +489,9 @@ export function formatMcpToolsForAgent(mcpStatus) {
|
|
|
469
489
|
sections.push(`${name}: connected, tools not discovered yet`);
|
|
470
490
|
continue;
|
|
471
491
|
}
|
|
472
|
-
|
|
492
|
+
// Always advertise the qualified call name (server__tool): showing bare
|
|
493
|
+
// tool names here is what teaches the model to emit unqualified calls.
|
|
494
|
+
sections.push(`${name}: ${tools.map((tool) => `${name}__${tool.name}`).join(', ')}`);
|
|
473
495
|
}
|
|
474
496
|
return sections.length > 0 ? sections.join('\n') : 'No connected MCP tools discovered yet.';
|
|
475
497
|
}
|
|
@@ -498,6 +520,31 @@ export function parseToolCallName(name) {
|
|
|
498
520
|
return { server: name.slice(0, sep), tool: name.slice(sep + 2) };
|
|
499
521
|
}
|
|
500
522
|
|
|
523
|
+
// Deterministic recovery for unqualified tool-call names emitted by the LLM
|
|
524
|
+
// (e.g. "cme_status" instead of "cme__cme_status"). Exact-name match only:
|
|
525
|
+
// if exactly one connected server (or extra pseudo-server) exposes the bare
|
|
526
|
+
// tool name, route to it and report `normalized: true`; otherwise return
|
|
527
|
+
// `server: null` with the list of candidate servers so the caller can raise
|
|
528
|
+
// an explicit error. This is name normalization, never fuzzy matching — do
|
|
529
|
+
// not extend it to description/similarity-based selection (plan directeur
|
|
530
|
+
// §20 forbids that).
|
|
531
|
+
export function resolveToolCallName(mcpStatus, name, extraServers = {}) {
|
|
532
|
+
const parsed = parseToolCallName(name);
|
|
533
|
+
if (parsed.server) return { ...parsed, normalized: false, candidates: [] };
|
|
534
|
+
const candidates = [];
|
|
535
|
+
for (const [serverName, toolNames] of Object.entries(extraServers)) {
|
|
536
|
+
if (toolNames.includes(parsed.tool)) candidates.push(serverName);
|
|
537
|
+
}
|
|
538
|
+
for (const [serverName, value] of Object.entries(mcpStatus ?? {})) {
|
|
539
|
+
if (value.status !== 'connected') continue;
|
|
540
|
+
if ((value.tools ?? []).some((tool) => tool.name === parsed.tool)) candidates.push(serverName);
|
|
541
|
+
}
|
|
542
|
+
if (candidates.length === 1) {
|
|
543
|
+
return { server: candidates[0], tool: parsed.tool, normalized: true, candidates };
|
|
544
|
+
}
|
|
545
|
+
return { server: null, tool: parsed.tool, normalized: false, candidates };
|
|
546
|
+
}
|
|
547
|
+
|
|
501
548
|
export function mcpStatusMarker(status) {
|
|
502
549
|
if (status === 'connected') return '●';
|
|
503
550
|
if (status === 'configured') return '◐';
|
package/src/core/mcp.test.js
CHANGED
|
@@ -3,7 +3,76 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { mkdtemp, writeFile } from 'node:fs/promises';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
buildMcpStatus,
|
|
8
|
+
callMcpTool,
|
|
9
|
+
discoverMcpTools,
|
|
10
|
+
formatMcpToolsForAgent,
|
|
11
|
+
resolveRetryPolicy,
|
|
12
|
+
resolveToolCallName,
|
|
13
|
+
truncateToolResult,
|
|
14
|
+
} from './mcp.js';
|
|
15
|
+
|
|
16
|
+
const resolveFixtureStatus = {
|
|
17
|
+
production: {
|
|
18
|
+
status: 'connected',
|
|
19
|
+
tools: [{ name: 'production_start_job' }, { name: 'agent_status' }],
|
|
20
|
+
},
|
|
21
|
+
cme: {
|
|
22
|
+
status: 'connected',
|
|
23
|
+
tools: [{ name: 'cme_status' }, { name: 'agent_status' }],
|
|
24
|
+
},
|
|
25
|
+
documents: {
|
|
26
|
+
status: 'configured', // not connected: must never be a candidate
|
|
27
|
+
tools: [{ name: 'cme_status' }],
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
test('resolveToolCallName passes qualified names through untouched', () => {
|
|
32
|
+
const resolved = resolveToolCallName(resolveFixtureStatus, 'cme__cme_status');
|
|
33
|
+
assert.deepEqual(
|
|
34
|
+
{ server: resolved.server, tool: resolved.tool, normalized: resolved.normalized },
|
|
35
|
+
{ server: 'cme', tool: 'cme_status', normalized: false },
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('resolveToolCallName normalizes a bare name with exactly one connected match', () => {
|
|
40
|
+
const resolved = resolveToolCallName(resolveFixtureStatus, 'cme_status');
|
|
41
|
+
assert.deepEqual(
|
|
42
|
+
{ server: resolved.server, tool: resolved.tool, normalized: resolved.normalized },
|
|
43
|
+
{ server: 'cme', tool: 'cme_status', normalized: true },
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('resolveToolCallName refuses ambiguous bare names and reports candidates', () => {
|
|
48
|
+
const resolved = resolveToolCallName(resolveFixtureStatus, 'agent_status');
|
|
49
|
+
assert.equal(resolved.server, null);
|
|
50
|
+
assert.equal(resolved.normalized, false);
|
|
51
|
+
assert.deepEqual([...resolved.candidates].sort(), ['cme', 'production']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('resolveToolCallName returns no server for unknown bare names', () => {
|
|
55
|
+
const resolved = resolveToolCallName(resolveFixtureStatus, 'does_not_exist');
|
|
56
|
+
assert.equal(resolved.server, null);
|
|
57
|
+
assert.deepEqual(resolved.candidates, []);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('resolveToolCallName resolves internal pseudo-server tools via extraServers', () => {
|
|
61
|
+
const resolved = resolveToolCallName(resolveFixtureStatus, 'plan_set', { wiki: ['plan_set', 'plan_done'] });
|
|
62
|
+
assert.deepEqual(
|
|
63
|
+
{ server: resolved.server, tool: resolved.tool, normalized: resolved.normalized },
|
|
64
|
+
{ server: 'wiki', tool: 'plan_set', normalized: true },
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('formatMcpToolsForAgent advertises qualified server__tool names only', () => {
|
|
69
|
+
const listing = formatMcpToolsForAgent(resolveFixtureStatus);
|
|
70
|
+
assert.match(listing, /cme__cme_status/);
|
|
71
|
+
assert.match(listing, /production__production_start_job/);
|
|
72
|
+
// No bare tool name outside a qualified form.
|
|
73
|
+
assert.doesNotMatch(listing, /(?<![\w])cme_status(?![\w])/);
|
|
74
|
+
assert.doesNotMatch(listing, /(?<![\w])production_start_job(?![\w])/);
|
|
75
|
+
});
|
|
7
76
|
|
|
8
77
|
test('buildMcpStatus reads external MCP endpoints from mcp.endpoints.json', async () => {
|
|
9
78
|
const originalCwd = process.cwd();
|
|
@@ -448,3 +517,27 @@ test('callMcpTool parses SSE responses after keepalive comments', async () => {
|
|
|
448
517
|
globalThis.fetch = originalFetch;
|
|
449
518
|
}
|
|
450
519
|
});
|
|
520
|
+
|
|
521
|
+
test('truncateToolResult keeps short results intact and bounds long ones head+tail', () => {
|
|
522
|
+
assert.equal(truncateToolResult('short result', 100), 'short result');
|
|
523
|
+
|
|
524
|
+
const long = `START-${'x'.repeat(50000)}-END`;
|
|
525
|
+
const bounded = truncateToolResult(long, 1000);
|
|
526
|
+
assert.ok(bounded.length < 1200, `bounded length ${bounded.length} should stay near the cap`);
|
|
527
|
+
assert.match(bounded, /^START-/);
|
|
528
|
+
assert.match(bounded, /-END$/);
|
|
529
|
+
assert.match(bounded, /caractères tronqués/);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test('truncateToolResult honours WIKI_MANAGER_TOOL_RESULT_MAX_CHARS', () => {
|
|
533
|
+
const previous = process.env.WIKI_MANAGER_TOOL_RESULT_MAX_CHARS;
|
|
534
|
+
process.env.WIKI_MANAGER_TOOL_RESULT_MAX_CHARS = '500';
|
|
535
|
+
try {
|
|
536
|
+
const bounded = truncateToolResult('y'.repeat(5000));
|
|
537
|
+
assert.ok(bounded.length < 700);
|
|
538
|
+
assert.match(bounded, /caractères tronqués/);
|
|
539
|
+
} finally {
|
|
540
|
+
if (previous === undefined) delete process.env.WIKI_MANAGER_TOOL_RESULT_MAX_CHARS;
|
|
541
|
+
else process.env.WIKI_MANAGER_TOOL_RESULT_MAX_CHARS = previous;
|
|
542
|
+
}
|
|
543
|
+
});
|
package/src/core/runtimeLog.js
CHANGED
|
@@ -61,7 +61,12 @@ export function normalizeRuntimeLog(input, { session = null } = {}) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
export function formatRuntimeLogPayload(payload = {}, ts = null) {
|
|
64
|
-
|
|
64
|
+
// Plain messages get the same time prefix as structured events: untimed
|
|
65
|
+
// lines ended up visually glued at the bottom of Logs/Trace, out of
|
|
66
|
+
// chronology with the shell's own timestamped lines.
|
|
67
|
+
if (payload?.message != null && !payload.event) {
|
|
68
|
+
return [timeLabel(ts), String(payload.message)].filter(Boolean).join(' ');
|
|
69
|
+
}
|
|
65
70
|
const time = timeLabel(ts);
|
|
66
71
|
const event = eventLabel(payload.event);
|
|
67
72
|
const fields = ORDERED_FIELDS
|
|
@@ -80,5 +80,7 @@ test('emitRuntimeLog accepts structured payloads and preserves legacy strings',
|
|
|
80
80
|
assert.match(session.agentProjection.logs[0], /ASSIGNED/);
|
|
81
81
|
assert.match(session.agentProjection.logs[0], /run=run-structured/);
|
|
82
82
|
assert.match(session.agentProjection.logs[0], /workspace=docs/);
|
|
83
|
-
|
|
83
|
+
// Legacy plain messages now carry the same HH:MM:SS prefix as structured
|
|
84
|
+
// events so the Logs/Trace panel stays chronologically readable.
|
|
85
|
+
assert.match(session.agentProjection.logs[1], /^\d{2}:\d{2}:\d{2} legacy line$/);
|
|
84
86
|
});
|
package/src/runtime/client.js
CHANGED
|
@@ -10,6 +10,14 @@ function runtimeEndpoint(url, path, workspace = null) {
|
|
|
10
10
|
return endpoint.toString();
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
function runtimeEndpointWithParams(url, path, params = {}) {
|
|
14
|
+
const endpoint = new URL(`${base(url)}${path}`);
|
|
15
|
+
for (const [key, value] of Object.entries(params)) {
|
|
16
|
+
if (value != null && value !== '') endpoint.searchParams.set(key, String(value));
|
|
17
|
+
}
|
|
18
|
+
return endpoint.toString();
|
|
19
|
+
}
|
|
20
|
+
|
|
13
21
|
export function runtimeUrlFromEnv() {
|
|
14
22
|
return process.env.WIKI_MANAGER_RUNTIME_URL ?? 'http://127.0.0.1:7788';
|
|
15
23
|
}
|
|
@@ -97,6 +105,20 @@ export async function postRuntimeCancel({
|
|
|
97
105
|
return response.json();
|
|
98
106
|
}
|
|
99
107
|
|
|
108
|
+
export async function postRuntimeKill({
|
|
109
|
+
url = runtimeUrlFromEnv(),
|
|
110
|
+
token = runtimeToken(),
|
|
111
|
+
workspace = null,
|
|
112
|
+
runId = null,
|
|
113
|
+
} = {}) {
|
|
114
|
+
const response = await fetch(runtimeEndpointWithParams(url, '/kill', { workspace, runId }), {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: runtimeHeaders(token),
|
|
117
|
+
});
|
|
118
|
+
if (!response.ok && response.status !== 501) throw new Error(`Runtime kill failed: HTTP ${response.status}`);
|
|
119
|
+
return response.json();
|
|
120
|
+
}
|
|
121
|
+
|
|
100
122
|
export async function postRuntimeShutdown({
|
|
101
123
|
url = runtimeUrlFromEnv(),
|
|
102
124
|
token = runtimeToken(),
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Deterministic, localized messages for the runtime control lane.
|
|
2
|
+
//
|
|
3
|
+
// Control-lane acknowledgements (run queued, ambiguous input, conversation
|
|
4
|
+
// fallback…) are intentionally NOT generated by Donna: spending an LLM turn
|
|
5
|
+
// to say "your request is queued" would reintroduce exactly the per-message
|
|
6
|
+
// cost the orchestration refactor removed. But they are user-facing, so they
|
|
7
|
+
// must follow the session's configured reply language. This catalog is the
|
|
8
|
+
// single source for those strings — never hardcode a control-lane message in
|
|
9
|
+
// the shell or the server directly.
|
|
10
|
+
|
|
11
|
+
const CONTROL_MESSAGES = {
|
|
12
|
+
queued_for_future_run: {
|
|
13
|
+
en: 'Request added to the queue — it will start automatically after the current run.',
|
|
14
|
+
fr: 'Demande ajoutée à la file — elle démarrera automatiquement à la fin du run en cours.',
|
|
15
|
+
},
|
|
16
|
+
plan_patch_proposed: {
|
|
17
|
+
en: 'Plan patch proposed. Approve it explicitly to apply it to the active plan.',
|
|
18
|
+
fr: 'Modification de plan proposée. Approuvez-la explicitement pour l’appliquer au plan actif.',
|
|
19
|
+
},
|
|
20
|
+
ambiguous_control: {
|
|
21
|
+
en: 'A run is already active, and this looks like a new action. Say "queue it" to run it after the current run, "modify the run" to change the active plan, "cancel" to stop the current run first — or wait for it to finish.',
|
|
22
|
+
fr: 'Un run est déjà actif et ta demande ressemble à une nouvelle action. Dis « mets en file » pour l\'exécuter après le run en cours, « modifie le run » pour changer le plan actif, « annule » pour arrêter le run actuel — ou attends la fin.',
|
|
23
|
+
},
|
|
24
|
+
converse_while_running: {
|
|
25
|
+
en: 'Runtime run is still active. This message was treated as conversation and did not create a queued run.',
|
|
26
|
+
fr: 'Un run est toujours actif. Ce message a été traité comme conversation et n’a pas créé de run en file.',
|
|
27
|
+
},
|
|
28
|
+
converse_while_idle: {
|
|
29
|
+
en: 'Runtime is idle. This message was treated as conversation and did not create a run.',
|
|
30
|
+
fr: 'Le runtime est inactif. Ce message a été traité comme conversation et n’a pas créé de run.',
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function controlLanguage(session) {
|
|
35
|
+
const raw = String(session?.language ?? 'en').toLowerCase();
|
|
36
|
+
return raw.startsWith('fr') ? 'fr' : 'en';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function controlMessage(session, key) {
|
|
40
|
+
const entry = CONTROL_MESSAGES[key];
|
|
41
|
+
if (!entry) throw new Error(`Unknown control message key: ${key}`);
|
|
42
|
+
return entry[controlLanguage(session)] ?? entry.en;
|
|
43
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { controlLanguage, controlMessage } from './controlMessages.js';
|
|
4
|
+
|
|
5
|
+
test('controlLanguage maps fr locales to fr and everything else to en', () => {
|
|
6
|
+
assert.equal(controlLanguage({ language: 'fr-FR' }), 'fr');
|
|
7
|
+
assert.equal(controlLanguage({ language: 'fr' }), 'fr');
|
|
8
|
+
assert.equal(controlLanguage({ language: 'en-US' }), 'en');
|
|
9
|
+
assert.equal(controlLanguage({ language: null }), 'en');
|
|
10
|
+
assert.equal(controlLanguage(null), 'en');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('controlMessage returns the localized queued acknowledgement', () => {
|
|
14
|
+
assert.match(controlMessage({ language: 'fr-FR' }, 'queued_for_future_run'), /ajoutée à la file/);
|
|
15
|
+
assert.match(controlMessage({ language: 'en-US' }, 'queued_for_future_run'), /added to the queue/);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('controlMessage falls back to en for unknown locales and throws on unknown keys', () => {
|
|
19
|
+
assert.match(controlMessage({ language: 'de-DE' }, 'queued_for_future_run'), /added to the queue/);
|
|
20
|
+
assert.throws(() => controlMessage({ language: 'fr-FR' }, 'nope'), /Unknown control message key/);
|
|
21
|
+
});
|
|
@@ -531,7 +531,7 @@ function toolResult(payload) {
|
|
|
531
531
|
}
|
|
532
532
|
|
|
533
533
|
test('Recipe #7 — "où en es-tu ?" during an active run: status in conversation, run continues, no new run', async (t) => {
|
|
534
|
-
const session = { workspace: '
|
|
534
|
+
const session = { workspace: 'acme', controlQueue: [] };
|
|
535
535
|
let runCount = 0;
|
|
536
536
|
let handle;
|
|
537
537
|
try {
|
|
@@ -550,7 +550,7 @@ test('Recipe #7 — "où en es-tu ?" during an active run: status in conversatio
|
|
|
550
550
|
listEvents: () => [],
|
|
551
551
|
},
|
|
552
552
|
getContext: async () => ({
|
|
553
|
-
workspace: '
|
|
553
|
+
workspace: 'acme',
|
|
554
554
|
session,
|
|
555
555
|
running: true,
|
|
556
556
|
currentAbortController: new AbortController(),
|
|
@@ -566,7 +566,7 @@ test('Recipe #7 — "où en es-tu ?" during an active run: status in conversatio
|
|
|
566
566
|
}
|
|
567
567
|
|
|
568
568
|
try {
|
|
569
|
-
const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=
|
|
569
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
|
|
570
570
|
method: 'POST',
|
|
571
571
|
headers: { 'Content-Type': 'application/json' },
|
|
572
572
|
body: JSON.stringify({ action: 'message', input: 'où en es-tu ?' }),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parseJsonText } from '../core/activity.js';
|
|
2
2
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
3
3
|
import { formatMcpToolResult, callMcpTool as defaultCallMcpTool } from '../core/mcp.js';
|
|
4
|
+
import { createCapabilityRegistry } from '../orchestrator/capabilityRegistry.js';
|
|
4
5
|
import { accept as acceptResult } from '../orchestrator/resultAggregator.js';
|
|
5
6
|
|
|
6
7
|
const ACTIVE_TASK_STATUSES = new Set(['running', 'queued', 'starting', 'assigned']);
|
|
@@ -24,9 +25,11 @@ export async function recoverActiveRuns({
|
|
|
24
25
|
for (const run of runs) {
|
|
25
26
|
const tasks = store.listTasks?.({ runId: run.id }) ?? [];
|
|
26
27
|
const activeTasks = tasks.filter((task) => ACTIVE_TASK_STATUSES.has(String(task.status ?? '').toLowerCase()));
|
|
28
|
+
const runOutcomes = [];
|
|
27
29
|
for (const task of activeTasks) {
|
|
28
30
|
try {
|
|
29
31
|
const outcome = await recoverTask({ store, session, run, task, callTool, resultAggregator });
|
|
32
|
+
runOutcomes.push(outcome ?? null);
|
|
30
33
|
if (outcome?.status === 'recovered') recovered.push(outcome);
|
|
31
34
|
else if (outcome?.status === 'rescheduled') rescheduled.push(outcome);
|
|
32
35
|
else if (outcome?.status === 'interrupted') interrupted.push(outcome);
|
|
@@ -34,6 +37,21 @@ export async function recoverActiveRuns({
|
|
|
34
37
|
errors.push({ runId: run.id, taskId: task.id, error: error instanceof Error ? error.message : String(error) });
|
|
35
38
|
}
|
|
36
39
|
}
|
|
40
|
+
// A run in which nothing was recovered or rescheduled can never progress:
|
|
41
|
+
// leaving it 'running' in the store would re-attach it as a zombie on
|
|
42
|
+
// every subsequent boot. Close it for good.
|
|
43
|
+
if (activeTasks.length > 0 && runOutcomes.length === activeTasks.length
|
|
44
|
+
&& runOutcomes.every((outcome) => outcome?.status === 'interrupted')) {
|
|
45
|
+
const changed = store.interruptRuns?.({ workspace: run.workspace ?? null, runId: run.id, reason: 'Recovery found no recoverable task.' }) ?? 0;
|
|
46
|
+
if (changed > 0) {
|
|
47
|
+
dispatch(session, store, 'runtime_log', {
|
|
48
|
+
origin: 'recovery_manager',
|
|
49
|
+
runId: run.id,
|
|
50
|
+
workspace: run.workspace ?? workspaceFromSession(session),
|
|
51
|
+
payload: { message: `recovery: run ${run.id} interrupted (no recoverable task)` },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
37
55
|
}
|
|
38
56
|
|
|
39
57
|
return {
|
|
@@ -46,6 +64,28 @@ export async function recoverActiveRuns({
|
|
|
46
64
|
}
|
|
47
65
|
|
|
48
66
|
async function recoverTask({ store, session, run, task, callTool, resultAggregator }) {
|
|
67
|
+
// A task whose capability no longer resolves can never be dispatched:
|
|
68
|
+
// re-attaching it would recreate the forever-waiting queue it came from.
|
|
69
|
+
// Fail it explicitly instead. No registry information at all (discovery
|
|
70
|
+
// not run yet) keeps the current behavior.
|
|
71
|
+
if (task.requiredCapability && !capabilityResolvable(session, task.requiredCapability)) {
|
|
72
|
+
dispatch(session, store, 'plan_step_updated', {
|
|
73
|
+
origin: 'recovery_manager',
|
|
74
|
+
runId: run.id,
|
|
75
|
+
taskId: task.id,
|
|
76
|
+
workspace: run.workspace ?? workspaceFromSession(session),
|
|
77
|
+
payload: {
|
|
78
|
+
taskId: task.id,
|
|
79
|
+
status: 'failed',
|
|
80
|
+
recovery: {
|
|
81
|
+
reason: 'unresolvable_capability',
|
|
82
|
+
capability: task.requiredCapability,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
return interruptTask({ store, session, run, task, reason: `unresolvable capability: ${task.requiredCapability}` });
|
|
87
|
+
}
|
|
88
|
+
|
|
49
89
|
const attempt = latestAttempt(store.listTaskAttempts?.({ taskId: task.id }) ?? []);
|
|
50
90
|
const assignment = latestAssignment(store.listTaskAssignments?.({ taskId: task.id }) ?? [], attempt?.attemptId);
|
|
51
91
|
if (!attempt?.jobId || !assignment?.agentInstanceId) {
|
|
@@ -126,6 +166,20 @@ function isTerminal(status) {
|
|
|
126
166
|
return TERMINAL_STATUSES.has(String(status ?? '').toLowerCase());
|
|
127
167
|
}
|
|
128
168
|
|
|
169
|
+
function capabilityResolvable(session, capability) {
|
|
170
|
+
const registry = session.capabilityRegistry
|
|
171
|
+
?? ((session.agentRegistrySnapshot ?? []).length > 0
|
|
172
|
+
? createCapabilityRegistry({ agents: session.agentRegistrySnapshot })
|
|
173
|
+
: null);
|
|
174
|
+
if (!registry || typeof registry.providersFor !== 'function') return true;
|
|
175
|
+
// Only trust a registry that actually knows about capabilities. An empty
|
|
176
|
+
// one (discovery not finished, or agents described without capability
|
|
177
|
+
// lists) cannot distinguish "nothing provides X" from "no information".
|
|
178
|
+
const snapshot = typeof registry.snapshot === 'function' ? registry.snapshot() : {};
|
|
179
|
+
if (Object.keys(snapshot ?? {}).length === 0) return true;
|
|
180
|
+
return registry.providersFor(capability).length > 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
129
183
|
function parseToolPayload(result) {
|
|
130
184
|
if (result && typeof result === 'object' && !Array.isArray(result) && !Array.isArray(result.content)) return result;
|
|
131
185
|
return parseJsonText(formatMcpToolResult(result)) ?? {};
|