@dotdrelle/wiki-manager 0.14.7 → 0.14.8
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 +6 -1
- package/src/agent/graph.js +61 -7
- package/src/agent/graph.test.js +104 -0
- package/src/cli/wiki-manager.js +21 -3
- package/src/commands/slash.js +18 -17
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +51 -21
- package/src/runtime/server.js +16 -4
- package/src/runtime/server.test.js +81 -1
- package/src/shell/LeftPane.tsx +1 -1
- package/src/shell/repl.js +29 -0
- package/src/shell/repl.test.js +34 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotdrelle/wiki-manager",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.8",
|
|
4
4
|
"description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
|
|
5
5
|
"license": "PolyForm-Noncommercial-1.0.0",
|
|
6
6
|
"author": "dotrelle",
|
|
@@ -46,6 +46,11 @@
|
|
|
46
46
|
"cli"
|
|
47
47
|
],
|
|
48
48
|
"packageManager": "pnpm@10.29.2",
|
|
49
|
+
"pnpm": {
|
|
50
|
+
"onlyBuiltDependencies": [
|
|
51
|
+
"bun"
|
|
52
|
+
]
|
|
53
|
+
},
|
|
49
54
|
"dependencies": {
|
|
50
55
|
"@langchain/langgraph": "^1.3.2",
|
|
51
56
|
"@opentui/core": "^0.3.2",
|
package/src/agent/graph.js
CHANGED
|
@@ -403,6 +403,23 @@ function summarizeToolArguments(rawArguments) {
|
|
|
403
403
|
}
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
+
function googleOAuthUrlFromMessages(messages) {
|
|
407
|
+
for (const message of [...(messages ?? [])].reverse()) {
|
|
408
|
+
if (message?.role !== 'tool') continue;
|
|
409
|
+
const match = String(message.content ?? '').match(/https:\/\/accounts\.google\.com\/[^\s<>"')\]]+/i);
|
|
410
|
+
if (match) return match[0];
|
|
411
|
+
}
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function preserveRequiredOAuthUrl(content, messages) {
|
|
416
|
+
const text = String(content ?? '');
|
|
417
|
+
const url = googleOAuthUrlFromMessages(messages);
|
|
418
|
+
if (!url || text.includes(url)) return text;
|
|
419
|
+
const prefix = text.trimEnd();
|
|
420
|
+
return `${prefix}${prefix ? '\n\n' : ''}Lien d’autorisation Google : ${url}`;
|
|
421
|
+
}
|
|
422
|
+
|
|
406
423
|
function buildQueuedResult(session, item, activeJobId = null) {
|
|
407
424
|
const message = activeJobId != null
|
|
408
425
|
? `Production job queued as ${item.id}; waiting for ${activeJobId}.`
|
|
@@ -709,6 +726,13 @@ async function handleRuntimeControlTool(session, tool, args = {}) {
|
|
|
709
726
|
if (tool === 'delegate') {
|
|
710
727
|
const objective = String(args.objective ?? '').trim();
|
|
711
728
|
if (!objective) return 'Delegation rejected: missing objective.';
|
|
729
|
+
const connectorConfig = connectorConfigurationTarget(session, objective);
|
|
730
|
+
if (connectorConfig?.setupTool) {
|
|
731
|
+
return `Delegation rejected: configuring or authenticating ${connectorConfig.serverName} is not an orchestrated export. Call the offered ${connectorConfig.serverName}__${connectorConfig.setupTool} tool directly and present its authorization instructions or URL to the user.`;
|
|
732
|
+
}
|
|
733
|
+
if (connectorConfig) {
|
|
734
|
+
return `Delegation rejected: ${connectorConfig.serverName} advertises no setup or authentication tool. Do not call an unrelated data tool and do not delegate to export. Explain conversationally that authentication must be completed outside MCP, using only configuration instructions already available in the current context.`;
|
|
735
|
+
}
|
|
712
736
|
const result = await postRuntimeDelegate(objective, { url, workspace });
|
|
713
737
|
return result?.runId
|
|
714
738
|
? JSON.stringify({
|
|
@@ -746,6 +770,24 @@ async function handleRuntimeControlTool(session, tool, args = {}) {
|
|
|
746
770
|
}
|
|
747
771
|
}
|
|
748
772
|
|
|
773
|
+
function connectorConfigurationTarget(session, objective) {
|
|
774
|
+
const text = String(objective ?? '').toLowerCase();
|
|
775
|
+
if (!/(?:configur|connect|authent|oauth|setup|sign[ -]?in)/i.test(text)) return null;
|
|
776
|
+
for (const [serverName, server] of Object.entries(session?.mcp ?? {})) {
|
|
777
|
+
if (server?.status !== 'connected' || !Array.isArray(server.tools) || server.tools.length === 0) continue;
|
|
778
|
+
const aliases = String(serverName).toLowerCase().split(/[^a-z0-9]+/).filter((part) => part.length >= 3);
|
|
779
|
+
if (!aliases.some((alias) => text.includes(alias))) continue;
|
|
780
|
+
const setupTool = server.tools.find((tool) => {
|
|
781
|
+
const name = String(tool?.name ?? '').toLowerCase();
|
|
782
|
+
const description = String(tool?.description ?? '').toLowerCase();
|
|
783
|
+
return /(?:^|_)(?:setup|config|configure|auth|authenticate|oauth|connect)(?:_|$)/.test(name)
|
|
784
|
+
|| /(?:initiat|start|configure|authenticate).{0,30}(?:oauth|authentication)/.test(description);
|
|
785
|
+
})?.name ?? null;
|
|
786
|
+
return { serverName, setupTool };
|
|
787
|
+
}
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
790
|
+
|
|
749
791
|
function handleWikiTool(session, tool, args) {
|
|
750
792
|
if (tool === 'plan_set') {
|
|
751
793
|
const steps = Array.isArray(args.steps) ? args.steps : [];
|
|
@@ -888,10 +930,10 @@ export function buildAgentSystemPrompt(state) {
|
|
|
888
930
|
'For service actions, recommend only available service primitives from Available primitives, with the exact service name when the primitive supports one.',
|
|
889
931
|
'Scope discipline: execute ONLY the action(s) the user explicitly requested. Never chain additional mutating operations (ingest, build, export, polish, delete, send…) that the user did not ask for — even when diagnostics or recommendations suggest them. Finish with the requested result and stop. Example: "applique les recommandations de config" means apply the config; it does NOT authorize launching the ingest those recommendations mention.',
|
|
890
932
|
state.session.runtime?.url
|
|
891
|
-
? 'The runtime is connected and runtime__delegate is bound and available
|
|
933
|
+
? 'The runtime is connected and runtime__delegate is bound and available for heavy orchestrated operations (ingest, build, export, polish, pipeline). Single-step connector actions such as configure, authenticate, add a source, convert, search, or send use the connected MCP tool directly. Never delegate connector configuration to an export capability.'
|
|
892
934
|
: 'No runtime is connected, so you cannot execute actions. State that plainly and name the runtime connection as the missing capability — do not invent a workaround.',
|
|
893
935
|
'If the connector or service needed for a requested read or action is absent from the Connected MCP tools above (its service is not running — e.g. CME, documents, or production), say plainly that this service is not connected and name it as the missing capability. Never redirect a simple read (e.g. "give me the CME config") to an "agent action", never invent its result, and never propose a workaround. Only requests you can actually serve with a listed tool are answered with data.',
|
|
894
|
-
'For
|
|
936
|
+
'For heavy orchestrated operations only (ingest, build, export, polish, pipeline), call runtime__delegate with the user objective only. For a single-step connector action, call the offered connector tool directly. Never choose a capability, operation, agent, plan, or implementation yourself. Never call <provider>__agent_plan, <provider>__agent_execute, legacy production__production_start_job, wiki__plan_set, or wiki__plan_done from interactive chat.',
|
|
895
937
|
'Do not ask the user which sources, files, connectors, or templates to use for an ingest, build, or export: the specialized agent discovers them from the workspace. When the objective is clear (e.g. "lance une ingestion"), delegate it as stated, without a clarifying question.',
|
|
896
938
|
'Promise only what the resolved capability actually exposes in its declared contract (the input schema the specialized agent publishes for that capability). When the user requests an execution parameter — a batch or chunk size, a count "N at a time", concurrency, ordering, priority, or any tuning knob — apply it only if that parameter exists in the target capability\'s published input schema. Otherwise do not confirm or promise it: delegate the objective, and if the user explicitly asked for that parameter, say plainly in one line that you started the work but do not control that aspect (the runtime and the specialized agent decide it). Never state or imply a parameter was applied when the agent contract cannot enforce it.',
|
|
897
939
|
'If runtime__delegate returns a blocker or no specialized provider is available, report only that concrete blocker concisely. Never replace the missing execution path with a suggested slash command, skill, MCP tool name, manual file move, administrator escalation, or alternative workflow unless the user explicitly asks for alternatives.',
|
|
@@ -963,15 +1005,18 @@ function toolsForClassification(classification, writeTools, session = null) {
|
|
|
963
1005
|
return [SHELL_READ_COMMAND_TOOL, ...controlTools, ...capabilityRunTools, ...writeTools];
|
|
964
1006
|
}
|
|
965
1007
|
|
|
1008
|
+
const DONNA_READ_VERBS = new Set(['status', 'list', 'search', 'read', 'get', 'fetch']);
|
|
1009
|
+
|
|
966
1010
|
export function isDonnaReadTool(item) {
|
|
967
1011
|
const name = String(item?.function?.name ?? '');
|
|
968
1012
|
if (!name || name.startsWith('shell__') || name === 'wiki__plan_set' || name === 'wiki__plan_done') return false;
|
|
969
1013
|
if (item?.readOnly === true) return true;
|
|
970
1014
|
const tool = name.includes('__') ? name.slice(name.indexOf('__') + 2) : name;
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1015
|
+
if (tool === 'wiki_workspace_status' || tool === 'agent_describe' || tool === 'agent_status') return true;
|
|
1016
|
+
// Match a read verb anywhere in the underscore-tokenized name, not just as
|
|
1017
|
+
// a trailing suffix — third-party MCPs don't all name tools verb-last
|
|
1018
|
+
// (e.g. exa's "web_search_exa"/"web_fetch_exa" put the verb in the middle).
|
|
1019
|
+
return tool.split('_').some((segment) => DONNA_READ_VERBS.has(segment));
|
|
975
1020
|
}
|
|
976
1021
|
|
|
977
1022
|
// Two-tier tool policy. Donna may call any connected MCP tool directly
|
|
@@ -1192,6 +1237,15 @@ export function createAgentGraph(options = {}) {
|
|
|
1192
1237
|
};
|
|
1193
1238
|
}
|
|
1194
1239
|
|
|
1240
|
+
// Authentication URLs are execution outputs, not optional prose. Small
|
|
1241
|
+
// models sometimes summarize an OAuth tool result as "open the supplied
|
|
1242
|
+
// link" while dropping the link itself, leaving ShellUI unusable even
|
|
1243
|
+
// though the MCP call succeeded. Preserve that exact URL deterministically.
|
|
1244
|
+
const finalContent = preserveRequiredOAuthUrl(result.content, conversationMessages);
|
|
1245
|
+
if (finalContent !== String(result.content ?? '')) {
|
|
1246
|
+
result.content = finalContent;
|
|
1247
|
+
result.message = { ...(result.message ?? { role: 'assistant' }), content: finalContent };
|
|
1248
|
+
}
|
|
1195
1249
|
const invalidCommands = invalidSuggestedSlashCommands(result.content, state.session);
|
|
1196
1250
|
const leakedTools = invalidUserFacingToolNames(result.content, state.session);
|
|
1197
1251
|
if (invalidCommands.length > 0 || leakedTools.length > 0) {
|
|
@@ -1246,7 +1300,7 @@ export function createAgentGraph(options = {}) {
|
|
|
1246
1300
|
|
|
1247
1301
|
// Fallback path (streamWithTools unavailable): hand off to runLine for streaming.
|
|
1248
1302
|
state.session._onStep?.('Agent: streaming final answer…');
|
|
1249
|
-
if (typeof llm.stream === 'function') {
|
|
1303
|
+
if (typeof llm.stream === 'function' && !googleOAuthUrlFromMessages(conversationMessages)) {
|
|
1250
1304
|
return {
|
|
1251
1305
|
response: null,
|
|
1252
1306
|
pendingToolCalls: null,
|
package/src/agent/graph.test.js
CHANGED
|
@@ -395,6 +395,110 @@ test('Donna delegates the objective without choosing technical identifiers', asy
|
|
|
395
395
|
}
|
|
396
396
|
});
|
|
397
397
|
|
|
398
|
+
test('Donna refuses to delegate connector authentication to an export capability', async () => {
|
|
399
|
+
const originalFetch = globalThis.fetch;
|
|
400
|
+
const fetchedUrls = [];
|
|
401
|
+
globalThis.fetch = async (url, options = {}) => {
|
|
402
|
+
fetchedUrls.push(String(url));
|
|
403
|
+
const body = JSON.parse(String(options.body ?? '{}'));
|
|
404
|
+
assert.equal(body.params?.name, 'start_google_auth');
|
|
405
|
+
return {
|
|
406
|
+
ok: true,
|
|
407
|
+
status: 200,
|
|
408
|
+
headers: { get: () => null },
|
|
409
|
+
text: async () => JSON.stringify({ result: { content: [{ type: 'text', text: 'ACTION REQUIRED: authorize at https://accounts.google.com/o/oauth2/auth?client_id=test&state=abc' }] } }),
|
|
410
|
+
};
|
|
411
|
+
};
|
|
412
|
+
let turn = 0;
|
|
413
|
+
const session = sessionBase({
|
|
414
|
+
runtime: { url: 'http://runtime.test' },
|
|
415
|
+
mcp: {
|
|
416
|
+
'google-workspace': {
|
|
417
|
+
status: 'connected',
|
|
418
|
+
url: 'http://google.test/mcp',
|
|
419
|
+
tools: [
|
|
420
|
+
{
|
|
421
|
+
name: 'start_google_auth',
|
|
422
|
+
description: 'Manually initiate Google OAuth authentication flow.',
|
|
423
|
+
inputSchema: { type: 'object', additionalProperties: true },
|
|
424
|
+
},
|
|
425
|
+
{ name: 'search_gmail_messages', inputSchema: { type: 'object', additionalProperties: true } },
|
|
426
|
+
],
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
llm: {
|
|
430
|
+
async completeWithTools() {
|
|
431
|
+
turn += 1;
|
|
432
|
+
if (turn === 1) return {
|
|
433
|
+
content: null,
|
|
434
|
+
message: { role: 'assistant', content: null },
|
|
435
|
+
tool_calls: [{ id: 'wrong-delegate', type: 'function', function: { name: 'runtime__delegate', arguments: '{"objective":"je veux configurer google"}' } }],
|
|
436
|
+
};
|
|
437
|
+
if (turn === 2) return {
|
|
438
|
+
content: null,
|
|
439
|
+
message: { role: 'assistant', content: null },
|
|
440
|
+
tool_calls: [{ id: 'google-auth', type: 'function', function: { name: 'google-workspace__start_google_auth', arguments: '{}' } }],
|
|
441
|
+
};
|
|
442
|
+
return {
|
|
443
|
+
content: 'J’ai lancé l’authentification. Ouvre le lien fourni.',
|
|
444
|
+
message: { role: 'assistant', content: 'J’ai lancé l’authentification. Ouvre le lien fourni.' },
|
|
445
|
+
tool_calls: null,
|
|
446
|
+
};
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
try {
|
|
452
|
+
const result = await createAgentGraph().invoke({ input: 'je veux configurer google', session });
|
|
453
|
+
assert.match(result.response, /J’ai lancé l’authentification/);
|
|
454
|
+
assert.match(result.response, /https:\/\/accounts\.google\.com\/o\/oauth2\/auth\?client_id=test&state=abc/);
|
|
455
|
+
assert.equal(fetchedUrls.some((url) => url.includes('runtime.test')), false);
|
|
456
|
+
assert.equal(fetchedUrls.some((url) => url.includes('google.test')), true);
|
|
457
|
+
} finally {
|
|
458
|
+
globalThis.fetch = originalFetch;
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test('Donna does not invent a setup tool when a connector advertises data tools only', async () => {
|
|
463
|
+
const originalFetch = globalThis.fetch;
|
|
464
|
+
globalThis.fetch = async () => assert.fail('neither runtime delegation nor an unrelated data tool should be called');
|
|
465
|
+
let turn = 0;
|
|
466
|
+
const session = sessionBase({
|
|
467
|
+
runtime: { url: 'http://runtime.test' },
|
|
468
|
+
mcp: {
|
|
469
|
+
acme: {
|
|
470
|
+
status: 'connected',
|
|
471
|
+
url: 'http://acme.test/mcp',
|
|
472
|
+
tools: [{ name: 'list_records', description: 'List records.', inputSchema: { type: 'object' } }],
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
llm: {
|
|
476
|
+
async completeWithTools({ messages }) {
|
|
477
|
+
turn += 1;
|
|
478
|
+
if (turn === 1) return {
|
|
479
|
+
content: null,
|
|
480
|
+
message: { role: 'assistant', content: null },
|
|
481
|
+
tool_calls: [{ id: 'wrong-delegate', type: 'function', function: { name: 'runtime__delegate', arguments: '{"objective":"configure acme"}' } }],
|
|
482
|
+
};
|
|
483
|
+
const refusal = (messages ?? []).filter((message) => message.role === 'tool').at(-1)?.content;
|
|
484
|
+
assert.match(String(refusal), /advertises no setup or authentication tool/);
|
|
485
|
+
return {
|
|
486
|
+
content: 'ACME doit être authentifié hors de cette interface.',
|
|
487
|
+
message: { role: 'assistant', content: 'ACME doit être authentifié hors de cette interface.' },
|
|
488
|
+
tool_calls: null,
|
|
489
|
+
};
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
try {
|
|
495
|
+
const result = await createAgentGraph().invoke({ input: 'configure acme', session });
|
|
496
|
+
assert.equal(result.response, 'ACME doit être authentifié hors de cette interface.');
|
|
497
|
+
} finally {
|
|
498
|
+
globalThis.fetch = originalFetch;
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
|
|
398
502
|
test('runtime status does not manufacture a plan', async () => {
|
|
399
503
|
const originalFetch = globalThis.fetch;
|
|
400
504
|
globalThis.fetch = async () => ({
|
package/src/cli/wiki-manager.js
CHANGED
|
@@ -6,11 +6,11 @@ import { ensureManagerScaffold, loadManagerEnv } from '../core/env.js';
|
|
|
6
6
|
loadManagerEnv();
|
|
7
7
|
import { createAgentGraph } from '../agent/graph.js';
|
|
8
8
|
import { handleSlashCommand, printHelp, printVersion, refreshMcpRuntimeStatus } from '../commands/slash.js';
|
|
9
|
-
import { runShell } from '../shell/repl.js';
|
|
9
|
+
import { runShell, runHeadlessChatTurn } from '../shell/repl.js';
|
|
10
10
|
import { runChecks } from '../core/startupCheck.js';
|
|
11
11
|
import { applySessionWikircProfile } from '../core/sessionConfig.js';
|
|
12
12
|
import { listWikircProfiles } from '../core/wikirc.js';
|
|
13
|
-
import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
|
|
13
|
+
import { callMcpTool, formatMcpToolResult, readChatAccessConfig } from '../core/mcp.js';
|
|
14
14
|
import { extractActivity, parseJsonText, sessionActivities, terminalFailures } from '../core/activity.js';
|
|
15
15
|
import { syncActivitiesToPlan, formatPlanStatus } from '../core/plan.js';
|
|
16
16
|
import { createAgentEvent, dispatchAgentEvent, reduceAgentEvents } from '../core/agentEvents.js';
|
|
@@ -1039,7 +1039,25 @@ async function runRuntime(argv, agent) {
|
|
|
1039
1039
|
workspace: context.workspace ?? null,
|
|
1040
1040
|
payload: { content: input },
|
|
1041
1041
|
}));
|
|
1042
|
-
|
|
1042
|
+
// Read-only chat turn: same chatAccess policy as the Shell UI's /chat, now
|
|
1043
|
+
// reachable over HTTP so `wiki serve` chat mode gets read tools without
|
|
1044
|
+
// duplicating the loop. Anything other than mode === 'chat' stays the full
|
|
1045
|
+
// unrestricted agent turn.
|
|
1046
|
+
const chatMode = String(body.mode ?? '').toLowerCase() === 'chat';
|
|
1047
|
+
let response;
|
|
1048
|
+
if (chatMode) {
|
|
1049
|
+
ephemeral.chatMode = true;
|
|
1050
|
+
ephemeral.chatAccess = readChatAccessConfig();
|
|
1051
|
+
const history = messages.length && messages[messages.length - 1]?.role === 'user'
|
|
1052
|
+
? messages.slice(0, -1)
|
|
1053
|
+
: messages;
|
|
1054
|
+
response = await runHeadlessChatTurn(ephemeral, input, {
|
|
1055
|
+
history,
|
|
1056
|
+
onStep: ephemeral._onStep,
|
|
1057
|
+
});
|
|
1058
|
+
} else {
|
|
1059
|
+
response = await runAgentTurn(agent, ephemeral, input, { messages, signal });
|
|
1060
|
+
}
|
|
1043
1061
|
ensureInteractiveAssistantMessage(ephemeral, response, {
|
|
1044
1062
|
turnId,
|
|
1045
1063
|
workspace: context.workspace ?? null,
|
package/src/commands/slash.js
CHANGED
|
@@ -232,8 +232,8 @@ function statLine(label, stat) {
|
|
|
232
232
|
return `${label}: ${stat.count} (${formatBytes(stat.totalBytes)})`;
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
-
function
|
|
236
|
-
if (!stats) return 'No workspace loaded.';
|
|
235
|
+
function workspaceStatsColumns(stats) {
|
|
236
|
+
if (!stats) return { left: 'No workspace loaded.', right: '' };
|
|
237
237
|
const hints = [];
|
|
238
238
|
if (stats.untracked.count > 0) {
|
|
239
239
|
hints.push(`${stats.untracked.count} raw/untracked document(s) are waiting for ingest.`);
|
|
@@ -280,11 +280,10 @@ function workspaceStatsText(stats) {
|
|
|
280
280
|
]);
|
|
281
281
|
const hintsColumn = sectionBlock('Hints', hints.length > 0 ? hints : ['No immediate content action detected.']);
|
|
282
282
|
|
|
283
|
-
return
|
|
284
|
-
|
|
285
|
-
'',
|
|
286
|
-
|
|
287
|
-
].join('\n');
|
|
283
|
+
return {
|
|
284
|
+
left: [wikiColumn, deliveryColumn].join('\n\n'),
|
|
285
|
+
right: [rawColumn, internalColumn, hintsColumn].join('\n\n'),
|
|
286
|
+
};
|
|
288
287
|
}
|
|
289
288
|
|
|
290
289
|
function workspaceLoadedText(workspace, summary, session) {
|
|
@@ -530,16 +529,18 @@ async function statusText(session) {
|
|
|
530
529
|
const runtimeColumn = sectionBlock('Runtime', (states ? serviceStatesText(states) : 'Docker runtime not available or no workspace loaded.').split('\n'));
|
|
531
530
|
const mcpColumn = sectionBlock('MCP', formatMcpStatus(session.mcp).split('\n'));
|
|
532
531
|
const mcpToolsColumn = sectionBlock('MCP tool summary', formatMcpToolSummary(session.mcp).split('\n'));
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
532
|
+
const stats = workspaceStatsColumns(workspaceStats);
|
|
533
|
+
|
|
534
|
+
const leftColumn = [workspaceColumn, stats.left, runtimeColumn, mcpColumn].filter(Boolean).join('\n\n');
|
|
535
|
+
const rightColumn = [configColumn, stats.right, mcpToolsColumn].filter(Boolean).join('\n\n');
|
|
536
|
+
|
|
537
|
+
// Leading/trailing padding row on *both* columns so the boxed pair doesn't
|
|
538
|
+
// butt directly against the pane border when the view is scrolled to show
|
|
539
|
+
// the tail. A single space on each side (not '') keeps the row tab-joined,
|
|
540
|
+
// so both the left and right box render — an empty string on either side
|
|
541
|
+
// of the tab makes twoColumns drop the pairing and only the left box shows.
|
|
542
|
+
const pad = ' \t ';
|
|
543
|
+
return [pad, twoColumns(leftColumn, rightColumn), pad].join('\n');
|
|
543
544
|
}
|
|
544
545
|
|
|
545
546
|
function loadWorkspaceSystemPrompt(workspacePath) {
|
package/src/core/buildInfo.json
CHANGED
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.14.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.14.8';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
|
@@ -29,6 +29,27 @@ function normalizeHeaders(headers) {
|
|
|
29
29
|
);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// An endpoint's url/headers may reference `${VAR}` placeholders with no
|
|
33
|
+
// `:-default`. If that env var is unset, the placeholder interpolates to ''
|
|
34
|
+
// (see interpolateEnv) and the endpoint would otherwise look "configured"
|
|
35
|
+
// with a blank credential — then discoverMcpTools happily probes the live
|
|
36
|
+
// endpoint and can report it "connected" even though it has no real auth.
|
|
37
|
+
function hasMissingRequiredEnv(value) {
|
|
38
|
+
if (typeof value !== 'string') return false;
|
|
39
|
+
let missing = false;
|
|
40
|
+
value.replace(/\$\{([^}]+)\}/g, (_, expr) => {
|
|
41
|
+
const sep = expr.indexOf(':-');
|
|
42
|
+
if (sep === -1 && !envValue(expr)) missing = true;
|
|
43
|
+
return '';
|
|
44
|
+
});
|
|
45
|
+
return missing;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function endpointHasMissingCredentials(endpoint) {
|
|
49
|
+
const headerValues = Object.values(endpoint?.headers ?? {}).filter((value) => typeof value === 'string');
|
|
50
|
+
return [String(endpoint?.url ?? ''), ...headerValues].some(hasMissingRequiredEnv);
|
|
51
|
+
}
|
|
52
|
+
|
|
32
53
|
function normalizeExternalUrlForRuntime(url) {
|
|
33
54
|
if (process.env.WIKI_MANAGER_KEEP_DOCKER_HOST === '1') return url;
|
|
34
55
|
try {
|
|
@@ -57,8 +78,14 @@ export function readChatAccessConfig() {
|
|
|
57
78
|
if (!chatAccess || typeof chatAccess !== 'object' || Array.isArray(chatAccess)) return null;
|
|
58
79
|
const servers = {};
|
|
59
80
|
for (const [name, entry] of Object.entries(chatAccess.servers ?? {})) {
|
|
60
|
-
|
|
61
|
-
|
|
81
|
+
// "*" is also commonly written as a one-element array (["*"]) since every
|
|
82
|
+
// other "allow" example in this config is an array of tool names — treat
|
|
83
|
+
// both forms as the same wildcard rather than silently allowing nothing.
|
|
84
|
+
if (entry?.allow === '*' || (Array.isArray(entry?.allow) && entry.allow.length === 1 && entry.allow[0] === '*')) {
|
|
85
|
+
servers[name] = { allow: '*' };
|
|
86
|
+
} else if (Array.isArray(entry?.allow)) {
|
|
87
|
+
servers[name] = { allow: entry.allow.map(String).filter(Boolean) };
|
|
88
|
+
}
|
|
62
89
|
}
|
|
63
90
|
const maxToolIterations = Number.isFinite(Number(chatAccess.maxToolIterations)) && Number(chatAccess.maxToolIterations) > 0
|
|
64
91
|
? Math.floor(Number(chatAccess.maxToolIterations))
|
|
@@ -75,24 +102,27 @@ function readExternalMcpEndpoints() {
|
|
|
75
102
|
return Object.fromEntries(
|
|
76
103
|
Object.entries(servers)
|
|
77
104
|
.filter(([, endpoint]) => endpoint?.url)
|
|
78
|
-
.map(([name, endpoint]) =>
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
:
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
105
|
+
.map(([name, endpoint]) => {
|
|
106
|
+
const missingCredentials = endpointHasMissingCredentials(endpoint);
|
|
107
|
+
return [
|
|
108
|
+
name,
|
|
109
|
+
{
|
|
110
|
+
...endpointStatus(!missingCredentials, missingCredentials ? 'credential not set' : ''),
|
|
111
|
+
url: normalizeExternalUrlForRuntime(interpolateEnv(String(endpoint.url))),
|
|
112
|
+
configuredUrl: interpolateEnv(String(endpoint.url)),
|
|
113
|
+
headers: normalizeHeaders(endpoint.headers),
|
|
114
|
+
// Tools the endpoint marks approval-gated: Donna may still call them
|
|
115
|
+
// directly (they are single-step tools), but toolRequiresApproval
|
|
116
|
+
// makes the call wait for the user's confirmation first (e.g. a
|
|
117
|
+
// destructive cme_export_run). Agent/operator owned — no hard-coded
|
|
118
|
+
// business name in the manager.
|
|
119
|
+
requireApproval: Array.isArray(endpoint.requireApproval)
|
|
120
|
+
? endpoint.requireApproval.map(String).filter(Boolean)
|
|
121
|
+
: undefined,
|
|
122
|
+
external: true,
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
}),
|
|
96
126
|
);
|
|
97
127
|
}
|
|
98
128
|
|
package/src/runtime/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
3
|
-
import { createAgentEvent, dispatchAgentEvent, resetSessionProjection } from '../core/agentEvents.js';
|
|
3
|
+
import { createAgentEvent, dispatchAgentEvent, resetSessionProjection, reduceAgentEvents } from '../core/agentEvents.js';
|
|
4
4
|
import { activeCacertPath } from '../core/cacert.js';
|
|
5
5
|
import { normalizePlanPatch, rebasePlanPatch } from '../core/planPatch.js';
|
|
6
6
|
import { validateContractInDev } from '../contracts/schemas.js';
|
|
@@ -284,7 +284,11 @@ export function startRuntimeServer({
|
|
|
284
284
|
sendJson(response, 400, { error: 'Missing input.' });
|
|
285
285
|
return;
|
|
286
286
|
}
|
|
287
|
-
|
|
287
|
+
// Read-only chat turns intentionally remain available while an agent
|
|
288
|
+
// run is active. Other interactive turns still become control
|
|
289
|
+
// messages so they cannot start a competing agent decision.
|
|
290
|
+
const readOnlyChat = String(body.mode ?? '').toLowerCase() === 'chat';
|
|
291
|
+
if (context.running && !readOnlyChat) {
|
|
288
292
|
const result = await handleControlMessage(context, store, input, {
|
|
289
293
|
intent: body.intent,
|
|
290
294
|
startNextControlRequest,
|
|
@@ -305,7 +309,7 @@ export function startRuntimeServer({
|
|
|
305
309
|
// A preceding serialized turn may have delegated and started a run
|
|
306
310
|
// after this request was accepted. Reclassify against the fresh
|
|
307
311
|
// state instead of starting another interactive decision in parallel.
|
|
308
|
-
if (context.running) {
|
|
312
|
+
if (context.running && !readOnlyChat) {
|
|
309
313
|
const result = await handleControlMessage(context, store, input, {
|
|
310
314
|
intent: body.intent,
|
|
311
315
|
startNextControlRequest,
|
|
@@ -595,10 +599,18 @@ function controlStatus(context, store) {
|
|
|
595
599
|
};
|
|
596
600
|
}
|
|
597
601
|
|
|
598
|
-
function runtimeState(context, store, { workspace = null, session = null } = {}) {
|
|
602
|
+
export function runtimeState(context, store, { workspace = null, session = null } = {}) {
|
|
599
603
|
const state = store.getState(context?.session ?? session ?? null, { workspace });
|
|
600
604
|
return {
|
|
601
605
|
...state,
|
|
606
|
+
// Interactive (runtime_turn) replies are persisted as events but never
|
|
607
|
+
// merged into the canonical in-memory projection, so a state built from
|
|
608
|
+
// that projection omits them — chat mode and conversational agent turns
|
|
609
|
+
// then show no reply at all. Rebuild the conversation from the full
|
|
610
|
+
// persisted event log (the same source interactive turns use to seed their
|
|
611
|
+
// own history) so those replies surface. The log is a superset of the
|
|
612
|
+
// canonical run conversation, so run rendering is unaffected.
|
|
613
|
+
conversation: reduceAgentEvents(store.listEvents({ workspace })).conversation,
|
|
602
614
|
status: context?.running ? 'running' : state.status ?? 'idle',
|
|
603
615
|
running: Boolean(context?.running),
|
|
604
616
|
runId: context?.currentRunId ?? state.runId ?? null,
|
|
@@ -2,7 +2,14 @@ import assert from 'node:assert/strict';
|
|
|
2
2
|
import test from 'node:test';
|
|
3
3
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
4
4
|
import { createInteractiveSession, ensureInteractiveAssistantMessage } from '../cli/wiki-manager.js';
|
|
5
|
-
import { startRuntimeServer } from './server.js';
|
|
5
|
+
import { runtimeState, startRuntimeServer as startRuntimeServerImpl } from './server.js';
|
|
6
|
+
|
|
7
|
+
// Most server tests exercise endpoint behavior rather than authentication. Keep
|
|
8
|
+
// them independent from a developer's WIKI_MANAGER_RUNTIME_TOKEN environment;
|
|
9
|
+
// auth-specific tests can still override this default explicitly.
|
|
10
|
+
function startRuntimeServer(options) {
|
|
11
|
+
return startRuntimeServerImpl({ token: '', ...options });
|
|
12
|
+
}
|
|
6
13
|
|
|
7
14
|
test('interactive runtime sessions isolate canonical run state', () => {
|
|
8
15
|
const mcp = { wiki: { status: 'connected' } };
|
|
@@ -40,6 +47,35 @@ test('interactive turns publish a fallback assistant message exactly once', () =
|
|
|
40
47
|
assert.equal(published[0].payload.content, 'Réponse concise.');
|
|
41
48
|
});
|
|
42
49
|
|
|
50
|
+
test('runtime state rebuilds interactive conversation from persisted events', () => {
|
|
51
|
+
const user = {
|
|
52
|
+
...createAgentEvent('user_message', { origin: 'runtime_turn', turnId: 'turn-1', workspace: 'demo', payload: { content: 'Bonjour' } }),
|
|
53
|
+
origin: 'runtime_turn', turnId: 'turn-1', workspace: 'demo',
|
|
54
|
+
};
|
|
55
|
+
const assistant = {
|
|
56
|
+
...createAgentEvent('assistant_message', { origin: 'runtime_turn', turnId: 'turn-1', workspace: 'demo', payload: { content: 'Salut !' } }),
|
|
57
|
+
origin: 'runtime_turn', turnId: 'turn-1', workspace: 'demo',
|
|
58
|
+
};
|
|
59
|
+
const session = { agentProjection: { conversation: [] } };
|
|
60
|
+
const store = {
|
|
61
|
+
getState: (receivedSession) => {
|
|
62
|
+
assert.equal(receivedSession, session);
|
|
63
|
+
return { status: 'idle', conversation: [] };
|
|
64
|
+
},
|
|
65
|
+
listEvents: ({ workspace }) => {
|
|
66
|
+
assert.equal(workspace, 'demo');
|
|
67
|
+
return [user, assistant];
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const state = runtimeState({ workspace: 'demo', session, running: false }, store, { workspace: 'demo' });
|
|
72
|
+
|
|
73
|
+
assert.deepEqual(state.conversation.map(({ role, content }) => ({ role, content })), [
|
|
74
|
+
{ role: 'user', content: 'Bonjour' },
|
|
75
|
+
{ role: 'assistant', content: 'Salut !' },
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
43
79
|
test('runtime server checks bearer and x-runtime-token credentials', async (t) => {
|
|
44
80
|
let handle;
|
|
45
81
|
try {
|
|
@@ -1478,6 +1514,50 @@ test('runtime server accepts an interactive turn without starting a run', async
|
|
|
1478
1514
|
}
|
|
1479
1515
|
});
|
|
1480
1516
|
|
|
1517
|
+
test('runtime server keeps read-only chat turns available during an active run', async (t) => {
|
|
1518
|
+
const context = { workspace: 'demo', session: {}, running: true };
|
|
1519
|
+
let received = null;
|
|
1520
|
+
let handle;
|
|
1521
|
+
try {
|
|
1522
|
+
handle = await startRuntimeServer({
|
|
1523
|
+
host: '127.0.0.1',
|
|
1524
|
+
port: 0,
|
|
1525
|
+
token: 'runtime-secret',
|
|
1526
|
+
store: {
|
|
1527
|
+
dbPath: ':memory:',
|
|
1528
|
+
getState: () => ({ status: 'running' }),
|
|
1529
|
+
listEvents: () => [],
|
|
1530
|
+
},
|
|
1531
|
+
getContext: async () => context,
|
|
1532
|
+
run: async () => assert.fail('/turn must not start another runtime run'),
|
|
1533
|
+
turn: async (_context, body, meta) => { received = { body, meta }; },
|
|
1534
|
+
});
|
|
1535
|
+
} catch (err) {
|
|
1536
|
+
if (err?.code === 'EPERM') {
|
|
1537
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
throw err;
|
|
1541
|
+
}
|
|
1542
|
+
try {
|
|
1543
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/turn`, {
|
|
1544
|
+
method: 'POST',
|
|
1545
|
+
headers: { authorization: 'Bearer runtime-secret', 'content-type': 'application/json' },
|
|
1546
|
+
body: JSON.stringify({ input: 'Quel est le statut CME ?', mode: 'chat', workspace: 'demo' }),
|
|
1547
|
+
});
|
|
1548
|
+
assert.equal(response.status, 202);
|
|
1549
|
+
const body = await response.json();
|
|
1550
|
+
assert.equal(body.kind, 'turn');
|
|
1551
|
+
await context.interactiveTurn;
|
|
1552
|
+
assert.equal(received.body.mode, 'chat');
|
|
1553
|
+
assert.equal(received.body.input, 'Quel est le statut CME ?');
|
|
1554
|
+
assert.equal(received.meta.turnId, body.turnId);
|
|
1555
|
+
assert.equal(context.running, true);
|
|
1556
|
+
} finally {
|
|
1557
|
+
await handle.close();
|
|
1558
|
+
}
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1481
1561
|
test('runtime health reports active runs across workspaces', async (t) => {
|
|
1482
1562
|
let handle;
|
|
1483
1563
|
try {
|
package/src/shell/LeftPane.tsx
CHANGED
|
@@ -312,7 +312,7 @@ function renderMarkdownLines(lines: Array<{ text: string; isCode: boolean }>, ro
|
|
|
312
312
|
function isStatusOutput(message: { role: string; content: string }) {
|
|
313
313
|
const content = String(message.content ?? '');
|
|
314
314
|
return message.role === 'command'
|
|
315
|
-
&& content.startsWith('Workspace')
|
|
315
|
+
&& content.trimStart().startsWith('Workspace')
|
|
316
316
|
&& content.includes('Config')
|
|
317
317
|
&& content.includes('MCP');
|
|
318
318
|
}
|
package/src/shell/repl.js
CHANGED
|
@@ -1279,6 +1279,35 @@ async function runDirectChatTurn(input, { session, onUpdate, onStep }) {
|
|
|
1279
1279
|
return { exit: false };
|
|
1280
1280
|
}
|
|
1281
1281
|
|
|
1282
|
+
// Headless equivalent of runDirectChatTurn for HTTP callers (the runtime /turn
|
|
1283
|
+
// in chat mode). Reuses the exact same read-only policy — chatReadTools +
|
|
1284
|
+
// runChatReadToolLoop + buildDirectChatSystemPrompt — so there is no second
|
|
1285
|
+
// implementation of chat access; it just returns the final text instead of
|
|
1286
|
+
// driving a live repl bubble. The caller must have seeded session.chatAccess
|
|
1287
|
+
// (and session.mcp) so chatReadTools can resolve the allow-listed read tools.
|
|
1288
|
+
export async function runHeadlessChatTurn(session, input, { history = [], onStep } = {}) {
|
|
1289
|
+
const donnaMessage = { role: 'donna', content: '' };
|
|
1290
|
+
const readTools = chatReadTools(session);
|
|
1291
|
+
const canUseReadTools = readTools.length > 0 && typeof session.llm?.completeWithTools === 'function';
|
|
1292
|
+
if (canUseReadTools) {
|
|
1293
|
+
await runChatReadToolLoop({ input, session, history, donnaMessage, onStep, readTools });
|
|
1294
|
+
return donnaMessage.content;
|
|
1295
|
+
}
|
|
1296
|
+
if (typeof session.llm?.stream === 'function') {
|
|
1297
|
+
let content = '';
|
|
1298
|
+
for await (const delta of session.llm.stream({
|
|
1299
|
+
system: buildDirectChatSystemPrompt(session),
|
|
1300
|
+
messages: [...history, { role: 'user', content: input }],
|
|
1301
|
+
signal: session._abortSignal,
|
|
1302
|
+
})) {
|
|
1303
|
+
const clean = stripDsmlArtifacts(delta);
|
|
1304
|
+
if (clean) content += clean;
|
|
1305
|
+
}
|
|
1306
|
+
return stripDsmlArtifacts(content).trimEnd() || formatLlmUnavailableMessage('flux vide');
|
|
1307
|
+
}
|
|
1308
|
+
return directChatUnavailableText(session);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1282
1311
|
function directChatUnavailableText(session) {
|
|
1283
1312
|
if (!session.workspacePath) {
|
|
1284
1313
|
return 'Direct chat unavailable: no workspace loaded. Use /use <workspace>.';
|
package/src/shell/repl.test.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
applyRuntimeStateToShellSession,
|
|
8
8
|
chatReadTools,
|
|
9
9
|
createSession,
|
|
10
|
+
runHeadlessChatTurn,
|
|
10
11
|
conversationMessages,
|
|
11
12
|
recordRuntimeUnavailableAgentInput,
|
|
12
13
|
runLine,
|
|
@@ -443,3 +444,36 @@ test('/chat falls back to the plain stream when no read tools are declared', asy
|
|
|
443
444
|
assert.match(last.content, /PLAIN_STREAM/);
|
|
444
445
|
assert.doesNotMatch(last.content, /SHOULD_NOT_APPEAR/);
|
|
445
446
|
});
|
|
447
|
+
|
|
448
|
+
test('runHeadlessChatTurn (HTTP /chat) uses the read-tool path and returns text', async () => {
|
|
449
|
+
const session = createSession();
|
|
450
|
+
session.chatMode = true;
|
|
451
|
+
session.chatAccess = { maxToolIterations: 4, servers: { cme: { allow: ['cme_status'] } } };
|
|
452
|
+
session.mcp = { cme: { status: 'connected', tools: [{ name: 'cme_status', inputSchema: { type: 'object', properties: {} } }] } };
|
|
453
|
+
let usedComplete = false;
|
|
454
|
+
session.llm = {
|
|
455
|
+
async *stream() { yield 'STREAM_FALLBACK'; },
|
|
456
|
+
async completeWithTools() {
|
|
457
|
+
usedComplete = true;
|
|
458
|
+
return { tool_calls: [], content: 'CME est configuré.', message: { role: 'assistant', content: 'CME est configuré.' } };
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
const reply = await runHeadlessChatTurn(session, 'le cme est-il configuré', { history: [] });
|
|
462
|
+
assert.ok(usedComplete, 'completeWithTools path was taken');
|
|
463
|
+
assert.match(reply, /CME est configuré/);
|
|
464
|
+
assert.doesNotMatch(reply, /STREAM_FALLBACK/);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test('runHeadlessChatTurn falls back to the plain stream without read tools', async () => {
|
|
468
|
+
const session = createSession();
|
|
469
|
+
session.chatMode = true;
|
|
470
|
+
session.chatAccess = null;
|
|
471
|
+
session.mcp = {};
|
|
472
|
+
session.llm = {
|
|
473
|
+
async *stream() { yield 'PLAIN_STREAM'; },
|
|
474
|
+
async completeWithTools() { return { tool_calls: [], content: 'SHOULD_NOT_APPEAR' }; },
|
|
475
|
+
};
|
|
476
|
+
const reply = await runHeadlessChatTurn(session, 'bonjour', { history: [] });
|
|
477
|
+
assert.match(reply, /PLAIN_STREAM/);
|
|
478
|
+
assert.doesNotMatch(reply, /SHOULD_NOT_APPEAR/);
|
|
479
|
+
});
|