@dotdrelle/wiki-manager 0.15.28 → 0.15.32

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.
Files changed (43) hide show
  1. package/.env.example +5 -2
  2. package/README.md +5 -4
  3. package/agents.docker-compose.override.example.yml +1 -1
  4. package/agents.docker-compose.yml +10 -0
  5. package/bin/wiki-manager.js +1 -1
  6. package/docker-compose.override.example.yml +3 -2
  7. package/mcp.endpoints.example.json +1 -1
  8. package/package.json +2 -2
  9. package/src/agent/graph.js +77 -13
  10. package/src/agent/graph.test.js +165 -8
  11. package/src/cli/runtimeStartup.test.js +14 -0
  12. package/src/cli/wiki-manager.js +118 -19
  13. package/src/cli/wiki-manager.test.js +192 -0
  14. package/src/commands/slash.js +168 -39
  15. package/src/commands/slash.test.js +100 -1
  16. package/src/core/agentsCompose.js +79 -0
  17. package/src/core/agentsCompose.test.js +99 -0
  18. package/src/core/buildInfo.json +2 -2
  19. package/src/core/composeOverrides.test.js +17 -3
  20. package/src/core/env.js +27 -10
  21. package/src/core/mcp.js +113 -38
  22. package/src/core/mcp.test.js +181 -20
  23. package/src/core/startupCheck.js +64 -27
  24. package/src/core/startupCheck.test.js +49 -1
  25. package/src/core/wikiSetup.js +57 -22
  26. package/src/core/wikiSetup.test.js +87 -0
  27. package/src/core/wikiWorkspace.test.js +16 -0
  28. package/src/orchestrator/agentRegistry.js +35 -7
  29. package/src/orchestrator/agentRegistry.test.js +73 -0
  30. package/src/orchestrator/dispatcher.js +6 -1
  31. package/src/orchestrator/dispatcher.test.js +24 -1
  32. package/src/orchestrator/objectiveResolver.js +24 -0
  33. package/src/orchestrator/objectiveResolver.test.js +27 -0
  34. package/src/runtime/auth.test.js +1 -65
  35. package/src/runtime/donna-contract.test.js +2 -1
  36. package/src/runtime/lifecycle.js +0 -33
  37. package/src/runtime/runner.test.js +10 -1
  38. package/src/runtime/supervisor.test.js +11 -1
  39. package/src/shell/LeftPane.tsx +49 -24
  40. package/src/shell/repl.js +114 -21
  41. package/src/shell/repl.test.js +88 -17
  42. package/src/shell/tui.tsx +10 -19
  43. package/wiki-workspace +98 -12
package/.env.example CHANGED
@@ -56,8 +56,11 @@ CONNECTORS_MCP_AUTH_TOKEN=
56
56
  # Enable the opt-in agent-connectors service:
57
57
  CONNECTORS_ENABLED=false
58
58
  #
59
- # The public wikiLLM Desktop/PKCE Client ID is built into agent-connectors.
60
- # Optional advanced/self-hosted override:
59
+ # The wikiLLM Google OAuth application is baked into the agent-connectors image
60
+ # at build time, from agent-external/agent-connectors/.env.build.local. When you
61
+ # build the image locally, that file must exist — otherwise the container starts
62
+ # with empty credentials.
63
+ # Optional runtime override (no rebuild needed):
61
64
  # GOOGLE_OAUTH_CLIENT_ID=
62
65
  # Optional confidential-client compatibility override:
63
66
  # GOOGLE_OAUTH_CLIENT_SECRET=
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
@@ -100,6 +100,16 @@ services:
100
100
  build:
101
101
  context: ../agent-external/agent-connectors
102
102
  dockerfile: Dockerfile
103
+ args:
104
+ # Without these the ARGs stay unset and the image ships empty
105
+ # WIKILLM_GOOGLE_OAUTH_* values, so the container ends up with no Google
106
+ # client at all. `wiki-workspace agents up` exports them from the
107
+ # connectors repo's .env.build.local before invoking Compose.
108
+ # Deliberately value-less: Compose then forwards each host variable only
109
+ # when it is defined. Never write `${VAR:-}` here — an empty
110
+ # --build-arg pins the ARG to the empty string.
111
+ WIKILLM_GOOGLE_OAUTH_CLIENT_ID:
112
+ WIKILLM_GOOGLE_OAUTH_CLIENT_SECRET:
103
113
  image: dotdrelle/agent-connectors:latest
104
114
  user: "${UID:-1000}:${GID:-1000}"
105
115
  ports:
@@ -80,7 +80,7 @@ async function main() {
80
80
  // Fallback for already-bootstrapped direct invocations; the shell wrapper
81
81
  // exports these before Bun starts.
82
82
  if (parsed.cacert) Object.assign(process.env, cacertEnvVars(parsed.cacert));
83
- const interactive = process.stdout.isTTY && process.stdin.isTTY && argv[0] !== 'runtime' && !argv.includes('--setup-wizard') && !argv.includes('--headless') && !argv.includes('--once') && !argv.includes('--version') && !argv.includes('-v') && !argv.includes('--help') && !argv.includes('-h');
83
+ const interactive = process.stdout.isTTY && process.stdin.isTTY && argv[0] !== 'runtime' && !argv.includes('--refresh') && !argv.includes('--setup-wizard') && !argv.includes('--headless') && !argv.includes('--once') && !argv.includes('--version') && !argv.includes('-v') && !argv.includes('--help') && !argv.includes('-h');
84
84
  if (interactive || argv.includes('--setup-wizard')) await import('@opentui/solid/preload');
85
85
  if (interactive) process.stdout.write('Starting wiki-manager…\r');
86
86
  const { runCli } = await import('../src/cli/wiki-manager.js');
@@ -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
@@ -40,6 +40,7 @@
40
40
  # services:
41
41
  # serve:
42
42
  # environment:
43
+ # - NODE_USE_ENV_PROXY=${NODE_USE_ENV_PROXY:-1}
43
44
  # - HTTP_PROXY=${HTTP_PROXY:-}
44
45
  # - HTTPS_PROXY=${HTTPS_PROXY:-}
45
46
  # - http_proxy=${HTTP_PROXY:-}
@@ -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.28",
3
+ "version": "0.15.32",
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'],
@@ -118,8 +203,10 @@ function toolCallingLlm() {
118
203
  test('agent graph waits for run-level approval before first MCP action', async () => {
119
204
  const originalFetch = globalThis.fetch;
120
205
  let fetchCalls = 0;
121
- globalThis.fetch = async () => {
122
- fetchCalls += 1;
206
+ globalThis.fetch = async (_url, init) => {
207
+ // Count tool traffic only: the MCP session handshake is transport
208
+ // plumbing, not an action the user needs to approve or observe.
209
+ if (JSON.parse(init.body).method === 'tools/call') fetchCalls += 1;
123
210
  return {
124
211
  ok: true,
125
212
  status: 200,
@@ -403,6 +490,15 @@ test('Donna refuses to delegate connector authentication to an export capability
403
490
  globalThis.fetch = async (url, options = {}) => {
404
491
  fetchedUrls.push(String(url));
405
492
  const body = JSON.parse(String(options.body ?? '{}'));
493
+ // MCP session handshake: answer it, then assert on the real tool call.
494
+ if (body.method === 'initialize') {
495
+ return {
496
+ ok: true,
497
+ status: 200,
498
+ headers: { get: () => null },
499
+ text: async () => '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-06-18"}}',
500
+ };
501
+ }
406
502
  assert.equal(body.params?.name, 'connectors_google_oauth_start');
407
503
  assert.deepEqual(body.params?.arguments, { workspace: 'docs' });
408
504
  return {
@@ -949,7 +1045,7 @@ test('forced delegation is cleared after one valid tool call and does not loop',
949
1045
  }
950
1046
  });
951
1047
 
952
- test('a rejected runtime delegation is terminal and never loops', async () => {
1048
+ test('a rejected runtime delegation returns to Donna once without leaking technical details', async () => {
953
1049
  const originalFetch = globalThis.fetch;
954
1050
  globalThis.fetch = async () => ({
955
1051
  ok: false,
@@ -964,6 +1060,16 @@ test('a rejected runtime delegation is terminal and never loops', async () => {
964
1060
  llm: {
965
1061
  async completeWithTools() {
966
1062
  calls += 1;
1063
+ if (calls === 2) {
1064
+ return {
1065
+ content: 'Je ne peux pas lancer cette action : aucun agent connecté ne la prend actuellement en charge.',
1066
+ message: {
1067
+ role: 'assistant',
1068
+ content: 'Je ne peux pas lancer cette action : aucun agent connecté ne la prend actuellement en charge.',
1069
+ },
1070
+ tool_calls: [],
1071
+ };
1072
+ }
967
1073
  return {
968
1074
  content: null,
969
1075
  message: { role: 'assistant', content: null },
@@ -979,14 +1085,63 @@ test('a rejected runtime delegation is terminal and never loops', async () => {
979
1085
 
980
1086
  try {
981
1087
  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.');
1088
+ assert.equal(calls, 2);
1089
+ assert.equal(
1090
+ result.response,
1091
+ 'Je ne peux pas lancer cette action : aucun agent connecté ne la prend actuellement en charge.',
1092
+ );
1093
+ assert.doesNotMatch(result.response, /ObjectiveNotOrchestrableError|capabilit|runtime__|[0-9a-f]{8}-/i);
984
1094
  assert.equal(result.terminalToolFailure, true);
985
1095
  } finally {
986
1096
  globalThis.fetch = originalFetch;
987
1097
  }
988
1098
  });
989
1099
 
1100
+ test('a delegation missing required provider inputs returns to Donna for clarification', async () => {
1101
+ const originalFetch = globalThis.fetch;
1102
+ globalThis.fetch = async () => ({
1103
+ ok: false,
1104
+ status: 422,
1105
+ json: async () => ({
1106
+ error: 'Delegation requires input: to, subject, body',
1107
+ }),
1108
+ });
1109
+ let calls = 0;
1110
+ const session = sessionBase({
1111
+ runtime: { url: 'http://runtime.test' },
1112
+ llm: {
1113
+ async completeWithTools() {
1114
+ calls += 1;
1115
+ if (calls === 1) {
1116
+ return {
1117
+ content: null,
1118
+ message: { role: 'assistant', content: null },
1119
+ tool_calls: [{
1120
+ id: 'delegate-needs-input',
1121
+ type: 'function',
1122
+ function: { name: 'runtime__delegate', arguments: '{"objective":"envoie un mail"}' },
1123
+ }],
1124
+ };
1125
+ }
1126
+ return {
1127
+ content: 'Oui. À qui dois-je écrire, avec quel objet et quel message ?',
1128
+ message: { role: 'assistant', content: 'Oui. À qui dois-je écrire, avec quel objet et quel message ?' },
1129
+ tool_calls: [],
1130
+ };
1131
+ },
1132
+ },
1133
+ });
1134
+
1135
+ try {
1136
+ const result = await createAgentGraph().invoke({ input: 'envoie un mail', session });
1137
+ assert.equal(calls, 2);
1138
+ assert.equal(result.response, 'Oui. À qui dois-je écrire, avec quel objet et quel message ?');
1139
+ assert.equal(result.terminalToolFailure, false);
1140
+ } finally {
1141
+ globalThis.fetch = originalFetch;
1142
+ }
1143
+ });
1144
+
990
1145
  // Guard: the system prompt must never show a connected tool's bare name
991
1146
  // outside its qualified server__tool form. Bare mentions are what teach the
992
1147
  // model to emit unqualified tool calls (the cme_status incident). The bare
@@ -1158,8 +1313,10 @@ test('agent graph executes action inputs inside a runtime run instead of asking
1158
1313
  // returned a canned clarification — "lance l'ingestion" did nothing.
1159
1314
  const originalFetch = globalThis.fetch;
1160
1315
  let fetchCalls = 0;
1161
- globalThis.fetch = async () => {
1162
- fetchCalls += 1;
1316
+ globalThis.fetch = async (_url, init) => {
1317
+ // Count tool traffic only: the MCP session handshake is transport
1318
+ // plumbing, not an action the user needs to approve or observe.
1319
+ if (JSON.parse(init.body).method === 'tools/call') fetchCalls += 1;
1163
1320
  return {
1164
1321
  ok: true,
1165
1322
  status: 200,
@@ -12,3 +12,17 @@ test('runtime startup is not blocked by optional Docker image maintenance', asyn
12
12
  assert.match(runtimeBranch, /await runRuntime\(argv\.slice\(1\), agent\)/);
13
13
  assert.doesNotMatch(runtimeBranch, /refreshRunningContainers/);
14
14
  });
15
+
16
+ test('shell exit leaves the shared runtime alive and refresh is explicit', async () => {
17
+ const cli = await readFile(new URL('./wiki-manager.js', import.meta.url), 'utf8');
18
+ const tui = await readFile(new URL('../shell/tui.tsx', import.meta.url), 'utf8');
19
+ const bin = await readFile(new URL('../../bin/wiki-manager.js', import.meta.url), 'utf8');
20
+
21
+ assert.doesNotMatch(cli, /await shutdownOwnedRuntime\(runtime/);
22
+ assert.doesNotMatch(tui, /shutdownOwnedRuntime/);
23
+ assert.match(tui, /shell closed; shared runtime left running/);
24
+ assert.match(tui, /process\.exit\(0\)/);
25
+ assert.match(cli, /argv\.includes\('--refresh'\)/);
26
+ assert.match(cli, /spawnSync\(workspaceCliPath, \['refresh'\]/);
27
+ assert.match(bin, /!argv\.includes\('--refresh'\)/);
28
+ });