@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.
- package/README.md +24 -21
- package/agents.docker-compose.yml +5 -1
- package/package.json +1 -1
- package/src/activity/activityAggregator.test.js +2 -2
- package/src/agent/graph.js +1 -0
- package/src/cli/wiki-manager.js +1 -1
- package/src/cli/wiki-manager.test.js +16 -16
- package/src/commands/slash.js +24 -5
- package/src/core/agentEvents.js +122 -25
- package/src/core/agentEvents.test.js +26 -1
- package/src/core/buildInfo.json +2 -2
- package/src/core/commandFailure.test.js +2 -2
- package/src/core/currentArtifact.test.js +5 -5
- package/src/core/mcp.js +1 -1
- package/src/core/mcp.test.js +1 -1
- package/src/core/otherWorkspacesRunning.test.js +6 -6
- package/src/core/runtimeLog.js +35 -1
- package/src/core/runtimeLog.test.js +27 -2
- package/src/core/skillInvocation.test.js +1 -1
- package/src/core/wikiSetup.js +25 -0
- package/src/core/wikiSetup.test.js +35 -0
- package/src/core/wikirc.test.js +6 -6
- package/src/core/workspaceInherit.test.js +14 -14
- package/src/orchestrator/agentRegistry.test.js +6 -6
- package/src/orchestrator/dispatcher.js +70 -26
- package/src/orchestrator/dispatcher.test.js +46 -3
- package/src/orchestrator/providers/deepAgentsProvider.test.js +2 -2
- package/src/orchestrator/providers/runtimeProviders.js +58 -5
- package/src/orchestrator/providers/runtimeProviders.test.js +23 -0
- package/src/orchestrator/scheduler.test.js +4 -4
- package/src/runtime/delegation.test.js +11 -11
- package/src/runtime/runner.test.js +1 -1
- package/src/runtime/server.test.js +2 -2
- package/src/runtime/store.test.js +8 -5
- package/src/runtime/workspaceIsolation.test.js +26 -26
- package/src/shell/RightPane.tsx +14 -2
- package/src/shell/repl.js +24 -2
- 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
|
|
30
|
+
const acme = workspaceEntry(root, 'acme');
|
|
31
31
|
|
|
32
|
-
const busy = await otherWorkspacesRunning({ workspace: '
|
|
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, '
|
|
39
|
+
const workspaces = [workspaceEntry(root, 'acme'), workspaceEntry(root, 'stale')];
|
|
40
40
|
|
|
41
|
-
const busy = await otherWorkspacesRunning({ workspace: '
|
|
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: '
|
|
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: '
|
|
50
|
+
assert.deepEqual(await otherWorkspacesRunning({ workspace: 'acme' }, [{}, null]), []);
|
|
51
51
|
});
|
package/src/core/runtimeLog.js
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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-
|
|
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', () => {
|
package/src/core/wikiSetup.js
CHANGED
|
@@ -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
|
+
});
|
package/src/core/wikirc.test.js
CHANGED
|
@@ -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('
|
|
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('
|
|
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', '
|
|
304
|
-
writeFileSync(join(agentsData, 'cme', '
|
|
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('
|
|
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', '
|
|
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', '
|
|
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', '
|
|
153
|
+
writeFileSync(join(root, 'cme', 'acme', 'sources-manifest.yaml'), 'sources: []\n', 'utf8');
|
|
154
154
|
|
|
155
|
-
const copied = await copyCmeCredentials(root, '
|
|
155
|
+
const copied = await copyCmeCredentials(root, 'acme', 'fresh');
|
|
156
156
|
|
|
157
|
-
assert.equal(copied, cmeCredentialsPath(root, '
|
|
157
|
+
assert.equal(copied, cmeCredentialsPath(root, 'fresh'));
|
|
158
158
|
assert.equal(readFileSync(copied, 'utf8'), '{"auth":{"pat":"secret"}}');
|
|
159
|
-
assert.equal(existsSync(join(root, 'cme', '
|
|
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', '
|
|
165
|
-
mkdirSync(join(root, 'cme', '
|
|
166
|
-
writeFileSync(join(root, 'cme', '
|
|
167
|
-
writeFileSync(join(root, 'cme', '
|
|
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, '
|
|
169
|
+
assert.equal(await copyCmeCredentials(root, 'acme', 'fresh'), null);
|
|
170
170
|
assert.equal(
|
|
171
|
-
readFileSync(cmeCredentialsPath(root, '
|
|
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', '
|
|
179
|
-
assert.equal(await copyCmeCredentials(root, null, '
|
|
180
|
-
assert.equal(await copyCmeCredentials(root, '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
296
|
+
assert.deepEqual(seen.cme, { workspace: 'acme' });
|
|
297
297
|
assert.deepEqual(seen.production, {});
|
|
298
|
-
assert.deepEqual(seen.connectors, { workspace: '
|
|
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: '
|
|
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
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
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 (
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
492
|
-
|
|
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 '
|
|
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 '
|
|
252
|
-
assert.equal(error.message, "Error: source '
|
|
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: '
|
|
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, '
|
|
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
|
|