@dotdrelle/wiki-manager 0.15.70 → 0.15.71

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 (38) hide show
  1. package/README.md +24 -21
  2. package/agents.docker-compose.yml +5 -1
  3. package/package.json +1 -1
  4. package/src/activity/activityAggregator.test.js +2 -2
  5. package/src/agent/graph.js +1 -0
  6. package/src/cli/wiki-manager.js +1 -1
  7. package/src/cli/wiki-manager.test.js +16 -16
  8. package/src/commands/slash.js +24 -5
  9. package/src/core/agentEvents.js +122 -25
  10. package/src/core/agentEvents.test.js +26 -1
  11. package/src/core/buildInfo.json +2 -2
  12. package/src/core/commandFailure.test.js +2 -2
  13. package/src/core/currentArtifact.test.js +5 -5
  14. package/src/core/mcp.js +1 -1
  15. package/src/core/mcp.test.js +1 -1
  16. package/src/core/otherWorkspacesRunning.test.js +6 -6
  17. package/src/core/runtimeLog.js +35 -1
  18. package/src/core/runtimeLog.test.js +27 -2
  19. package/src/core/skillInvocation.test.js +1 -1
  20. package/src/core/wikiSetup.js +25 -0
  21. package/src/core/wikiSetup.test.js +35 -0
  22. package/src/core/wikirc.test.js +6 -6
  23. package/src/core/workspaceInherit.test.js +14 -14
  24. package/src/orchestrator/agentRegistry.test.js +6 -6
  25. package/src/orchestrator/dispatcher.js +70 -26
  26. package/src/orchestrator/dispatcher.test.js +46 -3
  27. package/src/orchestrator/providers/deepAgentsProvider.test.js +2 -2
  28. package/src/orchestrator/providers/runtimeProviders.js +58 -5
  29. package/src/orchestrator/providers/runtimeProviders.test.js +23 -0
  30. package/src/orchestrator/scheduler.test.js +4 -4
  31. package/src/runtime/delegation.test.js +11 -11
  32. package/src/runtime/runner.test.js +1 -1
  33. package/src/runtime/server.test.js +2 -2
  34. package/src/runtime/store.test.js +8 -5
  35. package/src/runtime/workspaceIsolation.test.js +26 -26
  36. package/src/shell/RightPane.tsx +14 -2
  37. package/src/shell/repl.js +24 -2
  38. package/wiki-workspace +34 -0
@@ -27,25 +27,25 @@ function workspaceEntry(root, name) {
27
27
 
28
28
  test('the current workspace is never counted as another one', async () => {
29
29
  const root = mkdtempSync(join(tmpdir(), 'ws-running-self-'));
30
- const acpi = workspaceEntry(root, 'acpi');
30
+ const acme = workspaceEntry(root, 'acme');
31
31
 
32
- const busy = await otherWorkspacesRunning({ workspace: 'acpi' }, [acpi]);
32
+ const busy = await otherWorkspacesRunning({ workspace: 'acme' }, [acme]);
33
33
 
34
34
  assert.deepEqual(busy, [], 'stopping a workspace must not be blocked by itself');
35
35
  });
36
36
 
37
37
  test('an unqueryable workspace does not hold the shared agents hostage', async () => {
38
38
  const root = mkdtempSync(join(tmpdir(), 'ws-running-unknown-'));
39
- const workspaces = [workspaceEntry(root, 'acpi'), workspaceEntry(root, 'stale')];
39
+ const workspaces = [workspaceEntry(root, 'acme'), workspaceEntry(root, 'stale')];
40
40
 
41
- const busy = await otherWorkspacesRunning({ workspace: 'acpi' }, workspaces);
41
+ const busy = await otherWorkspacesRunning({ workspace: 'acme' }, workspaces);
42
42
 
43
43
  assert.deepEqual(busy, []);
44
44
  });
45
45
 
46
46
  test('a single workspace, or none at all, never blocks', async () => {
47
- assert.deepEqual(await otherWorkspacesRunning({ workspace: 'acpi' }, []), []);
47
+ assert.deepEqual(await otherWorkspacesRunning({ workspace: 'acme' }, []), []);
48
48
  assert.deepEqual(await otherWorkspacesRunning({}, []), []);
49
49
  // Entries without a name are registry noise, not workspaces.
50
- assert.deepEqual(await otherWorkspacesRunning({ workspace: 'acpi' }, [{}, null]), []);
50
+ assert.deepEqual(await otherWorkspacesRunning({ workspace: 'acme' }, [{}, null]), []);
51
51
  });
@@ -70,7 +70,9 @@ export function formatRuntimeLogPayload(payload = {}, ts = null) {
70
70
  const time = timeLabel(ts);
71
71
  const event = eventLabel(payload.event);
72
72
  const fields = ORDERED_FIELDS
73
- .map((key) => formatField(FIELD_ALIASES[key], payload[key]))
73
+ .map((key) => (key === 'taskId'
74
+ ? formatField(FIELD_ALIASES[key], shortTaskLabel(payload[key]))
75
+ : formatField(FIELD_ALIASES[key], payload[key])))
74
76
  .filter(Boolean);
75
77
  if (payload.status != null) fields.push(formatField('status', payload.status));
76
78
  if (payload.percent != null) fields.push(formatField('percent', payload.percent));
@@ -90,11 +92,43 @@ function shortenUuids(text) {
90
92
  return String(text ?? '').replace(UUID_RE, (uuid) => `${uuid.slice(0, 8)}…`);
91
93
  }
92
94
 
95
+ // A structured taskId is `<runId-uuid>:<slug>-<hash8>`. The UUID and the hash
96
+ // carry no meaning for a reader; keep the human-readable slug in between so
97
+ // `task=…` in a runtime log line names the work instead of an opaque id. A
98
+ // plain id that is neither prefixed nor hash-suffixed (`task-build`, `a`, a
99
+ // legacy step number) is left exactly as it is — only the UUID is collapsed.
100
+ const TASK_HASH_SUFFIX = /-[0-9a-f]{8,}$/i;
101
+
102
+ export function shortTaskLabel(value) {
103
+ const raw = String(value ?? '').trim();
104
+ if (!raw) return raw;
105
+ const hasColon = raw.includes(':');
106
+ if (!hasColon && !TASK_HASH_SUFFIX.test(raw)) return shortenUuids(raw);
107
+ const tail = hasColon ? raw.slice(raw.indexOf(':') + 1) : raw;
108
+ const pretty = tail.replace(TASK_HASH_SUFFIX, '').replace(/[-_]+/g, ' ').trim();
109
+ return pretty || shortenUuids(raw);
110
+ }
111
+
93
112
  export function shortLogId(value, { maxLength = 40 } = {}) {
94
113
  const shortened = shortenUuids(value);
95
114
  return shortened.length > maxLength ? `${shortened.slice(0, maxLength - 1)}…` : shortened;
96
115
  }
97
116
 
117
+ // A line emitted by formatRuntimeLogPayload for a structured event: optional
118
+ // HH:MM:SS, then the ALL-CAPS event token eventLabel() produces from the LAST
119
+ // dotted segment ('job.accepted' → 'ACCEPTED', 'capability.resolving' →
120
+ // 'RESOLVING', 'agent_status' → 'AGENT_STATUS'). These are the dispatch
121
+ // plumbing. A business line the reducer writes starts with a ▸/✓/✗/↻ glyph or
122
+ // a capitalised word ("Plan received", "Run failed:") — never an all-caps
123
+ // token — so this one shape separates the two without an event-name list
124
+ // (which is what an earlier enumeration got wrong: it only ever matched the
125
+ // two underscore-form events and missed every dotted one).
126
+ const DISPATCH_PLUMBING_LINE = /^(?:\d{1,2}:\d{2}(?::\d{2})?\s+)?[A-Z][A-Z0-9_]{2,}(?:\s|$)/;
127
+
128
+ export function isDispatchPlumbingLine(line) {
129
+ return DISPATCH_PLUMBING_LINE.test(String(line ?? ''));
130
+ }
131
+
98
132
  export function runtimeLogMatchesFilter(line, filter = '') {
99
133
  const query = String(filter ?? '').trim();
100
134
  if (!query) return true;
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
 
4
4
  import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
5
- import { compactRuntimeLogForDisplay, formatRuntimeLogPayload, shortLogId } from './runtimeLog.js';
5
+ import { compactRuntimeLogForDisplay, formatRuntimeLogPayload, isDispatchPlumbingLine, shortLogId } from './runtimeLog.js';
6
6
  import { emitRuntimeLog } from '../runtime/supervisor.js';
7
7
 
8
8
  const CYCLE_EVENTS = [
@@ -112,12 +112,37 @@ test('long UUIDs collapse to a short prefix so log lines stay on one line', () =
112
112
  }, '2026-07-08T14:42:18.000Z');
113
113
 
114
114
  assert.match(line, /run=7fadad27…/);
115
- assert.match(line, /task=7fadad27…:taxonomy-synthesis/);
115
+ // The taskId's UUID prefix and hash suffix are dropped entirely: the field
116
+ // names the work ("taxonomy synthesis"), not an opaque id.
117
+ assert.match(line, /task="taxonomy synthesis"/);
116
118
  assert.match(line, /attempt=attempt-7fadad27…/);
117
119
  assert.match(line, /agentInstance=production-7fadad27…/);
118
120
  assert.doesNotMatch(line, /7fadad27-0be6-4d08-96e5-664fe7ee841e/);
119
121
  });
120
122
 
123
+ test('isDispatchPlumbingLine recognises every formatted event token, dotted ones included', () => {
124
+ // eventLabel() keeps only the last dotted segment, so these are the tokens
125
+ // that actually reach the panel — an event-name enumeration missed them.
126
+ for (const event of ['job.accepted', 'agent.selected', 'capability.resolving', 'runtime.accepted', 'agent_status', 'agent_execute', 'task.result_returned']) {
127
+ const line = formatRuntimeLogPayload({ event, runId: 'r1', taskId: 't1' }, '2026-07-08T14:42:18.000Z');
128
+ assert.equal(isDispatchPlumbingLine(line), true, `expected plumbing: ${line}`);
129
+ }
130
+ });
131
+
132
+ test('isDispatchPlumbingLine leaves the business flow lines for the Runtime tab', () => {
133
+ for (const line of [
134
+ '14:42:18 ▸ Polish proposition — started (knowledge.polish → agent-production)',
135
+ '14:42:19 ✓ Polish proposition — done (1 output)',
136
+ '14:42:19 ✗ Ingest DSI — failed: dependency_failed',
137
+ '14:42:20 ↻ Build overview — retry 2/3 (rate_limit)',
138
+ '14:42:21 Run failed: No agent provides capability workspace.restore.',
139
+ '14:42:22 Plan validated for run r1',
140
+ '14:42:23 Control message: où en est le build ?',
141
+ ]) {
142
+ assert.equal(isDispatchPlumbingLine(line), false, `expected business flow: ${line}`);
143
+ }
144
+ });
145
+
121
146
  test('shortLogId caps an over-long task slug while shortening embedded UUIDs', () => {
122
147
  const long = `${'x'.repeat(48)}-deadbeef`;
123
148
  assert.match(shortLogId(long), /…$/);
@@ -18,7 +18,7 @@ test('matchSkillInvocation resolves only a real workspace skill', () => {
18
18
 
19
19
  test('parseSkillArguments preserves one free-form argument and parses quoted multi params', () => {
20
20
  assert.deepEqual(parseSkillArguments({ params: ['files'] }, 'document A.md document B.md'), { files: 'document A.md document B.md' });
21
- assert.deepEqual(parseSkillArguments({ params: ['deliverable', 'polish'] }, '"architecture-juno" "améliorer la sécurité réseau"'), { deliverable: 'architecture-juno', polish: 'améliorer la sécurité réseau' });
21
+ assert.deepEqual(parseSkillArguments({ params: ['deliverable', 'polish'] }, '"architecture-demo" "improve the network security"'), { deliverable: 'architecture-demo', polish: 'improve the network security' });
22
22
  });
23
23
 
24
24
  test('legacy placeholders remain supported and are reported', () => {
@@ -115,6 +115,31 @@ export async function startAgents(options = {}) {
115
115
  profiles: composeContext.profiles,
116
116
  };
117
117
  } catch (err) {
118
+ // One optional agent failing to start (today: the agentic gateway, whose
119
+ // image is not pulled yet) makes `docker compose up` exit non-zero — but
120
+ // the base stack usually DID come up. Aborting here turned `/start all`
121
+ // into "gateway image missing ⇒ workspace never starts", which is the
122
+ // optional tail wagging the whole dog. If the base agents are up, degrade
123
+ // loudly instead of failing: the caller continues, and the absent agent
124
+ // announces itself through its own check (/status, preflight, discovery).
125
+ const verification = await verifyAgentsStarted({
126
+ context: composeContext,
127
+ agentsCheck: options.agentsCheck,
128
+ }).catch(() => null);
129
+ if (verification?.ok) {
130
+ return {
131
+ output: [
132
+ (options.services ?? []).length > 0
133
+ ? 'A requested agent failed to start — the rest of the stack is up.'
134
+ : 'The agents stack started degraded — one optional agent failed (see /status).',
135
+ err instanceof Error ? (err.message ?? String(err)) : String(err),
136
+ ].filter(Boolean).join('\n').trim(),
137
+ missingImages: [],
138
+ profiles: composeContext.profiles,
139
+ degraded: true,
140
+ degradedError: wrapDockerError(err),
141
+ };
142
+ }
118
143
  throw wrapDockerError(err);
119
144
  }
120
145
  }
@@ -85,3 +85,38 @@ test('the manager .env values override stale blank process values for agents up'
85
85
  }));
86
86
  assert.equal(childEnv.GOOGLE_OAUTH_CLIENT_SECRET, 'fresh-secret');
87
87
  });
88
+
89
+ test('an optional agent that refuses to start degrades instead of failing /start all', async () => {
90
+ // The gateway image not being pulled yet makes `docker compose up` exit
91
+ // non-zero while the base stack still comes up. Aborting there made
92
+ // `/start all` stop before the workspace services — the optional tail
93
+ // wagging the whole dog. With the base agents verified up, the start
94
+ // returns a degraded result the caller can continue from.
95
+ const result = await startAgents(startOptions({
96
+ exec: async () => {
97
+ const err = new Error('pull access denied for dotdrelle/wiki-agentic-gateway:latest');
98
+ err.code = 1;
99
+ throw err;
100
+ },
101
+ agentsCheck: async () => null,
102
+ }));
103
+
104
+ assert.equal(result.degraded, true);
105
+ assert.match(result.output, /started degraded/);
106
+ assert.match(result.output, /wiki-agentic-gateway/);
107
+ assert.deepEqual(result.profiles, ['connectors']);
108
+ });
109
+
110
+ test('a docker failure that leaves the base agents down still fails the start', async () => {
111
+ await assert.rejects(
112
+ startAgents(startOptions({
113
+ exec: async () => {
114
+ const err = new Error('Cannot connect to the Docker daemon');
115
+ err.code = 1;
116
+ throw err;
117
+ },
118
+ agentsCheck: async () => ({ kind: 'agents', context: { downServices: ['cme', 'documents'] } }),
119
+ })),
120
+ /Docker daemon is not running/,
121
+ );
122
+ });
@@ -273,7 +273,7 @@ test('finalizeCreatedWorkspace seeds a new workspace from the one in use', async
273
273
  return workspacePath;
274
274
  };
275
275
 
276
- makeWorkspace('acpi', [
276
+ makeWorkspace('acme', [
277
277
  'language: en',
278
278
  'llm:',
279
279
  ' provider: ai-gateway',
@@ -287,7 +287,7 @@ test('finalizeCreatedWorkspace seeds a new workspace from the one in use', async
287
287
  ].join('\n'));
288
288
 
289
289
  // Exactly what the scaffold writes: placeholders everywhere.
290
- const targetPath = makeWorkspace('nouveau', [
290
+ const targetPath = makeWorkspace('fresh', [
291
291
  'language: en',
292
292
  'llm:',
293
293
  ' provider: openai-compatible',
@@ -300,15 +300,15 @@ test('finalizeCreatedWorkspace seeds a new workspace from the one in use', async
300
300
  '',
301
301
  ].join('\n'));
302
302
 
303
- mkdirSync(join(agentsData, 'cme', 'acpi', 'cme'), { recursive: true });
304
- writeFileSync(join(agentsData, 'cme', 'acpi', 'cme', 'app_data.json'), '{"pat":"x"}', 'utf8');
303
+ mkdirSync(join(agentsData, 'cme', 'acme', 'cme'), { recursive: true });
304
+ writeFileSync(join(agentsData, 'cme', 'acme', 'cme', 'app_data.json'), '{"pat":"x"}', 'utf8');
305
305
 
306
306
  const previousDir = process.env.WIKI_WORKSPACES_DIR;
307
307
  const previousData = process.env.AGENTS_DATA_DIR;
308
308
  process.env.WIKI_WORKSPACES_DIR = registryRoot;
309
309
  process.env.AGENTS_DATA_DIR = agentsData;
310
310
  try {
311
- const { inherited } = await finalizeCreatedWorkspace('nouveau', { inheritFrom: 'acpi' });
311
+ const { inherited } = await finalizeCreatedWorkspace('fresh', { inheritFrom: 'acme' });
312
312
  const parsed = YAML.parse(readFileSync(join(targetPath, '.wikirc.yaml'), 'utf8'));
313
313
 
314
314
  assert.equal(parsed.llm.baseUrl, 'https://itsdonna.events/v1');
@@ -320,7 +320,7 @@ test('finalizeCreatedWorkspace seeds a new workspace from the one in use', async
320
320
  assert.ok(inherited.includes('llm.baseUrl'));
321
321
  assert.ok(inherited.includes('cme.app_data.json'));
322
322
  assert.equal(
323
- readFileSync(join(agentsData, 'cme', 'nouveau', 'cme', 'app_data.json'), 'utf8'),
323
+ readFileSync(join(agentsData, 'cme', 'fresh', 'cme', 'app_data.json'), 'utf8'),
324
324
  '{"pat":"x"}',
325
325
  );
326
326
  } finally {
@@ -146,36 +146,36 @@ test('nothing to inherit yields an empty patch', () => {
146
146
 
147
147
  test('CME credentials are copied, and the source manifest is not', async () => {
148
148
  const root = mkdtempSync(join(tmpdir(), 'cme-inherit-'));
149
- const sourceDir = join(root, 'cme', 'acpi', 'cme');
149
+ const sourceDir = join(root, 'cme', 'acme', 'cme');
150
150
  mkdirSync(sourceDir, { recursive: true });
151
151
  writeFileSync(join(sourceDir, 'app_data.json'), '{"auth":{"pat":"secret"}}', 'utf8');
152
152
  // Export scope is what makes a workspace different — it must NOT travel.
153
- writeFileSync(join(root, 'cme', 'acpi', 'sources-manifest.yaml'), 'sources: []\n', 'utf8');
153
+ writeFileSync(join(root, 'cme', 'acme', 'sources-manifest.yaml'), 'sources: []\n', 'utf8');
154
154
 
155
- const copied = await copyCmeCredentials(root, 'acpi', 'nouveau');
155
+ const copied = await copyCmeCredentials(root, 'acme', 'fresh');
156
156
 
157
- assert.equal(copied, cmeCredentialsPath(root, 'nouveau'));
157
+ assert.equal(copied, cmeCredentialsPath(root, 'fresh'));
158
158
  assert.equal(readFileSync(copied, 'utf8'), '{"auth":{"pat":"secret"}}');
159
- assert.equal(existsSync(join(root, 'cme', 'nouveau', 'sources-manifest.yaml')), false);
159
+ assert.equal(existsSync(join(root, 'cme', 'fresh', 'sources-manifest.yaml')), false);
160
160
  });
161
161
 
162
162
  test('existing CME credentials on the target are never clobbered', async () => {
163
163
  const root = mkdtempSync(join(tmpdir(), 'cme-inherit-keep-'));
164
- mkdirSync(join(root, 'cme', 'acpi', 'cme'), { recursive: true });
165
- mkdirSync(join(root, 'cme', 'nouveau', 'cme'), { recursive: true });
166
- writeFileSync(join(root, 'cme', 'acpi', 'cme', 'app_data.json'), '{"from":"source"}', 'utf8');
167
- writeFileSync(join(root, 'cme', 'nouveau', 'cme', 'app_data.json'), '{"from":"target"}', 'utf8');
164
+ mkdirSync(join(root, 'cme', 'acme', 'cme'), { recursive: true });
165
+ mkdirSync(join(root, 'cme', 'fresh', 'cme'), { recursive: true });
166
+ writeFileSync(join(root, 'cme', 'acme', 'cme', 'app_data.json'), '{"from":"source"}', 'utf8');
167
+ writeFileSync(join(root, 'cme', 'fresh', 'cme', 'app_data.json'), '{"from":"target"}', 'utf8');
168
168
 
169
- assert.equal(await copyCmeCredentials(root, 'acpi', 'nouveau'), null);
169
+ assert.equal(await copyCmeCredentials(root, 'acme', 'fresh'), null);
170
170
  assert.equal(
171
- readFileSync(cmeCredentialsPath(root, 'nouveau'), 'utf8'),
171
+ readFileSync(cmeCredentialsPath(root, 'fresh'), 'utf8'),
172
172
  '{"from":"target"}',
173
173
  );
174
174
  });
175
175
 
176
176
  test('copying is a no-op without a source, a target, or a source file', async () => {
177
177
  const root = mkdtempSync(join(tmpdir(), 'cme-inherit-noop-'));
178
- assert.equal(await copyCmeCredentials(root, 'absent', 'nouveau'), null);
179
- assert.equal(await copyCmeCredentials(root, null, 'nouveau'), null);
180
- assert.equal(await copyCmeCredentials(root, 'acpi', 'acpi'), null);
178
+ assert.equal(await copyCmeCredentials(root, 'absent', 'fresh'), null);
179
+ assert.equal(await copyCmeCredentials(root, null, 'fresh'), null);
180
+ assert.equal(await copyCmeCredentials(root, 'acme', 'acme'), null);
181
181
  });
@@ -144,7 +144,7 @@ test('a failed re-discovery keeps the orchestrator agent, never erases its capab
144
144
  */
145
145
  const events = [];
146
146
  const session = {
147
- workspace: 'acpi',
147
+ workspace: 'acme',
148
148
  mcp: {
149
149
  production: { status: 'connected', tools: [{ name: 'agent_describe' }] },
150
150
  },
@@ -216,7 +216,7 @@ test('a stopped agent is reported once, not on every re-scan', async () => {
216
216
  // until it answers again.
217
217
  const events = [];
218
218
  const session = {
219
- workspace: 'acpi',
219
+ workspace: 'acme',
220
220
  mcp: { production: { status: 'connected', tools: [{ name: 'agent_describe' }] } },
221
221
  _onAgentEvent: (event) => events.push(event),
222
222
  };
@@ -260,7 +260,7 @@ test('discovery sends the workspace only to agents whose schema declares it', as
260
260
  });
261
261
 
262
262
  await registry.discover({
263
- workspace: 'acpi',
263
+ workspace: 'acme',
264
264
  mcp: {
265
265
  // Declares workspace: gets it, and can scope its vocabulary.
266
266
  cme: {
@@ -293,9 +293,9 @@ test('discovery sends the workspace only to agents whose schema declares it', as
293
293
  },
294
294
  });
295
295
 
296
- assert.deepEqual(seen.cme, { workspace: 'acpi' });
296
+ assert.deepEqual(seen.cme, { workspace: 'acme' });
297
297
  assert.deepEqual(seen.production, {});
298
- assert.deepEqual(seen.connectors, { workspace: 'acpi' });
298
+ assert.deepEqual(seen.connectors, { workspace: 'acme' });
299
299
  assert.deepEqual(seen.legacyish, {});
300
300
  });
301
301
 
@@ -374,7 +374,7 @@ test('markPersistedAgentsStale invalide aussi l’instantané routable', () => {
374
374
 
375
375
  test('un agent redevient sélectionnable après un agent_describe réussi', async () => {
376
376
  const session = {
377
- workspace: 'juno',
377
+ workspace: 'demo',
378
378
  agentEvents: [],
379
379
  agents: [{ agentInstanceId: 'production-main', serverName: 'production', health: 'available', description: { contractVersion: '1', capabilities: [{ id: 'knowledge.update', version: '1' }] } }],
380
380
  mcp: {
@@ -474,36 +474,80 @@ const READ_ONLY_WIKI_TOOLS = new Set([
474
474
  'wiki_workspace_status',
475
475
  ]);
476
476
 
477
- // The runtime's EYES, per run: the active workspace's wiki MCP, read tools
478
- // only. Workspace-scoped endpoints are per-run by nature — they cannot live
479
- // in a static gateway file. The allow-list here is the authority: nothing
477
+ // An arbitrary external connector's tool names cannot be enumerated ahead of
478
+ // time the way READ_ONLY_WIKI_TOOLS can, so this stays a denylist — but a
479
+ // word-boundary one. The old test was a raw substring match
480
+ // (/write|send|create|update|add|remove/), which dropped safe reads whose name
481
+ // merely contained a mutating verb (`list_recent_updates`, `get_created_at`,
482
+ // `search_addresses`) and missed side-effecting tools that use another verb
483
+ // (`crawl_site`, `post_message`, `run_report`, `publish_draft`). The real
484
+ // guardrails against "hands on the workspace" are elsewhere —
485
+ // EXCLUDED_EXTERNAL_SERVERS covers every workspace-writing agent, and a
486
+ // per-tool `requireApproval` entry drops the whole server — this filter only
487
+ // keeps an obviously-mutating tool of an otherwise-safe connector out of the
488
+ // runtime's eyes.
489
+ const EXTERNAL_MUTATING_VERB = /(?:^|[_-])(?:write|send|post|create|delete|destroy|remove|drop|modify|edit|update|patch|put|upload|publish|submit|execute|run|crawl|trigger|cancel|approve|move|rename|set|add|insert|append|archive)(?:[_-]|$)/i;
490
+
491
+ function isReadOnlyExternalTool(toolName) {
492
+ const base = toolName.includes('__') ? toolName.slice(toolName.lastIndexOf('__') + 2) : toolName;
493
+ return !EXTERNAL_MUTATING_VERB.test(base);
494
+ }
495
+
496
+ // The runtime's EYES, per run: the active workspace's wiki MCP (read tools
497
+ // only) PLUS the declared external MCP endpoints that are safe to hand over
498
+ // (connected, no approval-gated tools, not a workspace-mutating server) —
499
+ // typically web search (exa). The allow-list here is the authority: nothing
480
500
  // else reaches the runtime.
481
501
  export function activeProfileMcp(session) {
502
+ const blocks = [];
482
503
  const wiki = session?.mcp?.wiki;
483
- if (!wiki?.url || wiki.status !== 'connected') return null;
484
- const tools = (wiki.tools ?? [])
485
- .map((tool) => String(tool.name ?? ''))
486
- .filter((name) => {
487
- if (!name) return false;
488
- const base = name.includes('__') ? name.slice(name.lastIndexOf('__') + 2) : name;
489
- return READ_ONLY_WIKI_TOOLS.has(base);
504
+ if (wiki?.url && wiki.status === 'connected') {
505
+ const tools = (wiki.tools ?? [])
506
+ .map((tool) => String(tool.name ?? ''))
507
+ .filter((name) => {
508
+ if (!name) return false;
509
+ const base = name.includes('__') ? name.slice(name.lastIndexOf('__') + 2) : name;
510
+ return READ_ONLY_WIKI_TOOLS.has(base);
511
+ });
512
+ if (tools.length > 0) {
513
+ // Same credential contract as the manager's own MCP client (mcp.js
514
+ // `authorization: Bearer ${endpoint.token}`): the wiki detail carries
515
+ // `token`, not `headers` — without it the gateway's MCP connection is
516
+ // rejected by the workspace MCP server ("invalid or missing bearer token")
517
+ // and the Deep Agent runs blind.
518
+ const headers = {
519
+ ...(wiki.headers && typeof wiki.headers === 'object' ? wiki.headers : {}),
520
+ ...(wiki.token ? { Authorization: `Bearer ${wiki.token}` } : {}),
521
+ };
522
+ blocks.push({
523
+ name: 'wiki',
524
+ url: String(wiki.url),
525
+ ...(Object.keys(headers).length > 0 ? { headers } : {}),
526
+ tools,
527
+ });
528
+ }
529
+ }
530
+ // External connectors ride along ONLY when the operator declared them safe
531
+ // for the runtime's eyes. A connector added from the serve panel lands here
532
+ // too — without this, exa was offered in chat but the agentic path
533
+ // delegated to the gateway and the Deep Agent answered it had no web tools.
534
+ const EXCLUDED_EXTERNAL_SERVERS = new Set(['cme', 'documents', 'connectors', 'production']);
535
+ for (const [name, entry] of Object.entries(session?.mcp ?? {})) {
536
+ if (!entry?.external || entry.status !== 'connected') continue;
537
+ if (EXCLUDED_EXTERNAL_SERVERS.has(name)) continue;
538
+ if (Array.isArray(entry.requireApproval) && entry.requireApproval.length > 0) continue;
539
+ const tools = (entry.tools ?? [])
540
+ .map((tool) => String(tool.name ?? ''))
541
+ .filter((toolName) => toolName && isReadOnlyExternalTool(toolName));
542
+ if (tools.length === 0) continue;
543
+ blocks.push({
544
+ name,
545
+ url: String(entry.url),
546
+ ...(entry.headers && typeof entry.headers === 'object' ? { headers: entry.headers } : {}),
547
+ tools,
490
548
  });
491
- if (tools.length === 0) return null;
492
- // Same credential contract as the manager's own MCP client (mcp.js
493
- // `authorization: Bearer ${endpoint.token}`): the wiki detail carries
494
- // `token`, not `headers` — without it the gateway's MCP connection is
495
- // rejected by the workspace MCP server ("invalid or missing bearer token")
496
- // and the Deep Agent runs blind.
497
- const headers = {
498
- ...(wiki.headers && typeof wiki.headers === 'object' ? wiki.headers : {}),
499
- ...(wiki.token ? { Authorization: `Bearer ${wiki.token}` } : {}),
500
- };
501
- return [{
502
- name: 'wiki',
503
- url: String(wiki.url),
504
- ...(Object.keys(headers).length > 0 ? { headers } : {}),
505
- tools,
506
- }];
549
+ }
550
+ return blocks.length > 0 ? blocks : null;
507
551
  }
508
552
 
509
553
  // The Deep Agent's system prompt, built per run from the same ingredients
@@ -35,6 +35,49 @@ test('activeProfileMcp forwards only the read-only wiki tools to the external ru
35
35
  );
36
36
  });
37
37
 
38
+ test('activeProfileMcp hands the external runtime a safe connector\'s read tools by word-boundary verb', () => {
39
+ const session = {
40
+ mcp: {
41
+ wiki: {
42
+ url: 'http://wiki:3000/mcp', status: 'connected',
43
+ tools: [{ name: 'wiki_read_page' }],
44
+ },
45
+ exa: {
46
+ url: 'https://exa/mcp', status: 'connected', external: true,
47
+ tools: [
48
+ { name: 'web_search_exa' },
49
+ { name: 'research_paper_search' },
50
+ // a raw substring denylist wrongly dropped these safe reads:
51
+ { name: 'list_recent_updates' },
52
+ { name: 'get_created_items' },
53
+ // …and wrongly kept these, which use a verb it did not enumerate:
54
+ { name: 'crawl_site' },
55
+ { name: 'post_message' },
56
+ { name: 'run_report' },
57
+ { name: 'send_email' },
58
+ ],
59
+ },
60
+ },
61
+ };
62
+ const exaBlock = activeProfileMcp(session).find((block) => block.name === 'exa');
63
+ assert.deepEqual(
64
+ [...exaBlock.tools].sort(),
65
+ ['get_created_items', 'list_recent_updates', 'research_paper_search', 'web_search_exa'],
66
+ );
67
+ });
68
+
69
+ test('activeProfileMcp drops an external connector that is approval-gated or workspace-mutating', () => {
70
+ const session = {
71
+ mcp: {
72
+ wiki: { url: 'http://wiki:3000/mcp', status: 'connected', tools: [{ name: 'wiki_read_page' }] },
73
+ gated: { url: 'https://g/mcp', status: 'connected', external: true, requireApproval: ['search'], tools: [{ name: 'search' }] },
74
+ documents: { url: 'https://d/mcp', status: 'connected', external: true, tools: [{ name: 'get_document' }] },
75
+ },
76
+ };
77
+ const names = activeProfileMcp(session).map((block) => block.name);
78
+ assert.deepEqual(names, ['wiki']);
79
+ });
80
+
38
81
  test('activeProfileMcp tolerates namespaced tool names', () => {
39
82
  const session = {
40
83
  mcp: {
@@ -243,13 +286,13 @@ test('normalizeTaskError keeps the agent reason as the message, never the fallba
243
286
  // Regression: the fallback describes only WHERE the failure was seen
244
287
  // ("agent_execute rejected task"). Letting it win discarded the one
245
288
  // actionable sentence — and left Donna to invent a cause.
246
- const error = normalizeTaskError("Error: source 'acpi' not found", {
289
+ const error = normalizeTaskError("Error: source 'acme' not found", {
247
290
  fallbackCode: 'execution_rejected',
248
291
  fallbackMessage: 'agent_execute rejected task',
249
292
  });
250
293
 
251
- assert.equal(error.code, "Error: source 'acpi' not found");
252
- assert.equal(error.message, "Error: source 'acpi' not found");
294
+ assert.equal(error.code, "Error: source 'acme' not found");
295
+ assert.equal(error.message, "Error: source 'acme' not found");
253
296
  });
254
297
 
255
298
  test('normalizeTaskError falls back only when the agent reports no reason at all', () => {
@@ -93,7 +93,7 @@ test('execute POSTs /runs and returns the runId', async () => {
93
93
  const provider = createDeepAgentsProvider({ endpoint: 'http://agent-runtime:8080', fetchImpl });
94
94
 
95
95
  const run = await provider.execute({
96
- objective: 'analyse JUNO',
96
+ objective: 'analyze the demo workspace',
97
97
  operation: 'run',
98
98
  arguments: {},
99
99
  model: { baseUrl: 'http://llm:11434/v1', model: 'qwen3:14b', apiKey: 'secret' },
@@ -101,7 +101,7 @@ test('execute POSTs /runs and returns the runId', async () => {
101
101
  assert.deepEqual(run, { runId: 'run-1', status: 'running' });
102
102
  assert.equal(fetchImpl.calls[0].path, '/runs');
103
103
  const sent = JSON.parse(fetchImpl.calls[0].body);
104
- assert.equal(sent.objective, 'analyse JUNO');
104
+ assert.equal(sent.objective, 'analyze the demo workspace');
105
105
  assert.deepEqual(sent.model, { baseUrl: 'http://llm:11434/v1', model: 'qwen3:14b', apiKey: 'secret' });
106
106
  });
107
107