@dotdrelle/wiki-manager 0.14.20 → 0.14.23

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/.env.example CHANGED
@@ -42,11 +42,39 @@ WORKSPACES_ROOT=/path/to/workspaces
42
42
 
43
43
  CME_MCP_AUTH_TOKEN=
44
44
  DOCUMENTS_MCP_AUTH_TOKEN=
45
+ # Generated by `wiki-workspace agents up` when missing.
46
+ CONNECTORS_MCP_AUTH_TOKEN=
45
47
 
46
48
  # ── Agent ports (optional, change only if defaults conflict) ───────────────────
47
49
 
48
50
  # CME_MCP_PORT=3336
49
51
  # DOCUMENTS_MCP_PORT=3337
52
+ # CONNECTORS_MCP_PORT=3338
53
+
54
+ # ── SaaS connectors (optional) ────────────────────────────────────────────────
55
+ #
56
+ # Enable the opt-in agent-connectors service:
57
+ CONNECTORS_ENABLED=false
58
+ #
59
+ # The public wikiLLM Desktop/PKCE Client ID is built into agent-connectors.
60
+ # Optional advanced/self-hosted override:
61
+ # GOOGLE_OAUTH_CLIENT_ID=
62
+ # Optional confidential-client compatibility override:
63
+ # GOOGLE_OAUTH_CLIENT_SECRET=
64
+ #
65
+ # The callback URL is generated automatically
66
+ # by `wiki-workspace agents up` when empty. Register the resulting exact URL in
67
+ # Google Cloud Console. Override it only for a remote/public HTTPS deployment.
68
+ # GOOGLE_OAUTH_CALLBACK_URL=http://127.0.0.1:${CONNECTORS_MCP_PORT}/oauth/google/callback
69
+ #
70
+ # Generated automatically when missing. Keep the two secrets distinct.
71
+ # OAUTH_STATE_SECRET=
72
+ # OAUTH_START_TOKEN=
73
+ # OAUTH_STATE_TTL_SECONDS=600
74
+ #
75
+ # Collection concurrency advertised to Donna:
76
+ # CONNECTORS_RECOMMENDED_CONCURRENCY=2
77
+ # CONNECTORS_MAX_CONCURRENCY=4
50
78
 
51
79
  # ── Documents LLM OCR / Mermaid (optional) ─────────────────────────────────────
52
80
 
package/README.md CHANGED
@@ -393,7 +393,6 @@ of you in the browser (create → configure → start the agents → open).
393
393
  | [`agent-cme`](https://github.com/dotdrelle/agent-cme) | Global Confluence to Markdown MCP exporter; workspace injected automatically by Donna |
394
394
  | [`agent-wiki-production`](https://github.com/dotdrelle/agent-wiki-production) | Workspace-scoped production jobs: ingest, build, export, polish, pipeline |
395
395
  | [`agent-wiki-documents`](https://github.com/dotdrelle/agent-wiki-documents) | Document conversion MCP: PDF/Office/HTML/images → Markdown (OCR-capable) |
396
- | [`agent-mailer-api`](https://github.com/dotdrelle/agent-mailer-api) | Optional external mailer MCP endpoint (user-side override, not in the default stack) |
397
396
 
398
397
  ## Workspace Model
399
398
 
@@ -487,7 +486,7 @@ cp .env.example .env
487
486
 
488
487
  The `.env` file is loaded automatically by both `wiki-manager` (Node/Bun process)
489
488
  and `wiki-workspace` (Docker Compose). It sets `WORKSPACES_ROOT`, per-agent auth
490
- tokens and optional port overrides; credentials of optional connectors you enable via the user override.
489
+ tokens, optional port overrides, and credentials for enabled connectors.
491
490
 
492
491
  ### External MCP endpoints
493
492
 
@@ -599,18 +598,104 @@ generates the missing agent auth tokens into your manager `.env` and seeds
599
598
  automatically from the manager workspaces directory. Agent state is stored under
600
599
  `./.agents-data/` unless `AGENTS_DATA_DIR` is set.
601
600
 
602
- #### Optional agents and user overrides
601
+ An `npm -g update @dotdrelle/wiki-manager` replaces the packaged Compose files
602
+ but preserves the operator-owned `.env`, `mcp.endpoints.json`, workspaces, agent
603
+ data, and runtime database. Missing standard endpoint definitions are migrated
604
+ additively; existing endpoint definitions are never overwritten.
603
605
 
604
- Anything beyond the default stack (for example the MailerSend agent) is an
605
- external connector operated at the user's charge — built and published, but
606
- not part of the delivery. To enable one, create a file named
607
- `agents.docker-compose.override.yml` **next to your `.env`**:
606
+ The Gmail connector agent is packaged but opt-in. Enable it in the manager
607
+ `.env`:
608
+
609
+ ```dotenv
610
+ CONNECTORS_ENABLED=true
611
+ # Optional locally; generated from CONNECTORS_MCP_PORT when empty:
612
+ GOOGLE_OAUTH_CALLBACK_URL=
613
+ ```
614
+
615
+ For a local installation, `wiki-workspace agents up` fills an empty callback
616
+ with:
617
+
618
+ ```text
619
+ http://127.0.0.1:<CONNECTORS_MCP_PORT>/oauth/google/callback
620
+ ```
621
+
622
+ With the default port, register this exact redirect URI in Google Cloud
623
+ Console:
624
+
625
+ ```text
626
+ http://127.0.0.1:3338/oauth/google/callback
627
+ ```
628
+
629
+ The browser resolves `127.0.0.1`; Docker forwards the published host port to
630
+ the connectors container. For a remote deployment, set an explicit public
631
+ HTTPS callback instead. In both cases the configured URL must match the Google
632
+ Cloud redirect URI exactly.
633
+
634
+ The local flow uses the public wikiLLM Desktop OAuth Client ID with PKCE and
635
+ does not require a Client Secret. Normal users set neither Google credential.
636
+ `GOOGLE_OAUTH_CLIENT_ID` remains an advanced override for private/internal
637
+ Google projects, and `GOOGLE_OAUTH_CLIENT_SECRET` is an optional compatibility
638
+ override for administrators using a confidential web client.
639
+
640
+ `agents up` also generates the connectors MCP token plus distinct OAuth
641
+ start/state secrets when missing. It adds a regular, standard MCP `connectors`
642
+ entry to `mcp.endpoints.json`. Setting `CONNECTORS_ENABLED=false` and running
643
+ `agents up` removes that entry again, so disabled services are not probed. No
644
+ non-standard `enabled` property is written to MCP configuration files.
645
+ The matching `chatAccess.connectors` policy is managed at the same time. Its
646
+ read-only `allow` list exposes `connectors_google_status`, while the explicit
647
+ `allowActions` list exposes only `connectors_google_oauth_start`. Orchestration
648
+ tools such as `agent_execute` are never exposed directly to served chat.
649
+
650
+ Connector authorization is also available without asking the LLM. These two
651
+ commands work in both the Shell UI and the `llm-wiki serve` chat:
652
+
653
+ ```text
654
+ /connector list
655
+ /connector auth google
656
+ ```
657
+
658
+ The first reports the Gmail read-only authorization state for the active
659
+ workspace. The second opens Google's OAuth page in the browser. Asking Donna
660
+ to configure or check Google remains supported through the direct connector
661
+ tools above.
662
+
663
+ The Compose profile is an internal implementation detail. Do not set
664
+ `COMPOSE_PROFILES` and do not add provider names such as Gmail or Slack to it:
665
+ one `agent-connectors` service hosts all connector providers.
666
+
667
+ For a public serve deployment, authorization can be started through the
668
+ same-origin proxy:
608
669
 
609
670
  ```bash
610
- cp "$(npm root -g)/@dotdrelle/wiki-manager/agents.docker-compose.mailer.example.yml" \
611
- agents.docker-compose.override.yml
671
+ curl -X POST https://wiki.example.com/api/connectors/google/oauth/start \
672
+ -H 'Origin: https://wiki.example.com' \
673
+ -H 'X-LLM-WIKI-OAUTH: 1' \
674
+ -H 'Content-Type: application/json' \
675
+ -d '{"instanceId":"google-1"}'
612
676
  ```
613
677
 
678
+ Open the returned `authorizationUrl`. The workspace is injected by serve and
679
+ cannot be selected by the browser request.
680
+
681
+ Donna discovers the agent contract automatically from the `connectors` MCP
682
+ endpoint. With only one provider, no routing entry is required. To pin it
683
+ explicitly in a workspace profile:
684
+
685
+ ```yaml
686
+ capabilityRouting:
687
+ external-source.collect:
688
+ preferredAgents: [connectors]
689
+ allowedAgents: [connectors]
690
+ ```
691
+
692
+ #### Optional agents and user overrides
693
+
694
+ Anything beyond the packaged stack is an external connector operated and
695
+ configured independently by the user. To run one alongside the packaged
696
+ agents, create a file named
697
+ `agents.docker-compose.override.yml` **next to your `.env`**:
698
+
614
699
  `agents up` includes it automatically when present (standard Docker Compose
615
700
  merge: new services are added, same-name keys override the defaults — you can
616
701
  also use it to pin a port or a variable of a default agent). The file is
@@ -618,7 +703,8 @@ yours: wiki-manager never generates or overwrites it. Complete the setup by
618
703
  adding the connector's variables to your `.env` and its endpoint block to
619
704
  your `mcp.endpoints.json` — every variable an external MCP endpoint needs
620
705
  lives in the `.env` and is referenced as `${VAR_NAME}` from
621
- `mcp.endpoints.json`. Detailed steps are in the example file header.
706
+ `mcp.endpoints.json`. A connector running outside the manager Compose stack
707
+ only needs an entry in `mcp.endpoints.json`.
622
708
 
623
709
  Workspace-native MCP servers (`llm-wiki`, `production`) stay configured through
624
710
  each workspace `.env`. External agents are workspace-agnostic: the active
@@ -1029,7 +1115,7 @@ llm-wiki-manager/
1029
1115
  │ ├── useAgent.ts # agent call wrapper (drives the @langchain/langgraph run)
1030
1116
  │ └── renderer.ts # markdown stripping and line coloring
1031
1117
  ├── docker-compose.yml # workspace-scoped stack (serve, mcp-http, production-mcp)
1032
- ├── agents.docker-compose.yml # global external agents (cme, documents, mailer)
1118
+ ├── agents.docker-compose.yml # packaged global external agents
1033
1119
  ├── wiki-workspace
1034
1120
  ├── .env.example # template for local .env (WORKSPACES_ROOT, agent tokens, …)
1035
1121
  ├── mcp.endpoints.example.json
@@ -1,6 +1,8 @@
1
1
  # agents.docker-compose.yml — external agent stack
2
2
  #
3
3
  # Starts cme and documents as workspace-agnostic global services.
4
+ # The connectors service is opt-in with CONNECTORS_ENABLED=true; wiki-workspace
5
+ # translates that setting to the internal Compose profile.
4
6
  # All workspace paths are resolved at tool-call time via the `workspace` param.
5
7
  #
6
8
  # Required:
@@ -12,6 +14,11 @@
12
14
  # DOCUMENTS_MCP_PORT — 3337
13
15
  # CME_MCP_AUTH_TOKEN — bearer token for cme agent (empty = no auth)
14
16
  # DOCUMENTS_MCP_AUTH_TOKEN — bearer token for documents agent (empty = no auth)
17
+ # CONNECTORS_MCP_PORT — 3338
18
+ # CONNECTORS_MCP_AUTH_TOKEN — bearer token for connectors MCP
19
+ # GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET — OAuth application
20
+ # GOOGLE_OAUTH_CALLBACK_URL — exact public post-proxy callback URL
21
+ # OAUTH_STATE_SECRET / OAUTH_START_TOKEN — distinct 32+ byte secrets
15
22
  # DOCUMENT_LLM_BASE_URL — https://albert.api.etalab.gouv.fr/v1
16
23
  # DOCUMENT_LLM_MODEL — lightonai/LightOnOCR-2-1B
17
24
  # DOCUMENT_LLM_API_KEY — OpenAI/OpenAI-compatible API key for document OCR
@@ -76,3 +83,37 @@ services:
76
83
  - ${WORKSPACES_ROOT:?Set WORKSPACES_ROOT to the directory containing all workspace folders}:/workspaces
77
84
  #- ${AGENTS_DATA_DIR:-./.agents-data}/certs:/certs:ro
78
85
  restart: unless-stopped
86
+
87
+ connectors:
88
+ profiles: [connectors]
89
+ # Local iteration: build the image from the connectors repo instead of
90
+ # pulling the published tag. `image:` is kept so the build is tagged with the
91
+ # same name; restore image-only before publishing a release.
92
+ build:
93
+ context: ../agent-external/agent-connectors
94
+ dockerfile: Dockerfile
95
+ image: dotdrelle/agent-connectors:latest
96
+ user: "${UID:-1000}:${GID:-1000}"
97
+ ports:
98
+ - "${CONNECTORS_MCP_PORT:-3338}:3338"
99
+ environment:
100
+ - CONNECTORS_PORT=3338
101
+ - MCP_AUTH_TOKEN=${CONNECTORS_MCP_AUTH_TOKEN:-}
102
+ - WORKSPACES_ROOT=/workspaces
103
+ - AGENT_DATA_DIR=/data
104
+ - GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
105
+ - GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
106
+ - GOOGLE_OAUTH_CALLBACK_URL=${GOOGLE_OAUTH_CALLBACK_URL:-}
107
+ - OAUTH_STATE_SECRET=${OAUTH_STATE_SECRET:-}
108
+ - OAUTH_START_TOKEN=${OAUTH_START_TOKEN:-}
109
+ - OAUTH_STATE_TTL_SECONDS=${OAUTH_STATE_TTL_SECONDS:-600}
110
+ - CONNECTORS_RECOMMENDED_CONCURRENCY=${CONNECTORS_RECOMMENDED_CONCURRENCY:-2}
111
+ - CONNECTORS_MAX_CONCURRENCY=${CONNECTORS_MAX_CONCURRENCY:-4}
112
+ - NODE_USE_ENV_PROXY=${NODE_USE_ENV_PROXY:-}
113
+ - HTTPS_PROXY=${HTTPS_PROXY:-}
114
+ - HTTP_PROXY=${HTTP_PROXY:-}
115
+ - NO_PROXY=${NO_PROXY:-localhost,127.0.0.1,host.docker.internal}
116
+ volumes:
117
+ - ${AGENTS_DATA_DIR:-./.agents-data}/connectors:/data
118
+ - ${WORKSPACES_ROOT:?Set WORKSPACES_ROOT to the directory containing all workspace folders}:/workspaces
119
+ restart: unless-stopped
@@ -37,7 +37,10 @@ services:
37
37
  stop_grace_period: 10s
38
38
  volumes:
39
39
  - ${WIKI_WORKSPACE_PATH:-/tmp/llm-wiki-workspace-not-set}:/workspace
40
- - ./mcp.endpoints.json:/mcp.endpoints.json:ro
40
+ # Absolute path to the running instance's endpoints file (set by the
41
+ # manager to managerStateDir/mcp.endpoints.json). Falls back to the
42
+ # package-relative file only when launched without the manager.
43
+ - ${WIKI_MANAGER_MCP_ENDPOINTS_FILE:-./mcp.endpoints.json}:/mcp.endpoints.json:ro
41
44
  - ${AGENTS_DATA_DIR:-./.agents-data}/documents/input:/documents/input
42
45
  - ${AGENTS_DATA_DIR:-./.agents-data}/documents/uploads:/documents/uploads
43
46
  # TLS certificates — uncomment if WIKI_SERVE_TLS_CERT_PATH is set
@@ -53,6 +56,12 @@ services:
53
56
  - DOCUMENTS_MCP_PORT
54
57
  - CME_MCP_AUTH_TOKEN
55
58
  - DOCUMENTS_MCP_AUTH_TOKEN
59
+ # Connectors MCP: the serve panel resolves ${CONNECTORS_MCP_AUTH_TOKEN} in
60
+ # /mcp.endpoints.json from process.env only, so it must be forwarded here
61
+ # (symmetric with cme/documents) or the panel sends an empty Bearer → 401.
62
+ - CONNECTORS_MCP_PORT
63
+ - CONNECTORS_MCP_AUTH_TOKEN
64
+ - CONNECTORS_OAUTH_START_TOKEN=${OAUTH_START_TOKEN:-}
56
65
  - WORKSPACE_NAME
57
66
  - DOCUMENT_INPUT_DIR=/documents/input
58
67
  - DOCUMENT_UPLOADS_DIR=/documents/uploads
@@ -61,6 +70,7 @@ services:
61
70
  - PRODUCTION_MCP_PROXY_URL=http://host.docker.internal:${PRODUCTION_MCP_PORT:-3102}/mcp/
62
71
  - WIKI_MANAGER_RUNTIME_URL=http://host.docker.internal:${WIKI_MANAGER_RUNTIME_PORT:-7788}
63
72
  - WIKI_MANAGER_RUNTIME_TOKEN=${WIKI_MANAGER_RUNTIME_TOKEN:-}
73
+ - CONNECTORS_AGENT_URL=http://host.docker.internal:${CONNECTORS_MCP_PORT:-3338}
64
74
  # HTTPS — set paths inside the container (e.g. /certs/server.crt) and uncomment the volume above
65
75
  #- WIKI_SERVE_TLS_CERT_PATH=/certs/server.crt
66
76
  #- WIKI_SERVE_TLS_KEY_PATH=/certs/server.key
@@ -29,9 +29,9 @@
29
29
  "chatAccess": {
30
30
  "maxToolIterations": 8,
31
31
  "servers": {
32
- "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"] },
33
- "production": { "allow": ["production_job_status", "production_jobs_list"] },
34
- "cme": { "allow": ["cme_status", "cme_sources_list", "cme_export_status"] }
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"] },
33
+ "wiki-production": { "allow": ["production_job_status", "production_jobs_list"] },
34
+ "cme": { "allow": ["cme_status", "cme_sources_list", "cme_export_status"] }
35
35
  }
36
36
  }
37
37
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.14.20",
3
+ "version": "0.14.23",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "author": "dotrelle",
@@ -27,7 +27,6 @@
27
27
  "wiki-workspace",
28
28
  "docker-compose.yml",
29
29
  "agents.docker-compose.yml",
30
- "agents.docker-compose.mailer.example.yml",
31
30
  "mcp.endpoints.example.json",
32
31
  ".env.example",
33
32
  "tsconfig.json",
@@ -562,7 +562,8 @@ function assertAgentReadSlashCommandAllowed(commandLine) {
562
562
  function withActiveWorkspaceForExternalTool(session, server, tool, args) {
563
563
  const needsWorkspace =
564
564
  (server === 'documents' && tool.startsWith('documents_') && tool !== 'documents_status') ||
565
- (server === 'cme' && tool.startsWith('cme_') && tool !== 'cme_export_cancel' && !(tool === 'cme_export_status' && args.job_id));
565
+ (server === 'cme' && tool.startsWith('cme_') && tool !== 'cme_export_cancel' && !(tool === 'cme_export_status' && args.job_id)) ||
566
+ (server === 'connectors' && tool.startsWith('connectors_'));
566
567
  if (!needsWorkspace) return args;
567
568
  if (!session.workspace) {
568
569
  throw new Error(`No active workspace available for ${server}.${tool}. Use /use <workspace> first.`);
@@ -820,7 +821,16 @@ function connectorConfigurationTarget(session, objective) {
820
821
  if (!/(?:configur|connect|authent|oauth|setup|sign[ -]?in)/i.test(text)) return null;
821
822
  for (const [serverName, server] of Object.entries(session?.mcp ?? {})) {
822
823
  if (server?.status !== 'connected' || !Array.isArray(server.tools) || server.tools.length === 0) continue;
823
- const aliases = String(serverName).toLowerCase().split(/[^a-z0-9]+/).filter((part) => part.length >= 3);
824
+ const genericAliasParts = new Set([
825
+ 'auth', 'authenticate', 'connect', 'connector', 'connectors',
826
+ 'configure', 'oauth', 'setup', 'start', 'status',
827
+ ]);
828
+ const aliases = [
829
+ serverName,
830
+ ...server.tools.map((tool) => tool?.name ?? ''),
831
+ ]
832
+ .flatMap((value) => String(value).toLowerCase().split(/[^a-z0-9]+/))
833
+ .filter((part) => part.length >= 3 && !genericAliasParts.has(part));
824
834
  if (!aliases.some((alias) => text.includes(alias))) continue;
825
835
  const setupTool = server.tools.find((tool) => {
826
836
  const name = String(tool?.name ?? '').toLowerCase();
@@ -953,20 +963,20 @@ export function buildAgentSystemPrompt(state) {
953
963
  `Current wikirc profile: ${wikirc}.`,
954
964
  `Available primitives: ${commandList(state.session)}.`,
955
965
  'Only announce or call slash commands that appear exactly in Available primitives. Do not invent command names, subcommands, or arguments.',
956
- 'Connected MCP tools you may call directly (server__tool naming convention) — reads AND single-step actions like configuring or adding a connector source, converting a document, sending, or searching. Only the heavy multi-step operations (ingest, build, export, polish, pipeline) go through runtime__delegate to get their parallel plan. Everything listed below is directly callable:',
966
+ 'Connected MCP tools you may call directly (server__tool naming convention). Everything listed below is directly callable. When the requested action has no matching direct tool, call runtime__delegate with the original objective: the runtime resolves it against the discovered agent capability contracts, including executor-only single-task capabilities.',
957
967
  mcpTools,
958
968
  'Current local MCP job queue:',
959
969
  formatQueue(state.session),
960
970
  'Available skills:',
961
971
  skills,
962
- 'In interactive agent mode you may call only the read-only tools and runtime control/delegation tools actually provided to you.',
972
+ 'In interactive agent mode, call only tools actually provided to you. Any directly offered tool stays direct; never substitute an orchestration-contract tool yourself.',
963
973
  'When the user asks for an action that can be performed with connected MCP tools or safe primitives, do not answer with future intent such as "I will call...", "I am going to run...", or "launching..." unless you also call the tool in the same turn. Either call the tool now, ask for the exact missing required arguments, or explain the concrete blocker.',
964
974
  'Execution truthfulness: never invent a job id, status, percentage, duration, generated file, file content, URL, command, or tool result. An action is executed only when you call an available tool and receive its result. Examples and placeholders are forbidden in execution reports.',
965
975
  'After any completed action, give a short factual summary based only on the tool result: outcome and concrete outputs or references actually returned. Mention a viewing primitive only when it exists in Available primitives and is relevant. Never invent results, interpret generated content beyond what the tool returned, or fabricate a verification checklist.',
966
976
  'You are in AGENT mode, so you can actually act. You MAY close with ONE short, natural follow-up — a single sentence phrased as an offer, and only when it genuinely helps and is an action you can perform right here (delegate it or call a tool), e.g. "Want me to start ingesting these pages?" (phrased in the reply language). This is what makes you feel like an assistant rather than a readout. Only offer what you can truly do in agent mode — never an offer that would require another mode. Keep it to that one line: never produce a "Next steps"/"Prochaines étapes"/"À suivre" list, a checklist, an options menu, or commands for the user to type. If nothing useful naturally follows, simply stop after the answer — do not pad.',
967
977
  '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.',
968
978
  '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.',
969
- 'Only the heavy multi-step operations — ingest, build, export, polish, pipeline — are delegated via runtime__delegate (for their DAG and parallelism). Single-step actions — configuring or adding a connector source, converting a document, sending, searching — are called directly on the connected tool. Never call an agent orchestration-contract or plan tool directly.',
979
+ '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.',
970
980
  '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.',
971
981
  '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.',
972
982
  '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.',
@@ -975,10 +985,10 @@ export function buildAgentSystemPrompt(state) {
975
985
  'For service actions, recommend only available service primitives from Available primitives, with the exact service name when the primitive supports one.',
976
986
  '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.',
977
987
  state.session.runtime?.url
978
- ? '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.'
988
+ ? 'The runtime is connected and runtime__delegate is available for any requested capability action that has no matching direct tool. When a matching direct tool is offered, call it directly; otherwise let the runtime resolve the objective from discovered capability contracts.'
979
989
  : '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.',
980
990
  '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.',
981
- '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.',
991
+ 'For an action with no matching direct tool, call runtime__delegate with the user objective only. The runtime chooses the capability, operation, agent and plan, including a validated single task for executor-only agents. Never choose those identifiers yourself. Never call <provider>__agent_plan, <provider>__agent_execute, legacy production__production_start_job, wiki__plan_set, or wiki__plan_done from interactive chat.',
982
992
  '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.',
983
993
  '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.',
984
994
  '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.',
@@ -1087,10 +1097,48 @@ function isReadOnlyMcpCall(session, server, tool) {
1087
1097
  .find((item) => String(item?.name ?? '') === tool || String(item?.name ?? '').endsWith(`__${tool}`));
1088
1098
  return isDonnaReadTool({
1089
1099
  function: { name: `${server}__${tool}` },
1090
- readOnly: descriptor?.readOnly === true,
1100
+ readOnly: descriptor?.annotations?.readOnlyHint === true || descriptor?.readOnly === true,
1091
1101
  });
1092
1102
  }
1093
1103
 
1104
+ function hasExecutedActionTool(messages, session) {
1105
+ for (const message of messages ?? []) {
1106
+ for (const call of message?.tool_calls ?? []) {
1107
+ const resolved = resolveToolCallName(session?.mcp, call?.function?.name ?? '', INTERNAL_TOOL_SERVERS);
1108
+ const { server, tool } = resolved;
1109
+ if (!server) continue;
1110
+ if (server === 'runtime') {
1111
+ if (tool !== 'status') return true;
1112
+ continue;
1113
+ }
1114
+ if (server === 'shell') {
1115
+ if (tool !== 'read_command') return true;
1116
+ continue;
1117
+ }
1118
+ if (server === 'wiki' && (tool === 'plan_set' || tool === 'plan_done')) return true;
1119
+ if (!isReadOnlyMcpCall(session, server, tool)) return true;
1120
+ }
1121
+ }
1122
+ return false;
1123
+ }
1124
+
1125
+ function hasExecutedReadOnlyTool(messages, session) {
1126
+ for (const message of messages ?? []) {
1127
+ for (const call of message?.tool_calls ?? []) {
1128
+ const { server, tool } = resolveToolCallName(
1129
+ session?.mcp,
1130
+ call?.function?.name ?? '',
1131
+ INTERNAL_TOOL_SERVERS,
1132
+ );
1133
+ if (server === 'runtime' && tool === 'status') return true;
1134
+ if (server === 'shell' && tool === 'read_command') return true;
1135
+ if (server && !['runtime', 'shell'].includes(server)
1136
+ && isReadOnlyMcpCall(session, server, tool)) return true;
1137
+ }
1138
+ }
1139
+ return false;
1140
+ }
1141
+
1094
1142
  export function createAgentGraph(options = {}) {
1095
1143
  async function orchestratorNode(state) {
1096
1144
  const llm = state.session.llm ?? options.llm ?? null;
@@ -1216,9 +1264,17 @@ export function createAgentGraph(options = {}) {
1216
1264
  emitAgentEvent(state.session, 'assistant_message', 'llm', { content: '' });
1217
1265
  state.session._onStep?.(`[${iterations + 1}/${MAX_TOOL_ITERATIONS}] ${result.tool_calls.length} MCP action${result.tool_calls.length > 1 ? 's' : ''} queued…`);
1218
1266
  // On iteration 0 persist the user message too so it survives the loop.
1267
+ // Some OpenAI-compatible providers return tool_calls only at the
1268
+ // top-level result and omit them from `message`. Preserve the calls in
1269
+ // the transcript so follow-up guards can distinguish a status check
1270
+ // from an action that was actually executed.
1271
+ const assistantToolMessage = {
1272
+ ...(result.message ?? { role: 'assistant', content: result.content ?? null }),
1273
+ tool_calls: result.tool_calls,
1274
+ };
1219
1275
  const newMessages = iterations === 0
1220
- ? [{ role: 'user', content: state.input }, result.message]
1221
- : [result.message];
1276
+ ? [{ role: 'user', content: state.input }, assistantToolMessage]
1277
+ : [assistantToolMessage];
1222
1278
  return {
1223
1279
  pendingToolCalls: result.tool_calls,
1224
1280
  allowedToolNames: tools.map((item) => item?.function?.name).filter(Boolean),
@@ -1240,7 +1296,7 @@ export function createAgentGraph(options = {}) {
1240
1296
  result.message ?? { role: 'assistant', content: result.content ?? '' },
1241
1297
  {
1242
1298
  role: 'user',
1243
- content: 'Your previous response did not execute the requested action because it called no tool. Do not narrate or simulate execution. Call the appropriate available tool now. Never invent results.',
1299
+ content: 'Your previous response did not execute the requested action because it called no tool. Do not narrate or simulate execution. Call the matching direct tool now; if no matching direct tool is offered, call runtime__delegate with the original objective. Never invent results.',
1244
1300
  },
1245
1301
  ],
1246
1302
  toolIterations: 1,
@@ -1251,6 +1307,28 @@ export function createAgentGraph(options = {}) {
1251
1307
  }
1252
1308
 
1253
1309
  const canDelegate = tools.some((item) => item?.function?.name === 'runtime__delegate');
1310
+ if (iterations > 0 && canDelegate && !state.forceDelegation
1311
+ && hasExecutedReadOnlyTool(conversationMessages, state.session)
1312
+ && !hasExecutedActionTool(conversationMessages, state.session)
1313
+ && await classifyRequestedAction(llm, state.input, state.session._abortSignal)) {
1314
+ state.session._onStreamReset?.();
1315
+ state.session._onStep?.('Agent: read-only checks completed but requested action not executed — delegating…');
1316
+ return {
1317
+ pendingToolCalls: null,
1318
+ messages: [
1319
+ result.message ?? { role: 'assistant', content: result.content ?? '' },
1320
+ {
1321
+ role: 'user',
1322
+ content: 'You only performed read-only/status checks and did not execute the requested action. Call runtime__delegate now with the original objective only.',
1323
+ },
1324
+ ],
1325
+ toolIterations: iterations + 1,
1326
+ readyToStream: false,
1327
+ inputClassification: classification,
1328
+ retryWithoutTool: true,
1329
+ forceDelegation: true,
1330
+ };
1331
+ }
1254
1332
  if (!runtimeExecution && iterations === 0 && canDelegate && !state.retryWithoutTool
1255
1333
  && await classifyRequestedAction(llm, state.input, state.session._abortSignal)) {
1256
1334
  state.session._onStreamReset?.();
@@ -1570,6 +1648,7 @@ export function createAgentGraph(options = {}) {
1570
1648
  messages: toolResultMessages,
1571
1649
  pendingToolCalls: null,
1572
1650
  forceDelegation: false,
1651
+ retryWithoutTool: false,
1573
1652
  terminalToolFailure: true,
1574
1653
  invalidToolCallRetries: 0,
1575
1654
  invalidResponseRetries: 0,
@@ -1579,6 +1658,7 @@ export function createAgentGraph(options = {}) {
1579
1658
  messages: toolResultMessages,
1580
1659
  pendingToolCalls: null,
1581
1660
  forceDelegation: false,
1661
+ retryWithoutTool: false,
1582
1662
  invalidToolCallRetries: 0,
1583
1663
  invalidResponseRetries: 0,
1584
1664
  terminalToolFailure: false,