@dotdrelle/wiki-manager 0.14.20 → 0.15.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +28 -0
- package/README.md +137 -11
- package/agents.docker-compose.yml +41 -0
- package/docker-compose.yml +11 -1
- package/mcp.endpoints.example.json +3 -3
- package/package.json +1 -2
- package/src/agent/graph.js +92 -12
- package/src/agent/graph.test.js +108 -6
- package/src/cli/runtimeStartup.test.js +14 -0
- package/src/cli/wiki-manager.js +190 -29
- package/src/cli/wiki-manager.test.js +91 -0
- package/src/commands/slash.js +58 -0
- package/src/commands/slash.test.js +18 -0
- package/src/core/activity.js +1 -1
- package/src/core/activity.test.js +5 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/compose.js +7 -1
- package/src/core/dockerCompose.test.js +27 -3
- package/src/core/env.js +16 -3
- package/src/core/env.test.js +8 -3
- package/src/core/mcp.js +21 -8
- package/src/core/mcp.test.js +55 -0
- package/src/core/wikiSetup.js +26 -2
- package/src/core/wikiWorkspace.test.js +25 -0
- package/src/orchestrator/dispatcher.js +42 -17
- package/src/orchestrator/dispatcher.test.js +127 -0
- package/src/runtime/client.js +23 -0
- package/src/runtime/runner.js +3 -1
- package/src/shell/LeftPane.tsx +20 -1
- package/src/shell/repl.js +134 -36
- package/src/shell/repl.test.js +187 -15
- package/src/shell/useAgent.ts +8 -12
- package/tsconfig.json +2 -1
- package/wiki-workspace +110 -18
- package/agents.docker-compose.mailer.example.yml +0 -36
package/src/core/compose.js
CHANGED
|
@@ -5,7 +5,7 @@ import { promisify } from 'node:util';
|
|
|
5
5
|
import YAML from 'yaml';
|
|
6
6
|
import { cacertEnv, ensureCacertComposeOverride } from './cacert.js';
|
|
7
7
|
import { checkMissingDockerImages } from './dockerImages.js';
|
|
8
|
-
import { managerEnvFile, readEnvFile, resolveAgentsDataDir } from './env.js';
|
|
8
|
+
import { managerEnvFile, managerMcpEndpointsFile, readEnvFile, resolveAgentsDataDir } from './env.js';
|
|
9
9
|
import { managerRoot } from './workspaces.js';
|
|
10
10
|
|
|
11
11
|
const execFileAsync = promisify(execFile);
|
|
@@ -119,6 +119,12 @@ function composeEnv(session) {
|
|
|
119
119
|
AGENTS_DATA_DIR: resolveAgentsDataDir(session),
|
|
120
120
|
WORKSPACE_NAME: session.workspace,
|
|
121
121
|
WIKI_WORKSPACE_PATH: session.workspacePath,
|
|
122
|
+
// The serve container reads external connectors from /mcp.endpoints.json.
|
|
123
|
+
// Mount the SAME file the runtime reads (managerStateDir), not the package
|
|
124
|
+
// dir's copy — otherwise serve and the runtime diverge (different servers,
|
|
125
|
+
// unresolved tokens) depending on where wiki-manager was launched from.
|
|
126
|
+
WIKI_MANAGER_MCP_ENDPOINTS_FILE:
|
|
127
|
+
process.env.WIKI_MANAGER_MCP_ENDPOINTS_FILE ?? managerMcpEndpointsFile(),
|
|
122
128
|
...(session.wikirc?.fileName && { WIKI_CONFIG_PATH: session.wikirc.fileName }),
|
|
123
129
|
};
|
|
124
130
|
}
|
|
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { test } from 'node:test';
|
|
3
3
|
import assert from 'node:assert/strict';
|
|
4
4
|
import YAML from 'yaml';
|
|
5
|
+
import { configuredAgentImages } from './wikiSetup.js';
|
|
5
6
|
|
|
6
7
|
test('workspace compose does not start a per-workspace agent runtime', async () => {
|
|
7
8
|
const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
|
|
@@ -26,10 +27,33 @@ test('agent compose services run as the host uid and gid', async () => {
|
|
|
26
27
|
const agentsCompose = YAML.parse(agentsRaw);
|
|
27
28
|
assert.equal(agentsCompose.services.cme.user, '${UID:-1000}:${GID:-1000}');
|
|
28
29
|
assert.equal(agentsCompose.services.documents.user, '${UID:-1000}:${GID:-1000}');
|
|
30
|
+
assert.equal(agentsCompose.services.connectors.user, '${UID:-1000}:${GID:-1000}');
|
|
31
|
+
assert.deepEqual(agentsCompose.services.connectors.profiles, ['connectors']);
|
|
32
|
+
assert.equal(agentsCompose.services.connectors.environment.includes('GOOGLE_OAUTH_CALLBACK_URL=${GOOGLE_OAUTH_CALLBACK_URL:-}'), true);
|
|
33
|
+
assert.equal(agentsCompose.services.connectors.volumes.includes('${AGENTS_DATA_DIR:-./.agents-data}/connectors:/data'), true);
|
|
29
34
|
assert.equal(agentsCompose.services.documents.environment.includes('DOCUMENT_LLM_BASE_URL=${DOCUMENT_LLM_BASE_URL:-https://albert.api.etalab.gouv.fr/v1}'), true);
|
|
30
35
|
assert.equal(agentsCompose.services.documents.environment.includes('DOCUMENT_LLM_MODEL=${DOCUMENT_LLM_MODEL:-lightonai/LightOnOCR-2-1B}'), true);
|
|
31
36
|
assert.equal(agentsCompose.services.documents.environment.includes('DOCUMENT_LLM_API_KEY=${DOCUMENT_LLM_API_KEY:-}'), true);
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('serve proxies connector OAuth through the host agent endpoint', async () => {
|
|
40
|
+
const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
|
|
41
|
+
const compose = YAML.parse(raw);
|
|
42
|
+
assert.equal(compose.services.serve.environment.includes('CONNECTORS_AGENT_URL=http://host.docker.internal:${CONNECTORS_MCP_PORT:-3338}'), true);
|
|
43
|
+
assert.equal(compose.services.serve.environment.includes('CONNECTORS_OAUTH_START_TOKEN=${OAUTH_START_TOKEN:-}'), true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('missing-image checks ignore agents behind inactive Compose profiles', () => {
|
|
47
|
+
const compose = {
|
|
48
|
+
services: {
|
|
49
|
+
cme: { image: 'example/cme:latest' },
|
|
50
|
+
connectors: { image: 'example/connectors:latest', profiles: ['connectors'] },
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
assert.deepEqual(configuredAgentImages(compose), ['example/cme:latest']);
|
|
55
|
+
assert.deepEqual(
|
|
56
|
+
configuredAgentImages(compose, new Set(['connectors'])),
|
|
57
|
+
['example/cme:latest', 'example/connectors:latest'],
|
|
58
|
+
);
|
|
35
59
|
});
|
package/src/core/env.js
CHANGED
|
@@ -42,8 +42,8 @@ export function managerMcpEndpointsFile() {
|
|
|
42
42
|
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
43
43
|
|
|
44
44
|
// First-run scaffolding: a fresh install directory has neither
|
|
45
|
-
// mcp.endpoints.json nor .env, so the external agents (cme
|
|
46
|
-
//
|
|
45
|
+
// mcp.endpoints.json nor .env, so the external agents (cme and documents)
|
|
46
|
+
// silently never connect — /status shows no agents and Donna has
|
|
47
47
|
// no CME tools to configure anything with. Copy the packaged examples so a
|
|
48
48
|
// fresh directory works out of the box with the default agent ports. Optional
|
|
49
49
|
// credentials can be added later for the external services the user enables.
|
|
@@ -68,10 +68,23 @@ export function ensureManagerScaffold({ log = () => {} } = {}) {
|
|
|
68
68
|
const example = JSON.parse(readFileSync(endpointsExample, 'utf8'));
|
|
69
69
|
if (current && typeof current === 'object' && !Array.isArray(current)) {
|
|
70
70
|
const missing = Object.keys(example).filter((key) => !(key in current));
|
|
71
|
+
const currentServers = current.mcpServers;
|
|
72
|
+
const exampleServers = example.mcpServers;
|
|
73
|
+
const missingServers = currentServers && typeof currentServers === 'object' && !Array.isArray(currentServers)
|
|
74
|
+
&& exampleServers && typeof exampleServers === 'object' && !Array.isArray(exampleServers)
|
|
75
|
+
? Object.keys(exampleServers).filter((key) => !(key in currentServers))
|
|
76
|
+
: [];
|
|
71
77
|
if (missing.length > 0) {
|
|
72
78
|
for (const key of missing) current[key] = example[key];
|
|
79
|
+
}
|
|
80
|
+
for (const key of missingServers) currentServers[key] = exampleServers[key];
|
|
81
|
+
if (missing.length > 0 || missingServers.length > 0) {
|
|
73
82
|
writeFileSync(endpointsFile, `${JSON.stringify(current, null, 2)}\n`);
|
|
74
|
-
|
|
83
|
+
const changes = [
|
|
84
|
+
missing.length > 0 ? `keys: ${missing.join(', ')}` : '',
|
|
85
|
+
missingServers.length > 0 ? `servers: ${missingServers.join(', ')}` : '',
|
|
86
|
+
].filter(Boolean).join('; ');
|
|
87
|
+
created.push(`mcp.endpoints.json ${changes}`);
|
|
75
88
|
}
|
|
76
89
|
}
|
|
77
90
|
} catch {
|
package/src/core/env.test.js
CHANGED
|
@@ -45,9 +45,14 @@ test('scaffold merges missing top-level keys into an existing endpoints file', (
|
|
|
45
45
|
const created = ensureManagerScaffold();
|
|
46
46
|
assert.ok(created.some((item) => item.includes('chatAccess')));
|
|
47
47
|
const merged = JSON.parse(readFileSync(endpointsFile, 'utf8'));
|
|
48
|
-
// Operator's
|
|
49
|
-
|
|
50
|
-
assert.
|
|
48
|
+
// Operator's server is untouched; packaged servers are added without
|
|
49
|
+
// replacing any existing definitions.
|
|
50
|
+
assert.deepEqual(merged.mcpServers.custom, { url: 'http://localhost:9999/mcp/' });
|
|
51
|
+
assert.ok(merged.mcpServers.cme);
|
|
52
|
+
assert.ok(merged.mcpServers.documents);
|
|
53
|
+
// Server keys must match the connected MCP endpoint keys (the tool-call
|
|
54
|
+
// prefix): the wiki server is "llm-wiki", not "wiki".
|
|
55
|
+
assert.ok(merged.chatAccess?.servers?.['llm-wiki']);
|
|
51
56
|
});
|
|
52
57
|
});
|
|
53
58
|
|
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.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.15.25';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
|
@@ -64,11 +64,18 @@ function normalizeExternalUrlForRuntime(url) {
|
|
|
64
64
|
return url;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
// Config-driven policy for the /chat
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
// maxToolIterations budget.
|
|
71
|
-
// when not configured — then
|
|
67
|
+
// Config-driven policy for the /chat toolset — NOT /agent, which has the full
|
|
68
|
+
// toolset and ignores this. The endpoints file's "chatAccess" block declares,
|
|
69
|
+
// per server, which tools /chat may call ("*" or a list), plus a
|
|
70
|
+
// maxToolIterations budget. Every server uses the SAME shape: one `allow` key.
|
|
71
|
+
// Operator-owned, agnostic allow-list. Returns null when not configured — then
|
|
72
|
+
// /chat stays a plain, tool-less conversation.
|
|
73
|
+
//
|
|
74
|
+
// `allowActions` is a legacy key from the connectors work: it carved out a
|
|
75
|
+
// second list for tools the read-verb heuristic rejected, which made one
|
|
76
|
+
// server's entry shaped differently from every other. It is folded into
|
|
77
|
+
// `allow` on read so existing installs keep working without regenerating
|
|
78
|
+
// their endpoints file, but nothing writes it any more.
|
|
72
79
|
export function readChatAccessConfig() {
|
|
73
80
|
const filePath = managerMcpEndpointsFile();
|
|
74
81
|
if (!existsSync(filePath)) return null;
|
|
@@ -81,10 +88,15 @@ export function readChatAccessConfig() {
|
|
|
81
88
|
// "*" is also commonly written as a one-element array (["*"]) since every
|
|
82
89
|
// other "allow" example in this config is an array of tool names — treat
|
|
83
90
|
// both forms as the same wildcard rather than silently allowing nothing.
|
|
91
|
+
const legacyActions = Array.isArray(entry?.allowActions)
|
|
92
|
+
? entry.allowActions.map(String).filter(Boolean)
|
|
93
|
+
: [];
|
|
84
94
|
if (entry?.allow === '*' || (Array.isArray(entry?.allow) && entry.allow.length === 1 && entry.allow[0] === '*')) {
|
|
85
95
|
servers[name] = { allow: '*' };
|
|
86
96
|
} else if (Array.isArray(entry?.allow)) {
|
|
87
|
-
servers[name] = { allow: entry.allow.map(String).filter(Boolean) };
|
|
97
|
+
servers[name] = { allow: [...new Set([...entry.allow.map(String).filter(Boolean), ...legacyActions])] };
|
|
98
|
+
} else if (legacyActions.length > 0) {
|
|
99
|
+
servers[name] = { allow: legacyActions };
|
|
88
100
|
}
|
|
89
101
|
}
|
|
90
102
|
const maxToolIterations = Number.isFinite(Number(chatAccess.maxToolIterations)) && Number(chatAccess.maxToolIterations) > 0
|
|
@@ -284,7 +296,7 @@ async function mcpRequest(endpoint, method, params, signal, options = {}) {
|
|
|
284
296
|
let response = await doRequest(method, params);
|
|
285
297
|
let text = await response.text();
|
|
286
298
|
|
|
287
|
-
if (response.status === 400 && /session
|
|
299
|
+
if (response.status === 400 && /session(?:\s+ID)?/i.test(text)) {
|
|
288
300
|
endpoint._sessionId = null;
|
|
289
301
|
const initResponse = await fetch(endpoint.url, {
|
|
290
302
|
method: 'POST',
|
|
@@ -532,6 +544,7 @@ export async function discoverMcpTools(mcpStatus) {
|
|
|
532
544
|
name: tool.name,
|
|
533
545
|
description: tool.description ?? '',
|
|
534
546
|
inputSchema: tool.inputSchema,
|
|
547
|
+
annotations: tool.annotations,
|
|
535
548
|
})),
|
|
536
549
|
toolError: null,
|
|
537
550
|
};
|
package/src/core/mcp.test.js
CHANGED
|
@@ -523,6 +523,61 @@ test('discoverMcpTools downgrades connected endpoint when tool discovery fails',
|
|
|
523
523
|
}
|
|
524
524
|
});
|
|
525
525
|
|
|
526
|
+
test('discoverMcpTools initializes SDK servers that report no valid session', async () => {
|
|
527
|
+
const originalFetch = globalThis.fetch;
|
|
528
|
+
const methods = [];
|
|
529
|
+
globalThis.fetch = async (_url, init) => {
|
|
530
|
+
const body = JSON.parse(init.body);
|
|
531
|
+
methods.push(body.method);
|
|
532
|
+
if (body.method === 'tools/list' && !init.headers['mcp-session-id']) {
|
|
533
|
+
return {
|
|
534
|
+
ok: false,
|
|
535
|
+
status: 400,
|
|
536
|
+
headers: { get: () => null },
|
|
537
|
+
text: async () => '{"jsonrpc":"2.0","error":{"code":-32000,"message":"No valid session"},"id":null}',
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
if (body.method === 'initialize') {
|
|
541
|
+
return {
|
|
542
|
+
ok: true,
|
|
543
|
+
status: 200,
|
|
544
|
+
headers: { get: (name) => name === 'mcp-session-id' ? 'session-1' : null },
|
|
545
|
+
text: async () => '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-06-18"}}',
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
if (body.method === 'notifications/initialized') {
|
|
549
|
+
return {
|
|
550
|
+
ok: true,
|
|
551
|
+
status: 202,
|
|
552
|
+
headers: { get: () => null },
|
|
553
|
+
text: async () => '',
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
return {
|
|
557
|
+
ok: true,
|
|
558
|
+
status: 200,
|
|
559
|
+
headers: { get: () => null },
|
|
560
|
+
text: async () => '{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"connectors_google_status"}]}}',
|
|
561
|
+
};
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
try {
|
|
565
|
+
const status = await discoverMcpTools({
|
|
566
|
+
connectors: {
|
|
567
|
+
status: 'configured',
|
|
568
|
+
url: 'http://127.0.0.1:3338/mcp/',
|
|
569
|
+
headers: { authorization: 'Bearer token' },
|
|
570
|
+
},
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
assert.equal(status.connectors.status, 'connected');
|
|
574
|
+
assert.deepEqual(status.connectors.tools.map((tool) => tool.name), ['connectors_google_status']);
|
|
575
|
+
assert.deepEqual(methods, ['tools/list', 'initialize', 'notifications/initialized', 'tools/list']);
|
|
576
|
+
} finally {
|
|
577
|
+
globalThis.fetch = originalFetch;
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
|
|
526
581
|
test('callMcpTool parses SSE responses after keepalive comments', async () => {
|
|
527
582
|
const originalFetch = globalThis.fetch;
|
|
528
583
|
globalThis.fetch = async () => ({
|
package/src/core/wikiSetup.js
CHANGED
|
@@ -6,12 +6,36 @@ 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, resolveAgentsDataDir } from './env.js';
|
|
9
|
+
import { managerEnvFile, managerMcpEndpointsFile, readEnvFile, resolveAgentsDataDir } from './env.js';
|
|
10
10
|
import { createWorkspace, findWorkspace, isValidWorkspaceName, listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
|
|
11
11
|
|
|
12
12
|
const execFileAsync = promisify(execFile);
|
|
13
13
|
|
|
14
|
+
function enabled(value) {
|
|
15
|
+
return /^(?:1|true|yes|on)$/i.test(String(value ?? '').trim());
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function configuredAgentImages(config, activeProfiles = new Set()) {
|
|
19
|
+
return Object.values(config.services ?? {})
|
|
20
|
+
.filter((service) => {
|
|
21
|
+
const profiles = Array.isArray(service?.profiles) ? service.profiles : [];
|
|
22
|
+
return profiles.length === 0 || profiles.some((profile) => activeProfiles.has(profile));
|
|
23
|
+
})
|
|
24
|
+
.map((service) => service?.image)
|
|
25
|
+
.filter(Boolean);
|
|
26
|
+
}
|
|
27
|
+
|
|
14
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
|
+
}
|
|
15
39
|
const composeFiles = [
|
|
16
40
|
join(managerRoot(), 'agents.docker-compose.yml'),
|
|
17
41
|
join(dirname(managerEnvFile()), 'agents.docker-compose.override.yml'),
|
|
@@ -19,7 +43,7 @@ async function missingAgentImages() {
|
|
|
19
43
|
const images = [...new Set(composeFiles.flatMap((filePath) => {
|
|
20
44
|
try {
|
|
21
45
|
const config = YAML.parse(readFileSync(filePath, 'utf8')) ?? {};
|
|
22
|
-
return
|
|
46
|
+
return configuredAgentImages(config, activeProfiles);
|
|
23
47
|
} catch {
|
|
24
48
|
return [];
|
|
25
49
|
}
|
|
@@ -12,6 +12,11 @@ test('wiki-workspace autostarts host runtime before workspace services', async (
|
|
|
12
12
|
assert.match(script, /start_workspace_services "\$workspace"\n\n local serve_port prod_port/);
|
|
13
13
|
assert.match(script, /start_workspace_services "\$workspace"\n local serve_port production_port/);
|
|
14
14
|
assert.match(script, /ensure_runtime_up\n printf 'Starting mcp-http/);
|
|
15
|
+
assert.match(script, /wait_for_runtime_health\(\) \{/);
|
|
16
|
+
assert.match(script, /if ! wait_for_runtime_health; then/);
|
|
17
|
+
assert.match(script, /runtime_port\(\) \{\n env_value "\$MANAGER_ENV_FILE" WIKI_MANAGER_RUNTIME_PORT/);
|
|
18
|
+
assert.match(script, /port="\$\(runtime_port\)"/);
|
|
19
|
+
assert.doesNotMatch(script, /sleep 0\.5\n printf 'agent-runtime started/);
|
|
15
20
|
});
|
|
16
21
|
|
|
17
22
|
test('wiki-workspace checks runtime pid command before killing', async () => {
|
|
@@ -32,6 +37,26 @@ test('wiki-workspace regenerates CA compose overrides instead of retaining remov
|
|
|
32
37
|
assert.match(script, /Changes are overwritten on the next compose command/);
|
|
33
38
|
});
|
|
34
39
|
|
|
40
|
+
test('wiki-workspace provisions connector secrets and persistent state', async () => {
|
|
41
|
+
const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
|
|
42
|
+
|
|
43
|
+
assert.match(script, /connectors_enabled\(\) \{/);
|
|
44
|
+
assert.match(script, /CONNECTORS_ENABLED/);
|
|
45
|
+
assert.doesNotMatch(script, /\$\{enabled,,\}/);
|
|
46
|
+
assert.match(script, /\[Tt\]\[Rr\]\[Uu\]\[Ee\]/);
|
|
47
|
+
assert.match(script, /profile_args\+=\(--profile connectors\)/);
|
|
48
|
+
assert.match(script, /COMPOSE_PROFILES= \\\n docker compose/);
|
|
49
|
+
assert.match(script, /if connectors_enabled; then/);
|
|
50
|
+
assert.match(script, /CONNECTORS_MCP_AUTH_TOKEN OAUTH_STATE_SECRET OAUTH_START_TOKEN/);
|
|
51
|
+
assert.match(script, /http:\/\/127\.0\.0\.1:\$\{connectors_port\}\/oauth\/google\/callback/);
|
|
52
|
+
assert.match(script, /set_env_value "\$MANAGER_ENV_FILE" GOOGLE_OAUTH_CALLBACK_URL/);
|
|
53
|
+
assert.match(script, /"\$agents_data_dir\/connectors"/);
|
|
54
|
+
assert.match(script, /config\.mcpServers\.connectors \?\?=/);
|
|
55
|
+
assert.match(script, /config\.chatAccess\.servers\.connectors \?\?=/);
|
|
56
|
+
assert.match(script, /delete config\.mcpServers\.connectors/);
|
|
57
|
+
assert.match(script, /delete config\.chatAccess\.servers\.connectors/);
|
|
58
|
+
});
|
|
59
|
+
|
|
35
60
|
test('workspace creation keeps mutable manager files outside the installed package', async () => {
|
|
36
61
|
const source = await readFile(new URL('./workspaces.js', import.meta.url), 'utf8');
|
|
37
62
|
|
|
@@ -3,7 +3,7 @@ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
|
3
3
|
import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
|
|
4
4
|
import { emitRuntimeLog, pollActivitiesOnce } from '../runtime/supervisor.js';
|
|
5
5
|
|
|
6
|
-
const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'complete', 'completed', 'success', 'error']);
|
|
6
|
+
const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'complete', 'completed', 'success', 'succeeded', 'error']);
|
|
7
7
|
|
|
8
8
|
export function createDispatcher({
|
|
9
9
|
session = null,
|
|
@@ -115,7 +115,7 @@ export async function execute(task, assignment, {
|
|
|
115
115
|
jobId,
|
|
116
116
|
status: lastStatus?.result?.status ?? lastStatus?.status,
|
|
117
117
|
outputs: lastStatus?.result?.outputRefs ?? [],
|
|
118
|
-
error: lastStatus?.result?.error?.code ??
|
|
118
|
+
error: normalizeTaskError(lastStatus?.result?.error)?.code ?? null,
|
|
119
119
|
detail: 'terminal status',
|
|
120
120
|
}));
|
|
121
121
|
return taskResultFromStatus(task, assignment, jobId, lastStatus, attempt);
|
|
@@ -135,7 +135,7 @@ export async function execute(task, assignment, {
|
|
|
135
135
|
jobId,
|
|
136
136
|
status: lastStatus?.result?.status ?? lastStatus?.status,
|
|
137
137
|
outputs: lastStatus?.result?.outputRefs ?? [],
|
|
138
|
-
error: lastStatus?.result?.error?.code ??
|
|
138
|
+
error: normalizeTaskError(lastStatus?.result?.error)?.code ?? null,
|
|
139
139
|
detail: 'terminal status',
|
|
140
140
|
}));
|
|
141
141
|
return taskResultFromStatus(task, assignment, jobId, lastStatus, attempt);
|
|
@@ -213,20 +213,49 @@ function taskResultFromStatus(task, assignment, jobId, statusPayload, attempt =
|
|
|
213
213
|
status: result.status ?? statusPayload?.status,
|
|
214
214
|
outputRefs: Array.isArray(result.outputRefs) ? result.outputRefs : [],
|
|
215
215
|
metrics: result.metrics ?? {},
|
|
216
|
-
error: result.error
|
|
216
|
+
error: normalizeTaskError(result.error),
|
|
217
217
|
rawStatus: statusPayload,
|
|
218
218
|
};
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
// An agent may report a terminal failure either as an object ({code, message})
|
|
222
|
+
// or as a bare reason string — the contract mandates neither, and the
|
|
223
|
+
// executor-only agents report the latter. Consumers (runner logs, the UIs)
|
|
224
|
+
// only ever read `.code`/`.message`, so an unnormalized string silently
|
|
225
|
+
// became `null` and every failure surfaced as a bare "failed" with no reason.
|
|
226
|
+
// Normalize once, here, so the shape is uniform for every downstream reader.
|
|
227
|
+
export function normalizeTaskError(rawError, { fallbackCode = null, fallbackMessage = null } = {}) {
|
|
228
|
+
if (rawError && typeof rawError === 'object') {
|
|
229
|
+
const code = String(rawError.code ?? rawError.message ?? fallbackCode ?? 'failed');
|
|
230
|
+
return {
|
|
231
|
+
code,
|
|
232
|
+
message: String(rawError.message ?? rawError.code ?? fallbackMessage ?? code),
|
|
233
|
+
retryable: rawError.retryable === true
|
|
234
|
+
|| transientError(rawError.code)
|
|
235
|
+
|| transientError(rawError.message),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
if (rawError === null || rawError === undefined || String(rawError).trim() === '') {
|
|
239
|
+
if (!fallbackCode) return null;
|
|
240
|
+
return {
|
|
241
|
+
code: String(fallbackCode),
|
|
242
|
+
message: String(fallbackMessage ?? fallbackCode),
|
|
243
|
+
retryable: transientError(fallbackCode),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const code = String(rawError);
|
|
247
|
+
return {
|
|
248
|
+
code,
|
|
249
|
+
message: String(fallbackMessage ?? code),
|
|
250
|
+
retryable: transientError(code),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
221
254
|
function rejectedTaskResult(task, assignment, payload, attempt = null) {
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
code: String(rawError ?? 'execution_rejected'),
|
|
227
|
-
message: String(payload?.message ?? rawError ?? 'agent_execute rejected task'),
|
|
228
|
-
retryable: transientError(rawError ?? payload?.message),
|
|
229
|
-
};
|
|
255
|
+
const error = normalizeTaskError(payload?.error, {
|
|
256
|
+
fallbackCode: 'execution_rejected',
|
|
257
|
+
fallbackMessage: payload?.message ?? 'agent_execute rejected task',
|
|
258
|
+
});
|
|
230
259
|
return {
|
|
231
260
|
ok: false,
|
|
232
261
|
taskId: String(task.id ?? task.step),
|
|
@@ -236,11 +265,7 @@ function rejectedTaskResult(task, assignment, payload, attempt = null) {
|
|
|
236
265
|
status: 'failed',
|
|
237
266
|
outputRefs: [],
|
|
238
267
|
metrics: {},
|
|
239
|
-
error
|
|
240
|
-
code: String(error.code ?? 'execution_rejected'),
|
|
241
|
-
message: String(error.message ?? error.code ?? 'agent_execute rejected task'),
|
|
242
|
-
retryable: error.retryable === true || transientError(error.code) || transientError(error.message),
|
|
243
|
-
},
|
|
268
|
+
error,
|
|
244
269
|
rawStatus: payload,
|
|
245
270
|
};
|
|
246
271
|
}
|
|
@@ -32,3 +32,130 @@ test('dispatcher returns a retryable logical failure when agent_execute reports
|
|
|
32
32
|
assert.equal(result.error.code, 'workspace_busy');
|
|
33
33
|
assert.equal(result.error.retryable, true);
|
|
34
34
|
});
|
|
35
|
+
|
|
36
|
+
test('dispatcher completes when an executor-only agent reports succeeded', async () => {
|
|
37
|
+
const session = {
|
|
38
|
+
workspace: 'test',
|
|
39
|
+
mcp: {
|
|
40
|
+
connectors: {
|
|
41
|
+
status: 'connected',
|
|
42
|
+
tools: [
|
|
43
|
+
{ name: 'agent_execute' },
|
|
44
|
+
{ name: 'agent_status' },
|
|
45
|
+
{ name: 'agent_cancel' },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
activities: {},
|
|
50
|
+
};
|
|
51
|
+
const dispatcher = createDispatcher({
|
|
52
|
+
session,
|
|
53
|
+
pollIntervalMs: 1,
|
|
54
|
+
callTool: async (_mcp, _server, tool) => {
|
|
55
|
+
if (tool === 'agent_execute') {
|
|
56
|
+
return { accepted: true, jobId: 'job-connectors', status: 'queued' };
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
jobId: 'job-connectors',
|
|
60
|
+
status: 'succeeded',
|
|
61
|
+
terminal: true,
|
|
62
|
+
result: { status: 'succeeded', outputRefs: [] },
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const result = await dispatcher.execute(
|
|
68
|
+
{
|
|
69
|
+
id: 'collect-mail',
|
|
70
|
+
label: 'Collect mail',
|
|
71
|
+
requiredCapability: 'external-source.collect',
|
|
72
|
+
operation: 'collect',
|
|
73
|
+
arguments: {},
|
|
74
|
+
},
|
|
75
|
+
{ serverName: 'connectors', agentInstanceId: 'connectors' },
|
|
76
|
+
{ attempt: { attemptId: 'collect-mail:attempt-1', locks: [], release() {} } },
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
assert.equal(result.ok, true);
|
|
80
|
+
assert.equal(result.status, 'succeeded');
|
|
81
|
+
assert.equal(result.jobId, 'job-connectors');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('dispatcher normalizes a bare string error reported on a terminal agent_status', async () => {
|
|
85
|
+
const session = {
|
|
86
|
+
workspace: 'test',
|
|
87
|
+
mcp: {
|
|
88
|
+
connectors: {
|
|
89
|
+
status: 'connected',
|
|
90
|
+
tools: [
|
|
91
|
+
{ name: 'agent_execute' },
|
|
92
|
+
{ name: 'agent_status' },
|
|
93
|
+
{ name: 'agent_cancel' },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
activities: {},
|
|
98
|
+
};
|
|
99
|
+
const dispatcher = createDispatcher({
|
|
100
|
+
session,
|
|
101
|
+
pollIntervalMs: 1,
|
|
102
|
+
callTool: async (_mcp, _server, tool) => {
|
|
103
|
+
if (tool === 'agent_execute') {
|
|
104
|
+
return { accepted: true, jobId: 'job-connectors', status: 'queued' };
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
jobId: 'job-connectors',
|
|
108
|
+
status: 'failed',
|
|
109
|
+
terminal: true,
|
|
110
|
+
// Executor-only agents report the reason as a bare string, which the
|
|
111
|
+
// contract permits; it must not be swallowed into a bare "failed".
|
|
112
|
+
result: { status: 'failed', error: 'authentication_required' },
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const result = await dispatcher.execute(
|
|
118
|
+
{
|
|
119
|
+
id: 'collect-mail',
|
|
120
|
+
requiredCapability: 'external-source.collect',
|
|
121
|
+
operation: 'collect',
|
|
122
|
+
arguments: {},
|
|
123
|
+
},
|
|
124
|
+
{ serverName: 'connectors', agentInstanceId: 'connectors' },
|
|
125
|
+
{ attempt: { attemptId: 'collect-mail:attempt-1', locks: [], release() {} } },
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
assert.equal(result.ok, false);
|
|
129
|
+
assert.equal(result.error.code, 'authentication_required');
|
|
130
|
+
assert.equal(result.error.message, 'authentication_required');
|
|
131
|
+
assert.equal(result.error.retryable, false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('dispatcher keeps a null error when a terminal status reports none', async () => {
|
|
135
|
+
const session = {
|
|
136
|
+
workspace: 'test',
|
|
137
|
+
mcp: {
|
|
138
|
+
connectors: {
|
|
139
|
+
status: 'connected',
|
|
140
|
+
tools: [{ name: 'agent_execute' }, { name: 'agent_status' }, { name: 'agent_cancel' }],
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
activities: {},
|
|
144
|
+
};
|
|
145
|
+
const dispatcher = createDispatcher({
|
|
146
|
+
session,
|
|
147
|
+
pollIntervalMs: 1,
|
|
148
|
+
callTool: async (_mcp, _server, tool) => (tool === 'agent_execute'
|
|
149
|
+
? { accepted: true, jobId: 'job-connectors', status: 'queued' }
|
|
150
|
+
: { jobId: 'job-connectors', status: 'succeeded', terminal: true, result: { status: 'succeeded' } }),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const result = await dispatcher.execute(
|
|
154
|
+
{ id: 'collect-mail', requiredCapability: 'external-source.collect', operation: 'collect', arguments: {} },
|
|
155
|
+
{ serverName: 'connectors', agentInstanceId: 'connectors' },
|
|
156
|
+
{ attempt: { attemptId: 'collect-mail:attempt-1', locks: [], release() {} } },
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
assert.equal(result.ok, true);
|
|
160
|
+
assert.equal(result.error, null);
|
|
161
|
+
});
|
package/src/runtime/client.js
CHANGED
|
@@ -72,6 +72,29 @@ export async function postRuntimeRun(input, {
|
|
|
72
72
|
return response.json();
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
export async function postRuntimeTurn(input, {
|
|
76
|
+
url = runtimeUrlFromEnv(),
|
|
77
|
+
token = runtimeToken(),
|
|
78
|
+
workspace = null,
|
|
79
|
+
mode = 'agent',
|
|
80
|
+
} = {}) {
|
|
81
|
+
const response = await fetch(runtimeEndpoint(url, '/turn', workspace), {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: {
|
|
84
|
+
...runtimeHeaders(token),
|
|
85
|
+
'Content-Type': 'application/json',
|
|
86
|
+
},
|
|
87
|
+
body: JSON.stringify({ input, workspace, mode }),
|
|
88
|
+
});
|
|
89
|
+
const payload = await response.json().catch(() => ({}));
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
const err = new Error(payload.error ?? `Runtime turn failed: HTTP ${response.status}`);
|
|
92
|
+
err.status = response.status;
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
return payload;
|
|
96
|
+
}
|
|
97
|
+
|
|
75
98
|
export async function postRuntimeDelegate(objective, {
|
|
76
99
|
url = runtimeUrlFromEnv(),
|
|
77
100
|
token = runtimeToken(),
|
package/src/runtime/runner.js
CHANGED
|
@@ -841,7 +841,9 @@ async function runDispatchedTask(task, {
|
|
|
841
841
|
assignment,
|
|
842
842
|
jobId: result?.jobId,
|
|
843
843
|
error: result?.error?.code ?? result?.error?.message ?? result?.status ?? 'failed',
|
|
844
|
-
|
|
844
|
+
// Carry the agent's own reason instead of a constant: "task terminal"
|
|
845
|
+
// told the operator nothing about WHY the task ended.
|
|
846
|
+
detail: result?.error?.message ?? result?.error?.code ?? 'task terminal',
|
|
845
847
|
}));
|
|
846
848
|
return { ok: false, taskId, result, assignment };
|
|
847
849
|
}
|
package/src/shell/LeftPane.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import { For, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
|
|
3
3
|
import { StyledText, bg as styledBg, fg as styledFg, link as styledLink } from '@opentui/core';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
4
5
|
import { colorForRenderedLine, helpCommandParts, keyValueParts, renderPlainMarkdown } from './renderer';
|
|
5
6
|
import { httpLinkParts, wrapHttpLinks } from './externalLinks.js';
|
|
6
7
|
|
|
@@ -60,6 +61,19 @@ function styledSegments(segments: Segment[]) {
|
|
|
60
61
|
return chunk;
|
|
61
62
|
}));
|
|
62
63
|
}
|
|
64
|
+
|
|
65
|
+
function openSingleLineLink(segments: Segment[]) {
|
|
66
|
+
const urls = [...new Set(segments.map((segment) => segment.url).filter(Boolean))] as string[];
|
|
67
|
+
if (urls.length !== 1) return;
|
|
68
|
+
try {
|
|
69
|
+
const parsed = new URL(urls[0]);
|
|
70
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return;
|
|
71
|
+
const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
72
|
+
execFile(opener, [parsed.toString()], () => {});
|
|
73
|
+
} catch {
|
|
74
|
+
// Invalid URLs remain copyable text and never reach the system opener.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
63
77
|
type RenderedLine = {
|
|
64
78
|
segments: Segment[];
|
|
65
79
|
role?: string;
|
|
@@ -549,7 +563,12 @@ export function ConversationView(props: {
|
|
|
549
563
|
) : null}
|
|
550
564
|
</box>
|
|
551
565
|
) : (
|
|
552
|
-
<box
|
|
566
|
+
<box
|
|
567
|
+
height={1}
|
|
568
|
+
flexDirection="row"
|
|
569
|
+
overflow="hidden"
|
|
570
|
+
onMouseUp={() => openSingleLineLink(line.segments)}
|
|
571
|
+
>
|
|
553
572
|
<text>{styledSegments(line.segments)}</text>
|
|
554
573
|
</box>
|
|
555
574
|
)
|