@dotdrelle/wiki-manager 0.15.27 → 0.15.29

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 CHANGED
@@ -731,16 +731,17 @@ capabilityRouting:
731
731
 
732
732
  #### Compose overrides — optional agents, proxies, local fixes
733
733
 
734
- Two override files sit **next to your `.env`**, one per stack:
734
+ Two override files sit under **`.wiki/compose/`**, one per stack:
735
735
 
736
736
  | File | Applies to |
737
737
  | --- | --- |
738
- | `docker-compose.override.yml` | workspace stack (`serve`, `mcp-http`, `production-mcp`, `wiki`) |
739
- | `agents.docker-compose.override.yml` | agents stack (`cme`, `documents`, `connectors`) |
738
+ | `.wiki/compose/docker-compose.override.yml` | workspace stack (`serve`, `mcp-http`, `production-mcp`, `wiki`) |
739
+ | `.wiki/compose/agents.docker-compose.override.yml` | agents stack (`cme`, `documents`, `connectors`) |
740
740
 
741
741
  Both are created for you on first use, from packaged templates full of
742
742
  ready-to-uncomment examples, and are **never rewritten afterwards** — your edits
743
- survive package updates. Do not confuse them with `.wiki/runtime/*.compose.yml`,
743
+ survive package updates. Existing root-level files are migrated automatically.
744
+ Do not confuse them with `.wiki/runtime/*.compose.yml`,
744
745
  which the manager regenerates on every Compose command; editing those is always
745
746
  lost.
746
747
 
@@ -1,4 +1,4 @@
1
- # agents.docker-compose.override.yml — user-owned overrides for the agents stack
1
+ # .wiki/compose/agents.docker-compose.override.yml — user-owned agent overrides
2
2
  #
3
3
  # Copied here once by wiki-manager when absent, then NEVER touched again:
4
4
  # your edits survive every package update. Do not confuse it with
@@ -1,11 +1,11 @@
1
- # docker-compose.override.yml — user-owned overrides for the workspace stack
1
+ # .wiki/compose/docker-compose.override.yml — user-owned workspace overrides
2
2
  #
3
3
  # Copied here once by wiki-manager when absent, then NEVER touched again:
4
4
  # your edits survive every package update. Do not confuse it with
5
5
  # .wiki/runtime/cacert.compose.yml, which is generated state and is rewritten
6
6
  # on every compose command — editing that one is always lost.
7
7
  #
8
- # This file is GLOBAL: it lives next to the manager .env and applies to every
8
+ # This file is GLOBAL: it lives under .wiki/compose and applies to every
9
9
  # workspace stack (`wiki-workspace up <workspace>`, `/start` in the shell). A
10
10
  # proxy or a private registry is a property of the machine, not of a workspace,
11
11
  # so there is deliberately no per-workspace variant. For values that really do
@@ -29,7 +29,7 @@
29
29
  "chatAccess": {
30
30
  "maxToolIterations": 8,
31
31
  "servers": {
32
- "llm-wiki": { "allow": ["help_list", "help_read", "wiki_workspace_status", "wiki_list_pages", "wiki_read_page", "wiki_read_pages", "wiki_search_context", "wiki_collect_context", "wiki_read_ingested_source"] },
32
+ "llm-wiki": { "allow": ["help_list", "help_read", "help_search", "wiki_workspace_status", "wiki_list_pages", "wiki_read_page", "wiki_read_pages", "wiki_search_context", "wiki_collect_context", "wiki_read_ingested_source"] },
33
33
  "wiki-production": { "allow": ["production_job_status", "production_jobs_list"] },
34
34
  "cme": { "allow": ["cme_status", "cme_sources_list", "cme_export_status"] }
35
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.27",
3
+ "version": "0.15.29",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "author": "dotrelle",
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "scripts": {
13
13
  "start": "bun ./bin/wiki-manager.js",
14
- "test": "node --test src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardPlaceholders.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
14
+ "test": "node --test src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardPlaceholders.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
15
15
  "check-versions": "node scripts/check-versions.js",
16
16
  "prepack": "node scripts/check-versions.js",
17
17
  "prepublishOnly": "node scripts/check-versions.js",
@@ -376,7 +376,10 @@ async function classifyRequestedAction(llm, input, signal) {
376
376
  const system = [
377
377
  'Classify whether the user explicitly requests a real state-changing action now.',
378
378
  'Actions include starting, stopping, importing, ingesting, building, exporting, configuring, writing, deleting, or sending.',
379
- 'Questions, explanations, status questions, greetings, and hypothetical discussions are not actions.',
379
+ 'Questions, explanations, status questions, greetings, hypothetical discussions, and bare capability questions are not actions.',
380
+ 'Requests to refresh, show, or update the displayed plan/status are status reads, not state-changing actions.',
381
+ 'A bare capability question such as "can you send an email?" or "peux-tu envoyer un mail ?" asks what the assistant can do; it does not request execution.',
382
+ 'A concrete imperative or polite request such as "send this email to Alice" or "peux-tu envoyer ce message à Alice ?" is an action.',
380
383
  'Return JSON only: {"action":true} or {"action":false}.',
381
384
  ].join('\n');
382
385
  const messages = [{ role: 'user', content: String(input ?? '') }];
@@ -427,6 +430,28 @@ async function classifyRequestedAction(llm, input, signal) {
427
430
  }
428
431
  }
429
432
 
433
+ function looksLikeCapabilityQuestion(input) {
434
+ return /^(?:can|could|would)\s+you\b|^are\s+you\s+able\b|^do\s+you\s+(?:know\s+how|support)\b|^tu\s+peux\b|^vous\s+pouvez\b|^peux[\s-]*tu\b|^pouvez[\s-]*vous\b|^est[\s-]*ce\s+que\s+tu\s+peux\b/i
435
+ .test(String(input ?? '').trim());
436
+ }
437
+
438
+ function delegationBlockerForDonna(rawFailure) {
439
+ const cleaned = String(rawFailure ?? '')
440
+ .replace(/^[A-Za-z][A-Za-z0-9_]*Error\s*:?\s*/i, '')
441
+ .replace(/\s*Available capabilities:\s*[\s\S]*$/i, '')
442
+ .trim();
443
+ const reason = /No connected agent can do that|No orchestrable capability/i.test(cleaned)
444
+ ? 'No connected agent currently supports the requested action.'
445
+ : 'The requested action could not be assigned to a connected agent.';
446
+ return JSON.stringify({
447
+ delegated: false,
448
+ blocker: 'unsupported_action',
449
+ reason,
450
+ instruction:
451
+ 'Answer the user naturally in their language. Explain the concrete limitation briefly. Do not expose exception names, capability identifiers, tool names, UUIDs, or internal routing details. Do not retry or claim that an action started.',
452
+ });
453
+ }
454
+
430
455
  function summarizeToolArguments(rawArguments) {
431
456
  if (!rawArguments || rawArguments === '{}') return '';
432
457
  try {
@@ -816,9 +841,14 @@ async function handleRuntimeControlTool(session, tool, args = {}) {
816
841
  }
817
842
  }
818
843
 
819
- function connectorConfigurationTarget(session, objective) {
820
- const text = String(objective ?? '').toLowerCase();
821
- if (!/(?:configur|connect|authent|oauth|setup|sign[ -]?in)/i.test(text)) return null;
844
+ export function connectorConfigurationTarget(session, objective) {
845
+ const recentContext = (session?.agentProjection?.conversation ?? [])
846
+ .slice(-6)
847
+ .filter((message) => message?.role === 'user')
848
+ .map((message) => String(message?.content ?? ''))
849
+ .join(' ');
850
+ const text = `${recentContext} ${String(objective ?? '')}`.trim().toLowerCase();
851
+ if (!/(?:configur|connect|authent|oauth|setup|sign[ -]?in|\bpat\b|api[ _-]?token|credential|identifiant|mot de passe|password)/i.test(text)) return null;
822
852
  for (const [serverName, server] of Object.entries(session?.mcp ?? {})) {
823
853
  if (server?.status !== 'connected' || !Array.isArray(server.tools) || server.tools.length === 0) continue;
824
854
  const genericAliasParts = new Set([
@@ -977,6 +1007,7 @@ export function buildAgentSystemPrompt(state) {
977
1007
  'When calling a tool, emit no preliminary narration. Call it directly; the PLAN and Activity panels show progress. After completion, keep the final response concise and proportional to the result.',
978
1008
  'Write the way a thoughtful colleague speaks: warm, plain, and to the point. For a simple factual question, 1 to 3 sentences is the sweet spot. Stay synthetic and information-dense — use only the lines needed, and never exceed roughly 15 to 20 short lines even for a detailed answer. Never expose internal reasoning, repeated checks, tool-selection commentary, or a chronological diary. Prioritize the result, essential facts, concrete errors, and actual outputs — but say them in human language, not as a field dump.',
979
1009
  'Call a matching direct tool when one is offered. Otherwise, for an action backed by a discovered agent capability, call runtime__delegate with the original objective. This applies to both planner agents and executor-only single-task agents. Never call an agent orchestration-contract or plan tool directly.',
1010
+ 'Configuration is not a business run. When a connected server offers a setup or configuration tool, use it directly; never delegate configuration to an export, collect, send, build, or ingest capability. Read that server status first when existing non-secret values are needed, then ask only for required values that are still missing.',
980
1011
  'For any question about the current workspace inventory or what is waiting there, call wiki__wiki_workspace_status first and answer only from its result. This is the canonical read-only workspace state; do not reconstruct it from upload, connector, or production tools.',
981
1012
  'Tool identifiers are private implementation details. Never print MCP tool names such as server__tool in a user-facing answer. Describe the human result instead.',
982
1013
  'Internal data shapes are private too. Never quote raw JSON field names (e.g. pendingSources.files), internal directory paths (e.g. raw/untracked/), or config keys in a user-facing answer — translate them into plain language. Say "36 pages sources sont en attente d\'ingestion", not the field or path they came from.',
@@ -998,6 +1029,7 @@ export function buildAgentSystemPrompt(state) {
998
1029
  ? `Workspace profile (.wiki/profile.md) — durable user preferences, apply these to every reply (tone, tutoiement/vouvoiement, formatting, etc.):\n${workspaceProfile}`
999
1030
  : null,
1000
1031
  'Runtime control: you have runtime__status, runtime__cancel, runtime__kill, runtime__approve and runtime__enqueue. When the user asks to stop, remove, clean or kill the current run, its jobs or the queue ("supprime le job et la queue", "arr\u00eate tout"), call runtime__kill (or runtime__cancel for a soft stop of just the run) and confirm what was stopped. When the user explicitly asks to delete, reset, abandon or replace the current plan, call runtime__kill with purge=true; never set purge=true for a simple stop. For questions about what is running or queued, call runtime__status and answer from its data. When the user consents to a pending approval in any phrasing ("vas-y", "ok pour l\'export"), call runtime__approve. When the user asks for a NEW action while a run is active, do not execute it: propose runtime__enqueue (run it after) or, if they insist it replaces the current work, runtime__kill then the new action.',
1032
+ 'When the user asks to refresh, show, or update the displayed plan or status, call runtime__status. This is a state refresh request, not a new business capability, and must never be delegated.',
1001
1033
  'Report every runtime control outcome exactly as the tool returned it \u2014 never embellish. If runtime__kill reports 0 run(s)/0 task(s)/0 purged, say there was nothing active to stop or purge; do NOT claim a run, plan, pending approval or queue item was removed. If runtime__status returns an error or could not be read, say the runtime state could not be retrieved and do not describe a state you never obtained. Never assert that something was cleaned, cancelled, approved or purged unless that specific tool result confirms it.',
1002
1034
  'Durable profile updates are actions in this stabilized version: delegate them instead of writing directly.',
1003
1035
  ].filter(Boolean).join('\n');
@@ -1089,7 +1121,10 @@ export function isOrchestrationBypassTool(name) {
1089
1121
  if (full === 'wiki__plan_set' || full === 'wiki__plan_done') return true;
1090
1122
  const sep = full.indexOf('__');
1091
1123
  const tool = sep === -1 ? full : full.slice(sep + 2);
1092
- return tool === 'agent_plan' || tool === 'agent_execute' || tool === 'production_start_job';
1124
+ return tool === 'agent_plan'
1125
+ || tool === 'agent_execute'
1126
+ || tool === 'production_start_job'
1127
+ || tool === 'cme_export_run';
1093
1128
  }
1094
1129
 
1095
1130
  function isReadOnlyMcpCall(session, server, tool) {
@@ -1195,7 +1230,9 @@ export function createAgentGraph(options = {}) {
1195
1230
  WIKI_PLAN_DONE_TOOL,
1196
1231
  ...buildLlmTools(state.session.mcp),
1197
1232
  ];
1198
- const tools = toolsForClassification(classification, writeTools, state.session);
1233
+ const tools = state.terminalToolFailure || state.session._responseSynthesisOnly
1234
+ ? []
1235
+ : toolsForClassification(classification, writeTools, state.session);
1199
1236
  const system = buildAgentSystemPrompt(state);
1200
1237
 
1201
1238
  // On iteration 0: prior history is in state.messages, user input must be appended.
@@ -1445,6 +1482,7 @@ export function createAgentGraph(options = {}) {
1445
1482
  }
1446
1483
 
1447
1484
  async function toolExecutorNode(state) {
1485
+ const llm = state.session.llm ?? options.llm ?? null;
1448
1486
  const toolCalls = state.pendingToolCalls ?? [];
1449
1487
  const toolResultMessages = [];
1450
1488
  let terminalFailure = null;
@@ -1535,12 +1573,41 @@ export function createAgentGraph(options = {}) {
1535
1573
  const result = await updateWorkspaceProfilePreference(state.session, args.preference);
1536
1574
  resultText = JSON.stringify(result, null, 2);
1537
1575
  } else if (server === 'runtime') {
1538
- resultText = await handleRuntimeControlTool(state.session, tool, args);
1576
+ const isCapabilityQuestion = tool === 'delegate'
1577
+ && !state.session._currentRunIdentity
1578
+ && looksLikeCapabilityQuestion(String(args.objective ?? state.input ?? ''))
1579
+ && !await classifyRequestedAction(
1580
+ llm,
1581
+ String(args.objective ?? state.input ?? ''),
1582
+ state.session._abortSignal,
1583
+ );
1584
+ resultText = isCapabilityQuestion
1585
+ ? JSON.stringify({
1586
+ delegated: false,
1587
+ capabilityQuestion: true,
1588
+ instruction: 'Answer the user conversationally about whether this action is supported. Do not create a plan or claim that execution started.',
1589
+ })
1590
+ : await handleRuntimeControlTool(state.session, tool, args);
1539
1591
  if (tool === 'delegate' && /^Runtime control error \(delegate\):/i.test(resultText)) {
1540
- terminalFailure = resultText
1592
+ const delegationFailure = resultText
1541
1593
  .replace(/^Runtime control error \(delegate\):\s*/i, '')
1542
1594
  .replace(/^Delegation failed during objective_resolution:\s*/i, '');
1543
- ok = false;
1595
+ const needsInput = delegationFailure.match(/^Delegation requires input:\s*(.+)$/i);
1596
+ if (needsInput) {
1597
+ // Missing provider-required fields are a conversational blocker,
1598
+ // not an execution failure. Feed the generic field list back to
1599
+ // Donna so she can ask naturally in the workspace language.
1600
+ resultText = JSON.stringify({
1601
+ delegated: false,
1602
+ needsInput: true,
1603
+ missingRequiredFields: needsInput[1].split(',').map((item) => item.trim()).filter(Boolean),
1604
+ instruction: 'Ask the user for the missing required information. Do not expose internal validation details.',
1605
+ });
1606
+ } else {
1607
+ terminalFailure = delegationFailure;
1608
+ resultText = delegationBlockerForDonna(delegationFailure);
1609
+ ok = false;
1610
+ }
1544
1611
  }
1545
1612
  } else if (server !== 'shell') {
1546
1613
  await awaitRunApproval(state.session, { runId, tool: toolName });
@@ -1641,10 +1708,7 @@ export function createAgentGraph(options = {}) {
1641
1708
  }
1642
1709
 
1643
1710
  if (terminalFailure) {
1644
- const response = `Action non lancée : ${terminalFailure}`;
1645
- emitAgentEvent(state.session, 'assistant_message', 'agent_guard', { content: response });
1646
1711
  return {
1647
- response,
1648
1712
  messages: toolResultMessages,
1649
1713
  pendingToolCalls: null,
1650
1714
  forceDelegation: false,
@@ -1666,7 +1730,7 @@ export function createAgentGraph(options = {}) {
1666
1730
  }
1667
1731
 
1668
1732
  function routeToolExecutor(state) {
1669
- return state.terminalToolFailure ? END : 'orchestrator';
1733
+ return 'orchestrator';
1670
1734
  }
1671
1735
 
1672
1736
  function routeOrchestrator(state) {
@@ -3,7 +3,7 @@ import test from 'node:test';
3
3
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
- import { buildAgentSystemPrompt, createAgentGraph, invalidSuggestedSlashCommands, invalidUserFacingToolNames, knownCapabilityIds, normalizeToolArgumentsFromSchema } from './graph.js';
6
+ import { buildAgentSystemPrompt, connectorConfigurationTarget, createAgentGraph, invalidSuggestedSlashCommands, invalidUserFacingToolNames, isOrchestrationBypassTool, knownCapabilityIds, normalizeToolArgumentsFromSchema } from './graph.js';
7
7
 
8
8
  test('user-facing response guard hides MCP identifiers generically', () => {
9
9
  const session = sessionBase();
@@ -13,6 +13,29 @@ test('user-facing response guard hides MCP identifiers generically', () => {
13
13
  );
14
14
  });
15
15
 
16
+ test('CME setup stays direct while CME export execution stays orchestrated', () => {
17
+ assert.equal(isOrchestrationBypassTool('cme__cme_export_run'), true);
18
+ assert.equal(isOrchestrationBypassTool('cme__cme_setup'), false);
19
+ });
20
+
21
+ test('configuration routing retains the recent CME conversation context', () => {
22
+ const target = connectorConfigurationTarget({
23
+ agentProjection: {
24
+ conversation: [{ role: 'user', content: 'je veux configurer le CME' }],
25
+ },
26
+ mcp: {
27
+ cme: {
28
+ status: 'connected',
29
+ tools: [
30
+ { name: 'cme_setup', description: 'Configure Confluence credentials.' },
31
+ { name: 'cme_export_run', description: 'Run export.' },
32
+ ],
33
+ },
34
+ },
35
+ }, 'configurer l’agent wiki');
36
+ assert.deepEqual(target, { serverName: 'cme', setupTool: 'cme_setup' });
37
+ });
38
+
16
39
  test('Donna cannot answer an explicit action with manual instructions instead of delegating', async () => {
17
40
  const originalFetch = globalThis.fetch;
18
41
  let delegated = false;
@@ -57,6 +80,68 @@ test('Donna cannot answer an explicit action with manual instructions instead of
57
80
  }
58
81
  });
59
82
 
83
+ test('a bare capability question returns to Donna without runtime delegation', async () => {
84
+ const originalFetch = globalThis.fetch;
85
+ let delegated = false;
86
+ globalThis.fetch = async (url) => {
87
+ delegated ||= String(url).includes('/delegate');
88
+ if (String(url).includes('/delegate')) throw new Error(`Unexpected runtime request: ${url}`);
89
+ return { ok: true, status: 200, json: async () => ({ status: 'idle', running: false }) };
90
+ };
91
+ let mainCalls = 0;
92
+ const session = sessionBase({
93
+ runtime: { url: 'http://runtime.test' },
94
+ llm: {
95
+ async completeWithTools({ tools, messages }) {
96
+ if (tools.some((tool) => tool.function?.name === 'classify_action_request')) {
97
+ return {
98
+ content: null,
99
+ message: { role: 'assistant', content: null },
100
+ tool_calls: [{
101
+ id: 'classify-capability-question',
102
+ type: 'function',
103
+ function: { name: 'classify_action_request', arguments: '{"action":false}' },
104
+ }],
105
+ };
106
+ }
107
+ mainCalls += 1;
108
+ if (mainCalls === 1) {
109
+ return {
110
+ content: null,
111
+ message: { role: 'assistant', content: null },
112
+ tool_calls: [{
113
+ id: 'wrong-delegate',
114
+ type: 'function',
115
+ function: { name: 'runtime__delegate', arguments: '{"objective":"tu peux envoyer un mail ?"}' },
116
+ }],
117
+ };
118
+ }
119
+ const result = JSON.parse(
120
+ String((messages ?? []).filter((message) => message.role === 'tool').at(-1)?.content ?? '{}'),
121
+ );
122
+ assert.equal(result.capabilityQuestion, true);
123
+ return {
124
+ content: 'Oui, je peux envoyer un mail si tu me donnes le destinataire, le sujet et le contenu.',
125
+ message: {
126
+ role: 'assistant',
127
+ content: 'Oui, je peux envoyer un mail si tu me donnes le destinataire, le sujet et le contenu.',
128
+ },
129
+ tool_calls: null,
130
+ };
131
+ },
132
+ },
133
+ });
134
+
135
+ try {
136
+ const result = await createAgentGraph().invoke({ input: 'tu peux envoyer un mail ?', session });
137
+ assert.equal(delegated, false);
138
+ assert.equal(mainCalls, 2);
139
+ assert.match(result.response, /Oui, je peux envoyer un mail/);
140
+ } finally {
141
+ globalThis.fetch = originalFetch;
142
+ }
143
+ });
144
+
60
145
  function sessionBase(overrides = {}) {
61
146
  return {
62
147
  commands: ['status'],
@@ -949,7 +1034,7 @@ test('forced delegation is cleared after one valid tool call and does not loop',
949
1034
  }
950
1035
  });
951
1036
 
952
- test('a rejected runtime delegation is terminal and never loops', async () => {
1037
+ test('a rejected runtime delegation returns to Donna once without leaking technical details', async () => {
953
1038
  const originalFetch = globalThis.fetch;
954
1039
  globalThis.fetch = async () => ({
955
1040
  ok: false,
@@ -964,6 +1049,16 @@ test('a rejected runtime delegation is terminal and never loops', async () => {
964
1049
  llm: {
965
1050
  async completeWithTools() {
966
1051
  calls += 1;
1052
+ if (calls === 2) {
1053
+ return {
1054
+ content: 'Je ne peux pas lancer cette action : aucun agent connecté ne la prend actuellement en charge.',
1055
+ message: {
1056
+ role: 'assistant',
1057
+ content: 'Je ne peux pas lancer cette action : aucun agent connecté ne la prend actuellement en charge.',
1058
+ },
1059
+ tool_calls: [],
1060
+ };
1061
+ }
967
1062
  return {
968
1063
  content: null,
969
1064
  message: { role: 'assistant', content: null },
@@ -979,14 +1074,63 @@ test('a rejected runtime delegation is terminal and never loops', async () => {
979
1074
 
980
1075
  try {
981
1076
  const result = await createAgentGraph().invoke({ input: 'lance ingestion', session });
982
- assert.equal(calls, 1);
983
- assert.equal(result.response, 'Action non lancée : No orchestrable capability is currently available.');
1077
+ assert.equal(calls, 2);
1078
+ assert.equal(
1079
+ result.response,
1080
+ 'Je ne peux pas lancer cette action : aucun agent connecté ne la prend actuellement en charge.',
1081
+ );
1082
+ assert.doesNotMatch(result.response, /ObjectiveNotOrchestrableError|capabilit|runtime__|[0-9a-f]{8}-/i);
984
1083
  assert.equal(result.terminalToolFailure, true);
985
1084
  } finally {
986
1085
  globalThis.fetch = originalFetch;
987
1086
  }
988
1087
  });
989
1088
 
1089
+ test('a delegation missing required provider inputs returns to Donna for clarification', async () => {
1090
+ const originalFetch = globalThis.fetch;
1091
+ globalThis.fetch = async () => ({
1092
+ ok: false,
1093
+ status: 422,
1094
+ json: async () => ({
1095
+ error: 'Delegation requires input: to, subject, body',
1096
+ }),
1097
+ });
1098
+ let calls = 0;
1099
+ const session = sessionBase({
1100
+ runtime: { url: 'http://runtime.test' },
1101
+ llm: {
1102
+ async completeWithTools() {
1103
+ calls += 1;
1104
+ if (calls === 1) {
1105
+ return {
1106
+ content: null,
1107
+ message: { role: 'assistant', content: null },
1108
+ tool_calls: [{
1109
+ id: 'delegate-needs-input',
1110
+ type: 'function',
1111
+ function: { name: 'runtime__delegate', arguments: '{"objective":"envoie un mail"}' },
1112
+ }],
1113
+ };
1114
+ }
1115
+ return {
1116
+ content: 'Oui. À qui dois-je écrire, avec quel objet et quel message ?',
1117
+ message: { role: 'assistant', content: 'Oui. À qui dois-je écrire, avec quel objet et quel message ?' },
1118
+ tool_calls: [],
1119
+ };
1120
+ },
1121
+ },
1122
+ });
1123
+
1124
+ try {
1125
+ const result = await createAgentGraph().invoke({ input: 'envoie un mail', session });
1126
+ assert.equal(calls, 2);
1127
+ assert.equal(result.response, 'Oui. À qui dois-je écrire, avec quel objet et quel message ?');
1128
+ assert.equal(result.terminalToolFailure, false);
1129
+ } finally {
1130
+ globalThis.fetch = originalFetch;
1131
+ }
1132
+ });
1133
+
990
1134
  // Guard: the system prompt must never show a connected tool's bare name
991
1135
  // outside its qualified server__tool form. Bare mentions are what teach the
992
1136
  // model to emit unqualified tool calls (the cme_status incident). The bare
@@ -171,6 +171,18 @@ export async function resolveExecutorArguments({ llm, objective, capability, sig
171
171
  return {};
172
172
  }
173
173
 
174
+ export function missingRequiredArguments(schema, args) {
175
+ const required = Array.isArray(schema?.required) ? schema.required.map(String) : [];
176
+ const values = args && typeof args === 'object' && !Array.isArray(args) ? args : {};
177
+ return required.filter((key) => {
178
+ const value = values[key];
179
+ if (value === undefined || value === null) return true;
180
+ if (typeof value === 'string') return value.trim() === '';
181
+ if (Array.isArray(value)) return value.length === 0;
182
+ return false;
183
+ });
184
+ }
185
+
174
186
  function safeParseArgumentObject(text) {
175
187
  const cleaned = String(text ?? '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
176
188
  if (!cleaned) return null;
@@ -1013,6 +1025,13 @@ async function runRuntime(argv, agent) {
1013
1025
  capability: provider.capability,
1014
1026
  signal: session._abortSignal,
1015
1027
  });
1028
+ const missingArguments = missingRequiredArguments(
1029
+ provider.capability?.inputSchema,
1030
+ extractedArguments,
1031
+ );
1032
+ if (missingArguments.length > 0) {
1033
+ throw new Error(`Delegation requires input: ${missingArguments.join(', ')}`);
1034
+ }
1016
1035
  fragment = buildExecutorOnlyFragment({
1017
1036
  objective,
1018
1037
  workspace: session.workspace ?? context.workspace ?? 'workspace',
@@ -1434,21 +1453,35 @@ export async function runCli(argv) {
1434
1453
  // in a random cwd must not litter files.
1435
1454
  const scaffolded = ensureManagerScaffold({ log: (message) => console.log(`[wiki-manager] ${message}`) });
1436
1455
  if (scaffolded.length > 0) loadManagerEnv();
1437
- const reportCheck = ({ kind, ok, detail, context, skipped, pending }) => {
1456
+ const reportCheck = ({ kind, ok, detail, context, skipped, pending, requested }) => {
1457
+ // "waiting" checks (containers not up yet, MCP not connected yet) are the
1458
+ // normal state at launch — the shell starts them and reports the real
1459
+ // status afterwards, so echoing them here is pure noise. Only what is
1460
+ // ready or actually broken is printed.
1461
+ //
1462
+ // `pending` alone cannot express this: a service the operator explicitly
1463
+ // asked for (`requested`) is still "pending" right after /start, and that
1464
+ // one MUST be shown — silence there is how a failed start reads as a
1465
+ // success. Boot silence applies to unrequested checks only.
1466
+ if (!ok && (pending || skipped) && !requested) return;
1438
1467
  const labels = { docker: 'Docker', internet: 'Internet', agents: 'Agent containers', workspace: 'Workspaces', containers: 'Workspace containers', mcp: 'MCP' };
1439
1468
  const label = labels[kind] ?? kind;
1440
1469
  const instruction = !ok && context?.command ? ` — command: ${context.command}` : '';
1441
- const suffix = detail ? ` — ${detail}` : ` — ${context?.error ?? context?.dockerError ?? 'waiting'}`;
1470
+ // On success the detail is diagnostic only (the probe URL for Internet);
1471
+ // the label already says what passed.
1472
+ const suffix = ok ? '' : ` — ${detail || context?.error || context?.dockerError || 'waiting'}`;
1442
1473
  const color = ok ? '\x1b[32m' : '\x1b[33m';
1443
- const state = ok ? 'ready' : pending || skipped ? 'waiting' : 'needs attention';
1444
- const icon = ok ? '✓' : pending || skipped ? '◐' : '✗';
1474
+ const state = ok ? 'ready' : 'needs attention';
1475
+ const icon = ok ? '✓' : '✗';
1445
1476
  console.log(`${color}${icon} configuration: ${label} ${state}${suffix}${instruction}\x1b[0m`);
1446
1477
  };
1447
1478
  let preflight = await runPreflightChecks({ onCheck: reportCheck });
1448
- if (preflight.gaps.length > 0) {
1449
- await runStartupWizard(preflight.gaps);
1450
- // The wizard may have created a workspace, started agents or repaired
1451
- // configuration. Re-read everything before drawing the home screen.
1479
+ const wizardGaps = startupWizardGaps(preflight.gaps);
1480
+ if (wizardGaps.length > 0) {
1481
+ await runStartupWizard(wizardGaps);
1482
+ // The wizard may have created a workspace or repaired configuration.
1483
+ // Agents are deliberately excluded: starting them is an explicit console
1484
+ // action through /start agents or /start all.
1452
1485
  preflight = await runPreflightChecks();
1453
1486
  }
1454
1487
  const dockerReady = preflight.checks.some((check) => check.kind === 'docker' && check.ok);
@@ -1503,3 +1536,10 @@ export async function runCli(argv) {
1503
1536
  await shutdownOwnedRuntime(runtime, { log: (message) => console.log(`[wiki-manager] ${message}`) });
1504
1537
  }
1505
1538
  }
1539
+
1540
+ // Missing/stopped agents are status information, never a reason to interrupt
1541
+ // startup with a confirmation screen. The operator owns their lifecycle from
1542
+ // the console (`/start agents`, `/start all`, `/stop agents`).
1543
+ export function startupWizardGaps(gaps = []) {
1544
+ return gaps.filter((gap) => gap?.kind !== 'agents');
1545
+ }
@@ -3,8 +3,10 @@ import test from 'node:test';
3
3
  import {
4
4
  buildExecutorOnlyFragment,
5
5
  forwardRuntimeApproval,
6
+ missingRequiredArguments,
6
7
  resolveExecutorArguments,
7
8
  resolvePreparedDelegationApproval,
9
+ startupWizardGaps,
8
10
  } from './wiki-manager.js';
9
11
 
10
12
  const COLLECT_CAPABILITY = {
@@ -19,6 +21,18 @@ const COLLECT_CAPABILITY = {
19
21
  },
20
22
  };
21
23
 
24
+ test('startup never opens the setup wizard just because agents are stopped', () => {
25
+ const workspace = { kind: 'workspace', context: {} };
26
+ assert.deepEqual(
27
+ startupWizardGaps([
28
+ { kind: 'agents', context: { downServices: ['cme', 'documents'] } },
29
+ workspace,
30
+ ]),
31
+ [workspace],
32
+ );
33
+ assert.deepEqual(startupWizardGaps([{ kind: 'agents' }]), []);
34
+ });
35
+
22
36
  test('executor-only capabilities receive one manager-authored executable task', () => {
23
37
  const fragment = buildExecutorOnlyFragment({
24
38
  objective: 'donne-moi mes derniers mails',
@@ -96,6 +110,27 @@ test('argument extraction stays agnostic and safe when it cannot extract', async
96
110
  );
97
111
  });
98
112
 
113
+ test('required executor arguments become a conversational blocker before plan validation', () => {
114
+ const schema = {
115
+ type: 'object',
116
+ required: ['to', 'subject', 'body'],
117
+ properties: {
118
+ to: { type: 'string' },
119
+ subject: { type: 'string' },
120
+ body: { type: 'string' },
121
+ },
122
+ };
123
+ assert.deepEqual(missingRequiredArguments(schema, {}), ['to', 'subject', 'body']);
124
+ assert.deepEqual(
125
+ missingRequiredArguments(schema, { to: 'a@example.test', subject: 'Hello', body: 'Message' }),
126
+ [],
127
+ );
128
+ assert.deepEqual(
129
+ missingRequiredArguments(schema, { to: [], subject: ' ', body: 'Message' }),
130
+ ['to', 'subject'],
131
+ );
132
+ });
133
+
99
134
  test('runtime approval bridge preserves the complete run-scoped grant', async () => {
100
135
  let forwarded = null;
101
136
  const request = {