@dotdrelle/wiki-manager 0.15.28 → 0.15.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core/env.js CHANGED
@@ -39,18 +39,24 @@ export function managerMcpEndpointsFile() {
39
39
  return join(managerStateDir(), 'mcp.endpoints.json');
40
40
  }
41
41
 
42
- // User-owned compose overrides, one per stack, next to the manager .env.
43
- // Deliberately NOT under .wiki/runtime: everything there is generated state
44
- // that every compose command rewrites (see cacert.js). These two are seeded
45
- // once and never touched again, so an operator has a supported place to fix a
46
- // deployment — proxy passthrough, extra mounts, optional agents — instead of
47
- // editing a generated file and losing the change on the next command.
42
+ // User-owned compose overrides, one per stack. `.wiki/compose` is persistent
43
+ // operator configuration; `.wiki/runtime` is generated state rewritten by
44
+ // compose commands. Keeping those directories separate makes the lifecycle
45
+ // explicit while grouping manager-local files under one `.wiki` tree.
48
46
  export const COMPOSE_OVERRIDES = [
49
47
  { example: 'docker-compose.override.example.yml', target: 'docker-compose.override.yml' },
50
48
  { example: 'agents.docker-compose.override.example.yml', target: 'agents.docker-compose.override.yml' },
51
49
  ];
52
50
 
51
+ export function managerComposeDir() {
52
+ return join(managerStateDir(), '.wiki', 'compose');
53
+ }
54
+
53
55
  export function managerComposeOverrideFile(target = 'docker-compose.override.yml') {
56
+ return join(managerComposeDir(), target);
57
+ }
58
+
59
+ function legacyManagerComposeOverrideFile(target) {
54
60
  return join(managerStateDir(), target);
55
61
  }
56
62
 
@@ -160,12 +166,23 @@ export function ensureManagerScaffold({ log = () => {} } = {}) {
160
166
  for (const { example, target } of COMPOSE_OVERRIDES) {
161
167
  const examplePath = join(packageRoot, example);
162
168
  const targetPath = managerComposeOverrideFile(target);
169
+ const legacyPath = legacyManagerComposeOverrideFile(target);
170
+ mkdirSync(dirname(targetPath), { recursive: true });
171
+ if (!existsSync(targetPath) && existsSync(legacyPath)) {
172
+ renameSync(legacyPath, targetPath);
173
+ created.push(`.wiki/compose/${target} migrated`);
174
+ continue;
175
+ }
163
176
  if (!existsSync(examplePath) || existsSync(targetPath)) continue;
164
177
  copyFileSync(examplePath, targetPath);
165
- created.push(target);
178
+ created.push(`.wiki/compose/${target}`);
166
179
  }
167
180
  if (created.length > 0) {
168
- log(`configuration initialized successfully in ${managerStateDir()} — created ${created.join(' and ')} from packaged defaults. Optional credentials can be added later for external services.`);
181
+ // The list of seeded files and the state directory are implementation
182
+ // detail the operator has no decision to make about — the return value
183
+ // still carries them for callers that need to react (a fresh scaffold
184
+ // forces a runtime restart).
185
+ log('configuration initialized successfully');
169
186
  }
170
187
  return created;
171
188
  }
@@ -209,12 +226,12 @@ function parseEnvValue(value) {
209
226
  return value;
210
227
  }
211
228
 
212
- export function loadManagerEnv() {
229
+ export function loadManagerEnv({ override = false } = {}) {
213
230
  const filePath = managerEnvFile();
214
231
  if (!existsSync(filePath)) return;
215
232
  const values = readEnvFile(filePath);
216
233
  for (const [key, value] of Object.entries(values)) {
217
- if (!(key in process.env)) process.env[key] = value;
234
+ if (override || !(key in process.env)) process.env[key] = value;
218
235
  }
219
236
  }
220
237
 
package/src/core/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
3
3
 
4
- const WIKI_MANAGER_VERSION = '0.15.28';
4
+ const WIKI_MANAGER_VERSION = '0.15.29';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -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
+ });
@@ -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);
@@ -64,6 +64,33 @@ test('resolveObjective uses an unambiguously mentioned registry operation withou
64
64
  assert.equal(result.provider.agentInstanceId, 'production-1');
65
65
  });
66
66
 
67
+ test('resolveObjective declines an objective no listed capability covers', async () => {
68
+ // Forcing a pick is how "cree l'auth pour le gmail" became a doctor run on
69
+ // the production agent. Declining must be reported as such, not as a defect.
70
+ const session = sessionWithSelection({ capability: null, reason: 'Gmail authorization is not an orchestrable capability.' });
71
+ await assert.rejects(
72
+ resolveObjective("cree l'auth pour le gmail", session),
73
+ (err) => {
74
+ assert.equal(err.name, 'ObjectiveNotOrchestrableError');
75
+ assert.match(err.message, /No connected agent can do that\./);
76
+ assert.match(err.message, /Available capabilities: knowledge\.update\./);
77
+ assert.deepEqual(err.candidates, ['knowledge.update']);
78
+ return true;
79
+ },
80
+ );
81
+ });
82
+
83
+ test('resolveObjective treats a malformed selection as a resolution defect, not a decline', async () => {
84
+ await assert.rejects(
85
+ resolveObjective('Traite tout', sessionWithSelection({ reason: 'missing capability' })),
86
+ (err) => {
87
+ assert.notEqual(err.name, 'ObjectiveNotOrchestrableError');
88
+ assert.match(err.message, /unknown capability/);
89
+ return true;
90
+ },
91
+ );
92
+ });
93
+
67
94
  test('resolveObjective rejects invented capability and operation', async () => {
68
95
  await assert.rejects(
69
96
  resolveObjective('Traite tout', sessionWithSelection({ capability: 'ingest', operation: 'ingest_all_pending' })),