@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
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { promisify } from 'node:util';
5
5
  import { activeCacertPath, cacertEnv } from './cacert.js';
6
6
  import { COMPOSE_SERVICES, parseComposePsJson, serviceStates } from './compose.js';
7
+ import { resolveAgentsComposeContext } from './agentsCompose.js';
7
8
  import { buildMcpStatus, discoverMcpTools } from './mcp.js';
8
9
  import { listWikircProfiles, loadWikircProfile, summarizeWikircConfig } from './wikirc.js';
9
10
  import { listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
@@ -79,22 +80,27 @@ export async function checkInternetConnectivity({
79
80
  }
80
81
  }
81
82
 
82
- async function checkAgents({ exec = execFileAsync } = {}) {
83
- const composeFile = join(managerRoot(), 'agents.docker-compose.yml');
84
- if (!existsSync(composeFile)) return null;
83
+ export async function checkAgents({ exec = execFileAsync, context = resolveAgentsComposeContext() } = {}) {
84
+ if (!context.exists) return null;
85
85
  try {
86
- const { stdout } = await exec('docker', [
87
- 'compose',
88
- '--project-directory',
89
- managerRoot(),
90
- '-f',
91
- composeFile,
92
- '-p',
93
- 'wiki-agents',
94
- 'ps',
95
- '--format',
96
- 'json',
97
- ], {
86
+ // `config --services` is Compose's authoritative view after profiles,
87
+ // overrides and interpolation. Unlike `ps`, it also includes services that
88
+ // have never created a container, including ordinary (non-profiled)
89
+ // services and services contributed by the user override.
90
+ const { stdout: configuredStdout } = await exec(
91
+ 'docker',
92
+ [...context.args, 'config', '--services'],
93
+ {
94
+ cwd: managerRoot(),
95
+ env: {
96
+ ...process.env,
97
+ WIKI_WORKSPACES_DIR: workspacesDir(),
98
+ },
99
+ timeout: 30_000,
100
+ maxBuffer: 1024 * 1024,
101
+ },
102
+ );
103
+ const { stdout } = await exec('docker', [...context.args, 'ps', '--all', '--format', 'json'], {
98
104
  cwd: managerRoot(),
99
105
  env: {
100
106
  ...process.env,
@@ -104,16 +110,38 @@ async function checkAgents({ exec = execFileAsync } = {}) {
104
110
  timeout: 5000,
105
111
  maxBuffer: 1024 * 1024,
106
112
  });
107
- const entries = parseComposePsJson(stdout);
108
- if (entries.length === 0) return null;
109
- const downServices = entries
110
- .filter((entry) => {
111
- const state = String(entry.State ?? entry.state ?? entry.Status ?? entry.status ?? '').toLowerCase();
112
- return !(state.includes('running') || state.includes('up'));
113
- })
114
- .map((entry) => entry.Service ?? entry.service ?? entry.Name ?? entry.name)
113
+ const expectedServices = String(configuredStdout ?? '')
114
+ .split(/\r?\n/)
115
+ .map((service) => service.trim())
115
116
  .filter(Boolean);
116
- return downServices.length > 0 ? { kind: 'agents', context: { downServices } } : null;
117
+ const entries = parseComposePsJson(stdout);
118
+ const running = new Set();
119
+ const downServices = [];
120
+ for (const entry of entries) {
121
+ const name = entry.Service ?? entry.service ?? entry.Name ?? entry.name;
122
+ if (!name) continue;
123
+ const state = String(entry.State ?? entry.state ?? entry.Status ?? entry.status ?? '').toLowerCase();
124
+ if (state.includes('running') || state.includes('up')) running.add(name);
125
+ else downServices.push(name);
126
+ }
127
+ // A service whose profile the operator enabled but which `ps` does not list
128
+ // at all is missing, not opted out — reporting only the listed-but-stopped
129
+ // ones turned "connectors never started" into a silent success.
130
+ const missingServices = expectedServices.filter(
131
+ (service) => !running.has(service) && !downServices.includes(service),
132
+ );
133
+ if (entries.length === 0 && missingServices.length === 0) return null;
134
+ if (downServices.length === 0 && missingServices.length === 0) return null;
135
+ return {
136
+ kind: 'agents',
137
+ context: {
138
+ ...(downServices.length > 0 ? { downServices } : {}),
139
+ ...(missingServices.length > 0 ? { missingServices } : {}),
140
+ expectedServices,
141
+ requestedProfileServices: context.expectedProfileServices,
142
+ profiles: context.profiles,
143
+ },
144
+ };
117
145
  } catch (err) {
118
146
  if (err?.code === 'ENOENT') {
119
147
  return { kind: 'agents', context: { dockerMissing: true } };
@@ -349,16 +377,25 @@ export async function runChecks({
349
377
  if (!docker.ok) gaps.push({ kind: 'agents', context: docker.context ?? { dockerUnavailable: true } });
350
378
  if (!internet.ok) gaps.push({ kind: 'network', context: internet.context ?? {} });
351
379
  if (agents) gaps.push(agents);
380
+ const missingRequested = (agents?.context?.missingServices ?? []).filter(
381
+ (service) => (agents?.context?.requestedProfileServices ?? []).includes(service),
382
+ );
352
383
  onCheck({
353
384
  kind: 'agents',
354
385
  ok: docker.ok && !agents,
355
386
  skipped: !docker.ok,
356
387
  pending: !docker.ok || Boolean(agents),
388
+ // A service behind a profile the operator explicitly enabled is not merely
389
+ // "not started yet": they asked for it. Reporters use this to stay silent
390
+ // about ordinary boot latency while still surfacing this one.
391
+ requested: missingRequested.length > 0,
357
392
  detail: !docker.ok
358
393
  ? 'Waiting for Docker'
359
- : agents?.context?.downServices?.length
360
- ? `To start: ${agents.context.downServices.join(', ')}`
361
- : 'Running',
394
+ : missingRequested.length > 0
395
+ ? `Enabled but not running: ${missingRequested.join(', ')}`
396
+ : agents?.context?.downServices?.length
397
+ ? `To start: ${agents.context.downServices.join(', ')}`
398
+ : 'Running',
362
399
  context: { ...(agents?.context ?? {}), command: 'wiki-workspace agents up' },
363
400
  });
364
401
  const workspaceGap = checkWorkspace(workspaces);
@@ -4,7 +4,7 @@ import { mkdtemp } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import test from 'node:test';
7
- import { checkInternetConnectivity, checkMcpConnections, runChecks, runPreflightChecks, withRuntimePreflight } from './startupCheck.js';
7
+ import { checkAgents, checkInternetConnectivity, checkMcpConnections, runChecks, runPreflightChecks, withRuntimePreflight } from './startupCheck.js';
8
8
 
9
9
  async function withWorkspace(wikircLines, fn) {
10
10
  const root = await mkdtemp(join(tmpdir(), 'wiki-manager-startup-check-'));
@@ -34,6 +34,54 @@ function hasGap(gaps, kind) {
34
34
  return gaps.some((gap) => gap.kind === kind);
35
35
  }
36
36
 
37
+ test('checkAgents reports an enabled profile whose container never appeared', async () => {
38
+ // `docker compose ps` lists nothing for a service that was never created, so
39
+ // filtering only on "listed but stopped" reported success while connectors
40
+ // was absent. An enabled profile is a promise, not an opt-out.
41
+ const context = {
42
+ exists: true,
43
+ args: ['compose', '-p', 'wiki-agents', '--profile', 'connectors'],
44
+ profiles: ['connectors'],
45
+ expectedProfileServices: ['connectors'],
46
+ };
47
+ const running = JSON.stringify([{ Service: 'cme', State: 'running' }]);
48
+ const execWithPs = (ps) => async (_command, args) => ({
49
+ stdout: args.includes('config') ? 'cme\nconnectors\n' : ps,
50
+ });
51
+
52
+ const gap = await checkAgents({ context, exec: execWithPs(running) });
53
+ assert.equal(gap.kind, 'agents');
54
+ assert.deepEqual(gap.context.missingServices, ['connectors']);
55
+ assert.deepEqual(gap.context.profiles, ['connectors']);
56
+
57
+ const started = JSON.stringify([
58
+ { Service: 'cme', State: 'running' },
59
+ { Service: 'connectors', State: 'running' },
60
+ ]);
61
+ assert.equal(await checkAgents({ context, exec: execWithPs(started) }), null);
62
+ });
63
+
64
+ test('checkAgents ignores a profile the operator did not enable', async () => {
65
+ const context = { exists: true, args: [], profiles: [], expectedProfileServices: [] };
66
+ const stdout = JSON.stringify([{ Service: 'cme', State: 'running' }]);
67
+ const exec = async (_command, args) => ({
68
+ stdout: args.includes('config') ? 'cme\n' : stdout,
69
+ });
70
+ assert.equal(await checkAgents({ context, exec }), null);
71
+ });
72
+
73
+ test('checkAgents reports an ordinary agent that never created a container', async () => {
74
+ const context = { exists: true, args: [], profiles: [], expectedProfileServices: [] };
75
+ const exec = async (_command, args) => ({
76
+ stdout: args.includes('config')
77
+ ? 'cme\ndocuments\n'
78
+ : JSON.stringify([{ Service: 'cme', State: 'running' }]),
79
+ });
80
+ const gap = await checkAgents({ context, exec });
81
+ assert.deepEqual(gap.context.missingServices, ['documents']);
82
+ assert.deepEqual(gap.context.expectedServices, ['cme', 'documents']);
83
+ });
84
+
37
85
  test('runChecks treats default LLM config without baseUrl as incomplete', async () => {
38
86
  await withWorkspace([
39
87
  'llm:',
@@ -6,15 +6,12 @@ import { promisify } from 'node:util';
6
6
  import YAML from 'yaml';
7
7
  import { checkMissingDockerImages } from './dockerImages.js';
8
8
  import { patchWikircProfile } from './wikirc.js';
9
- import { managerEnvFile, managerMcpEndpointsFile, readEnvFile, resolveAgentsDataDir } from './env.js';
9
+ import { resolveAgentsComposeContext } from './agentsCompose.js';
10
+ import { managerEnvFile, managerMcpEndpointsFile, resolveAgentsDataDir } from './env.js';
10
11
  import { createWorkspace, findWorkspace, isValidWorkspaceName, listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
11
12
 
12
13
  const execFileAsync = promisify(execFile);
13
14
 
14
- function enabled(value) {
15
- return /^(?:1|true|yes|on)$/i.test(String(value ?? '').trim());
16
- }
17
-
18
15
  export function configuredAgentImages(config, activeProfiles = new Set()) {
19
16
  return Object.values(config.services ?? {})
20
17
  .filter((service) => {
@@ -25,21 +22,12 @@ export function configuredAgentImages(config, activeProfiles = new Set()) {
25
22
  .filter(Boolean);
26
23
  }
27
24
 
28
- async function missingAgentImages() {
29
- let managerEnv = {};
30
- try {
31
- managerEnv = readEnvFile(managerEnvFile());
32
- } catch {
33
- // A missing manager .env is valid during first-run setup.
34
- }
35
- const activeProfiles = new Set();
36
- if (enabled(managerEnv.CONNECTORS_ENABLED ?? process.env.CONNECTORS_ENABLED)) {
37
- activeProfiles.add('connectors');
38
- }
39
- const composeFiles = [
40
- join(managerRoot(), 'agents.docker-compose.yml'),
41
- join(dirname(managerEnvFile()), 'agents.docker-compose.override.yml'),
42
- ].filter(existsSync);
25
+ async function missingAgentImages(context = resolveAgentsComposeContext()) {
26
+ // Same resolution as the preflight and as `wiki-workspace agents up`: an
27
+ // image behind a profile the operator did not enable must not be reported
28
+ // missing, and one behind a profile they DID enable must be.
29
+ const activeProfiles = new Set(context.profiles);
30
+ const composeFiles = context.composeFiles.filter(existsSync);
43
31
  const images = [...new Set(composeFiles.flatMap((filePath) => {
44
32
  try {
45
33
  const config = YAML.parse(readFileSync(filePath, 'utf8')) ?? {};
@@ -77,13 +65,22 @@ function wrapDockerError(err) {
77
65
  }
78
66
 
79
67
  export async function startAgents(options = {}) {
68
+ const composeContext = options.composeContext ?? resolveAgentsComposeContext();
80
69
  try {
81
- const absentImages = await missingAgentImages();
70
+ const absentImages = options.imagesCheck
71
+ ? await options.imagesCheck(composeContext)
72
+ : await missingAgentImages(composeContext);
82
73
  if (absentImages.length > 0) options.onImagesMissing?.(absentImages);
83
- const { stdout, stderr } = await execFileAsync(join(managerRoot(), 'wiki-workspace'), ['agents', 'up'], {
74
+ const exec = options.exec ?? execFileAsync;
75
+ const { stdout, stderr } = await exec(join(managerRoot(), 'wiki-workspace'), ['agents', 'up'], {
84
76
  cwd: managerRoot(),
85
77
  env: {
86
78
  ...process.env,
79
+ // Compose gives variables already exported by its parent process
80
+ // precedence over --env-file, including an empty value loaded at boot.
81
+ // Re-apply the manager's resolved policy here so a token/secret written
82
+ // later to .env is not shadowed by stale process.env state.
83
+ ...composeContext.env,
87
84
  WIKI_WORKSPACES_DIR: workspacesDir(),
88
85
  // cwd is the npm package root (the script and compose files live
89
86
  // there), so the manager files MUST be pinned explicitly: without
@@ -102,15 +99,53 @@ export async function startAgents(options = {}) {
102
99
  timeout: options.timeout ?? 180_000,
103
100
  maxBuffer: options.maxBuffer ?? 1024 * 1024 * 8,
104
101
  });
102
+ // `wiki-workspace agents up` also (re)writes mcp.endpoints.json, so the
103
+ // endpoints must be re-read here — the caller's in-memory session still
104
+ // holds the file as it was BEFORE the connectors entry was added, and the
105
+ // freshly started agent would stay unreachable until the next restart.
106
+ const verification = await verifyAgentsStarted({
107
+ context: composeContext,
108
+ agentsCheck: options.agentsCheck,
109
+ });
110
+ if (!verification.ok) throw agentsStartFailure(verification);
105
111
  return {
106
112
  output: [stdout, stderr].filter(Boolean).join('\n').trim(),
107
113
  missingImages: absentImages,
114
+ profiles: composeContext.profiles,
108
115
  };
109
116
  } catch (err) {
110
117
  throw wrapDockerError(err);
111
118
  }
112
119
  }
113
120
 
121
+ // A start that leaves an enabled service down is a failure. Reporting success
122
+ // and letting the operator discover it through a silent preflight line is what
123
+ // made CONNECTORS_ENABLED=true look like it had been ignored.
124
+ async function verifyAgentsStarted({ context, agentsCheck }) {
125
+ const check = agentsCheck ?? (await import('./startupCheck.js')).checkAgents;
126
+ const gap = await check({ context });
127
+ if (!gap) return { ok: true, profiles: context.profiles };
128
+ const down = gap.context?.downServices ?? [];
129
+ const missing = gap.context?.missingServices ?? [];
130
+ if (down.length === 0 && missing.length === 0) return { ok: true, profiles: context.profiles };
131
+ return { ok: false, downServices: down, missingServices: missing, profiles: context.profiles };
132
+ }
133
+
134
+ function agentsStartFailure({ downServices = [], missingServices = [], profiles = [] }) {
135
+ const parts = [
136
+ missingServices.length > 0 ? `never started: ${missingServices.join(', ')}` : '',
137
+ downServices.length > 0 ? `not running: ${downServices.join(', ')}` : '',
138
+ ].filter(Boolean);
139
+ const error = new Error(
140
+ `Agents started, but the expected services are not up (${parts.join('; ')}).`
141
+ + (profiles.length > 0 ? ` Active profiles: ${profiles.join(', ')}.` : ''),
142
+ );
143
+ error.name = 'AgentsNotRunningError';
144
+ error.downServices = downServices;
145
+ error.missingServices = missingServices;
146
+ return error;
147
+ }
148
+
114
149
  export async function stopAgents(options = {}) {
115
150
  try {
116
151
  const { stdout, stderr } = await execFileAsync(join(managerRoot(), 'wiki-workspace'), ['agents', 'down'], {
@@ -0,0 +1,87 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import { startAgents } from './wikiSetup.js';
5
+
6
+ const CONTEXT = {
7
+ exists: true,
8
+ args: [],
9
+ profiles: ['connectors'],
10
+ expectedProfileServices: ['connectors'],
11
+ composeFiles: [],
12
+ };
13
+
14
+ function startOptions(overrides = {}) {
15
+ return {
16
+ composeContext: CONTEXT,
17
+ imagesCheck: async () => [],
18
+ exec: async () => ({ stdout: 'started', stderr: '' }),
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ test('starting the agents succeeds when every enabled service is running', async () => {
24
+ const result = await startAgents(startOptions({ agentsCheck: async () => null }));
25
+ assert.equal(result.output, 'started');
26
+ assert.deepEqual(result.profiles, ['connectors']);
27
+ });
28
+
29
+ test('an enabled connector that never started makes the start fail, not succeed', async () => {
30
+ // The script exits 0 whether or not the profiled container came up, so
31
+ // trusting its exit code alone reported success while connectors was absent
32
+ // — the operator only found out much later, through a silent preflight line.
33
+ await assert.rejects(
34
+ startAgents(startOptions({
35
+ agentsCheck: async () => ({ kind: 'agents', context: { missingServices: ['connectors'], profiles: ['connectors'] } }),
36
+ })),
37
+ (err) => {
38
+ assert.equal(err.name, 'AgentsNotRunningError');
39
+ assert.deepEqual(err.missingServices, ['connectors']);
40
+ assert.match(err.message, /never started: connectors/);
41
+ assert.match(err.message, /Active profiles: connectors\./);
42
+ return true;
43
+ },
44
+ );
45
+ });
46
+
47
+ test('a listed but stopped service also fails the start', async () => {
48
+ await assert.rejects(
49
+ startAgents(startOptions({
50
+ agentsCheck: async () => ({ kind: 'agents', context: { downServices: ['cme'] } }),
51
+ })),
52
+ (err) => {
53
+ assert.equal(err.name, 'AgentsNotRunningError');
54
+ assert.deepEqual(err.downServices, ['cme']);
55
+ assert.match(err.message, /not running: cme/);
56
+ return true;
57
+ },
58
+ );
59
+ });
60
+
61
+ test('the verification runs against the same compose context as the start', async () => {
62
+ let seen = null;
63
+ await startAgents(startOptions({
64
+ agentsCheck: async ({ context }) => { seen = context; return null; },
65
+ }));
66
+ assert.equal(seen, CONTEXT);
67
+ });
68
+
69
+ test('the manager .env values override stale blank process values for agents up', async () => {
70
+ let childEnv = null;
71
+ const context = {
72
+ ...CONTEXT,
73
+ env: {
74
+ ...process.env,
75
+ GOOGLE_OAUTH_CLIENT_SECRET: 'fresh-secret',
76
+ },
77
+ };
78
+ await startAgents(startOptions({
79
+ composeContext: context,
80
+ agentsCheck: async () => null,
81
+ exec: async (_file, _args, options) => {
82
+ childEnv = options.env;
83
+ return { stdout: 'started', stderr: '' };
84
+ },
85
+ }));
86
+ assert.equal(childEnv.GOOGLE_OAUTH_CLIENT_SECRET, 'fresh-secret');
87
+ });
@@ -28,6 +28,15 @@ test('wiki-workspace checks runtime pid command before killing', async () => {
28
28
  assert.match(script, /kill "\$\(cat "\$pid_file"\)"/);
29
29
  });
30
30
 
31
+ test('project refresh shuts down runtime and removes only compose-owned images', async () => {
32
+ const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
33
+
34
+ assert.match(script, /if \[\[ \$# -eq 1 && "\$1" == "refresh" \]\]; then\n refresh_project/);
35
+ assert.match(script, /compose_for_workspace "\$workspace" down --rmi all --remove-orphans/);
36
+ assert.match(script, /_agents_dc down --rmi all --remove-orphans/);
37
+ assert.doesNotMatch(script, /docker (?:image )?prune/);
38
+ });
39
+
31
40
  test('wiki-workspace regenerates CA compose overrides instead of retaining removed services', async () => {
32
41
  const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
33
42
 
@@ -57,6 +66,13 @@ test('wiki-workspace provisions connector secrets and persistent state', async (
57
66
  assert.match(script, /delete config\.chatAccess\.servers\.connectors/);
58
67
  });
59
68
 
69
+ test('local connector builds require both public Desktop OAuth values', async () => {
70
+ const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
71
+
72
+ assert.match(script, /for build_key in WIKILLM_GOOGLE_OAUTH_CLIENT_ID WIKILLM_GOOGLE_OAUTH_CLIENT_SECRET/);
73
+ assert.match(script, /require_connectors_build_credentials/);
74
+ });
75
+
60
76
  test('workspace creation keeps mutable manager files outside the installed package', async () => {
61
77
  const source = await readFile(new URL('./workspaces.js', import.meta.url), 'utf8');
62
78
 
@@ -41,13 +41,20 @@ async function discoverServerAgent(session, serverName, endpoint = {}, { callToo
41
41
  return legacyAgent(serverName, endpoint, { health: UNAVAILABLE, lastSeenAt });
42
42
  }
43
43
 
44
- const toolName = findAgentDescribeTool(serverName, endpoint.tools ?? []);
45
- if (!toolName) {
44
+ const tool = findAgentDescribeTool(serverName, endpoint.tools ?? []);
45
+ if (!tool) {
46
46
  return legacyAgent(serverName, endpoint, { health: AVAILABLE, lastSeenAt });
47
47
  }
48
+ const toolName = tool.name;
48
49
 
49
50
  try {
50
- const result = await callTool(session.mcp, serverName, toolName, {}, signal);
51
+ const result = await callTool(
52
+ session.mcp,
53
+ serverName,
54
+ toolName,
55
+ describeArguments(tool, session?.workspace),
56
+ signal,
57
+ );
51
58
  const description = assertContract('agentDescription', parseToolJsonResult(result));
52
59
  return {
53
60
  serverName,
@@ -108,13 +115,34 @@ function dispatchRegistryEvent(session, type, payload) {
108
115
  }
109
116
 
110
117
  function findAgentDescribeTool(serverName, tools) {
111
- const names = tools.map((tool) => String(tool.name ?? '')).filter(Boolean);
112
- return names.find((name) => name === 'agent_describe')
113
- ?? names.find((name) => name === `${serverName}__agent_describe`)
114
- ?? names.find((name) => name.endsWith('__agent_describe'))
118
+ const named = tools.filter((tool) => String(tool?.name ?? ''));
119
+ const byName = (predicate) => named.find((tool) => predicate(String(tool.name)));
120
+ return byName((name) => name === 'agent_describe')
121
+ ?? byName((name) => name === `${serverName}__agent_describe`)
122
+ ?? byName((name) => name.endsWith('__agent_describe'))
115
123
  ?? null;
116
124
  }
117
125
 
126
+ // Parts of a contract are workspace-scoped — typically the closed vocabulary of
127
+ // an argument (the sources declared in THIS workspace). Published as a bare
128
+ // string, such a field is unverifiable and a planner fills it with any noun
129
+ // from the objective, so it is worth telling the agent which workspace we are
130
+ // asking about.
131
+ //
132
+ // But the orchestrator must not assume an agent accepts an argument it never
133
+ // declared: an agent whose agent_describe schema is `additionalProperties:
134
+ // false` REJECTS the call, drops out of the registry, and its capabilities
135
+ // silently vanish — the objective then resolves to whatever agent is left.
136
+ // Send the workspace only to agents whose own schema says they can take it.
137
+ function describeArguments(tool, workspace) {
138
+ if (!workspace) return {};
139
+ const schema = tool?.inputSchema;
140
+ if (!schema || typeof schema !== 'object') return {};
141
+ const declaresWorkspace = Object.hasOwn(schema.properties ?? {}, 'workspace');
142
+ const acceptsExtra = schema.additionalProperties !== false;
143
+ return declaresWorkspace || acceptsExtra ? { workspace: String(workspace) } : {};
144
+ }
145
+
118
146
  function parseToolJsonResult(result) {
119
147
  if (result && typeof result === 'object' && !Array.isArray(result) && !Array.isArray(result.content)) {
120
148
  return result;
@@ -112,3 +112,76 @@ test('agentRegistry marks unavailable boot agents and emits health changes on re
112
112
  assert.equal(events[1].payload.health, 'available');
113
113
  assert.equal(registry.snapshot()[0].health, 'available');
114
114
  });
115
+
116
+ test('discovery sends the workspace only to agents whose schema declares it', async () => {
117
+ const seen = {};
118
+ const registry = createAgentRegistry({
119
+ callTool: async (_mcp, server, _tool, args) => {
120
+ seen[server] = args;
121
+ return { content: [{ type: 'text', text: JSON.stringify(description()) }] };
122
+ },
123
+ });
124
+
125
+ await registry.discover({
126
+ workspace: 'acpi',
127
+ mcp: {
128
+ // Declares workspace: gets it, and can scope its vocabulary.
129
+ cme: {
130
+ status: 'connected',
131
+ tools: [{
132
+ name: 'agent_describe',
133
+ inputSchema: { type: 'object', properties: { workspace: { type: 'string' } }, additionalProperties: false },
134
+ }],
135
+ },
136
+ // Strict schema without workspace: sending it would be REJECTED, the
137
+ // agent would drop out of the registry, and its capabilities would
138
+ // silently vanish from objective resolution.
139
+ production: {
140
+ status: 'connected',
141
+ tools: [{
142
+ name: 'agent_describe',
143
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
144
+ }],
145
+ },
146
+ // Open schema: tolerant, so the workspace is worth sending.
147
+ connectors: {
148
+ status: 'connected',
149
+ tools: [{ name: 'agent_describe', inputSchema: { type: 'object', properties: {} } }],
150
+ },
151
+ // No schema published at all: assume nothing.
152
+ legacyish: {
153
+ status: 'connected',
154
+ tools: [{ name: 'agent_describe' }],
155
+ },
156
+ },
157
+ });
158
+
159
+ assert.deepEqual(seen.cme, { workspace: 'acpi' });
160
+ assert.deepEqual(seen.production, {});
161
+ assert.deepEqual(seen.connectors, { workspace: 'acpi' });
162
+ assert.deepEqual(seen.legacyish, {});
163
+ });
164
+
165
+ test('discovery sends no workspace argument when no workspace is active', async () => {
166
+ const seen = [];
167
+ const registry = createAgentRegistry({
168
+ callTool: async (_mcp, _server, _tool, args) => {
169
+ seen.push(args);
170
+ return { content: [{ type: 'text', text: JSON.stringify(description()) }] };
171
+ },
172
+ });
173
+
174
+ await registry.discover({
175
+ mcp: {
176
+ cme: {
177
+ status: 'connected',
178
+ tools: [{
179
+ name: 'agent_describe',
180
+ inputSchema: { type: 'object', properties: { workspace: { type: 'string' } } },
181
+ }],
182
+ },
183
+ },
184
+ });
185
+
186
+ assert.deepEqual(seen, [{}]);
187
+ });
@@ -243,10 +243,15 @@ export function normalizeTaskError(rawError, { fallbackCode = null, fallbackMess
243
243
  retryable: transientError(fallbackCode),
244
244
  };
245
245
  }
246
+ // The agent gave a real reason. `fallbackMessage` describes only WHERE the
247
+ // failure was observed ("agent_execute rejected task"), so letting it win
248
+ // here replaced the one actionable sentence we have with a generic one —
249
+ // and Donna, left with nothing to explain, invented a cause. The fallback
250
+ // is a last resort, never an override.
246
251
  const code = String(rawError);
247
252
  return {
248
253
  code,
249
- message: String(fallbackMessage ?? code),
254
+ message: code,
250
255
  retryable: transientError(code),
251
256
  };
252
257
  }
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { createDispatcher } from './dispatcher.js';
3
+ import { createDispatcher, normalizeTaskError } from './dispatcher.js';
4
4
 
5
5
  test('dispatcher returns a retryable logical failure when agent_execute reports workspace_busy', async () => {
6
6
  const session = {
@@ -159,3 +159,26 @@ test('dispatcher keeps a null error when a terminal status reports none', async
159
159
  assert.equal(result.ok, true);
160
160
  assert.equal(result.error, null);
161
161
  });
162
+
163
+ test('normalizeTaskError keeps the agent reason as the message, never the fallback', () => {
164
+ // Regression: the fallback describes only WHERE the failure was seen
165
+ // ("agent_execute rejected task"). Letting it win discarded the one
166
+ // actionable sentence — and left Donna to invent a cause.
167
+ const error = normalizeTaskError("Error: source 'acpi' not found", {
168
+ fallbackCode: 'execution_rejected',
169
+ fallbackMessage: 'agent_execute rejected task',
170
+ });
171
+
172
+ assert.equal(error.code, "Error: source 'acpi' not found");
173
+ assert.equal(error.message, "Error: source 'acpi' not found");
174
+ });
175
+
176
+ test('normalizeTaskError falls back only when the agent reports no reason at all', () => {
177
+ const error = normalizeTaskError('', {
178
+ fallbackCode: 'execution_rejected',
179
+ fallbackMessage: 'agent_execute rejected task',
180
+ });
181
+
182
+ assert.equal(error.code, 'execution_rejected');
183
+ assert.equal(error.message, 'agent_execute rejected task');
184
+ });
@@ -1,3 +1,18 @@
1
+ // Distinguishes "nothing here can do that" from a genuine resolution failure:
2
+ // the first is a normal answer Donna gives the user, the second is a defect.
3
+ export class ObjectiveNotOrchestrableError extends Error {
4
+ constructor(objective, candidates = [], reason = '') {
5
+ const available = candidates.map((item) => item.id).join(', ') || 'none';
6
+ super(
7
+ `No connected agent can do that.${reason ? ` ${String(reason).trim()}` : ''}`
8
+ + ` Available capabilities: ${available}.`,
9
+ );
10
+ this.name = 'ObjectiveNotOrchestrableError';
11
+ this.objective = String(objective ?? '');
12
+ this.candidates = candidates.map((item) => item.id);
13
+ }
14
+ }
15
+
1
16
  export async function resolveObjective(objective, session) {
2
17
  const candidates = capabilityCandidates(session);
3
18
  if (candidates.length === 0) throw new Error('No orchestrable capability is currently available.');
@@ -10,6 +25,12 @@ export async function resolveObjective(objective, session) {
10
25
  system: [
11
26
  'You resolve one user objective against a closed capability registry.',
12
27
  'Select exactly one listed capability and one of its supported operations.',
28
+ // Without an explicit way out, the model has to pick SOMETHING: an
29
+ // objective no listed capability covers ("authorize Gmail") came back as
30
+ // workspace.diagnose/doctor and launched an unrelated job. Declining is
31
+ // a valid answer and the caller turns it into a plain reply.
32
+ 'If no listed capability can achieve the objective, do not pick the closest one:',
33
+ 'return {"capability":null,"reason":"<short reason>"}.',
13
34
  'Never invent identifiers. Return JSON only: {"capability":"...","operation":"..."}.',
14
35
  ].join('\n'),
15
36
  tools: [],
@@ -20,6 +41,9 @@ export async function resolveObjective(objective, session) {
20
41
  signal: session?._abortSignal,
21
42
  });
22
43
  const selection = parseJson(result?.content);
44
+ if (selection?.capability === null) {
45
+ throw new ObjectiveNotOrchestrableError(objective, candidates, selection?.reason);
46
+ }
23
47
  const capability = String(selection?.capability ?? '');
24
48
  const operation = String(selection?.operation ?? '');
25
49
  const candidate = candidates.find((item) => item.id === capability);