@dotdrelle/wiki-manager 0.14.23 → 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/README.md +44 -4
- package/package.json +1 -1
- package/src/agent/graph.js +1 -1
- package/src/core/buildInfo.json +1 -1
- package/src/core/mcp.js +19 -11
- package/src/orchestrator/dispatcher.js +41 -16
- package/src/orchestrator/dispatcher.test.js +79 -0
- package/src/runtime/runner.js +3 -1
- package/src/shell/repl.js +52 -31
- package/src/shell/repl.test.js +104 -20
- package/wiki-workspace +5 -5
package/README.md
CHANGED
|
@@ -517,6 +517,45 @@ process environment (including the `.env` loaded at startup):
|
|
|
517
517
|
Copy `mcp.endpoints.example.json` to `mcp.endpoints.json` and set the matching
|
|
518
518
|
token variables in `.env`.
|
|
519
519
|
|
|
520
|
+
### `chatAccess`: which tools chat may use
|
|
521
|
+
|
|
522
|
+
Declaring a server above makes its tools available to **`/agent`** — no further
|
|
523
|
+
declaration, ever. Plug in a new MCP and Donna discovers and uses its tools
|
|
524
|
+
immediately.
|
|
525
|
+
|
|
526
|
+
The optional `chatAccess` block is the authorization layer for **`/chat`**,
|
|
527
|
+
which is closed by default. It decides which tools chat may use, and nothing
|
|
528
|
+
else:
|
|
529
|
+
|
|
530
|
+
```json
|
|
531
|
+
"chatAccess": {
|
|
532
|
+
"maxToolIterations": 8,
|
|
533
|
+
"servers": {
|
|
534
|
+
"cme": { "allow": ["cme_status", "cme_sources_list", "cme_export_status"] },
|
|
535
|
+
"exa": { "allow": ["*"] }
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
| entry | effect in `/chat` |
|
|
541
|
+
| --- | --- |
|
|
542
|
+
| server absent | none of its tools — agent-only |
|
|
543
|
+
| `"allow": ["*"]` | every tool the server exposes |
|
|
544
|
+
| `"allow": [names]` | exactly those tools |
|
|
545
|
+
|
|
546
|
+
Every server uses this one shape. The list is authoritative: naming a tool is
|
|
547
|
+
the decision, whatever the tool is called. No name heuristic filters it — a
|
|
548
|
+
tool name is not a contract, and a third-party MCP is free to name its tools
|
|
549
|
+
however it likes.
|
|
550
|
+
|
|
551
|
+
`/chat` carries no plan: it performs direct unitary actions only. The
|
|
552
|
+
orchestration entry points (`agent_plan`, `agent_execute`,
|
|
553
|
+
`production_start_job`, and plan mutation) are therefore never offered to it,
|
|
554
|
+
including under `"*"`. Multi-step work belongs to `/agent`.
|
|
555
|
+
|
|
556
|
+
An `allowActions` key written by an older manager is folded into `allow` on
|
|
557
|
+
read and removed on the next `agents up`.
|
|
558
|
+
|
|
520
559
|
MCP `tools/call` requests retry transient HTTP/MCP failures before the run fails.
|
|
521
560
|
They also share a per-endpoint outbound control budget (45 RPM by default,
|
|
522
561
|
configurable with `WIKI_MANAGER_MCP_REQUESTS_PER_MINUTE`). This budget is
|
|
@@ -642,10 +681,11 @@ start/state secrets when missing. It adds a regular, standard MCP `connectors`
|
|
|
642
681
|
entry to `mcp.endpoints.json`. Setting `CONNECTORS_ENABLED=false` and running
|
|
643
682
|
`agents up` removes that entry again, so disabled services are not probed. No
|
|
644
683
|
non-standard `enabled` property is written to MCP configuration files.
|
|
645
|
-
The matching `chatAccess.connectors` policy is managed at the same time
|
|
646
|
-
|
|
647
|
-
`
|
|
648
|
-
|
|
684
|
+
The matching `chatAccess.connectors` policy is managed at the same time, in the
|
|
685
|
+
same shape as every other server — a single `allow` list holding
|
|
686
|
+
`connectors_google_status` and `connectors_google_oauth_start`, so chat can
|
|
687
|
+
report the authorization state and start it. See
|
|
688
|
+
[`chatAccess`](#chataccess-which-tools-chat-may-use).
|
|
649
689
|
|
|
650
690
|
Connector authorization is also available without asking the LLM. These two
|
|
651
691
|
commands work in both the Shell UI and the `llm-wiki serve` chat:
|
package/package.json
CHANGED
package/src/agent/graph.js
CHANGED
|
@@ -1083,7 +1083,7 @@ export function isDonnaReadTool(item) {
|
|
|
1083
1083
|
// production agent) is delegated for its DAG/parallelism; plain single-step
|
|
1084
1084
|
// tools are called directly. This is a blocklist, not a whitelist, so adding a
|
|
1085
1085
|
// new MCP never silently disables its tools.
|
|
1086
|
-
function isOrchestrationBypassTool(name) {
|
|
1086
|
+
export function isOrchestrationBypassTool(name) {
|
|
1087
1087
|
const full = String(name ?? '');
|
|
1088
1088
|
if (!full) return true;
|
|
1089
1089
|
if (full === 'wiki__plan_set' || full === 'wiki__plan_done') return true;
|
package/src/core/buildInfo.json
CHANGED
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,14 +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) };
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
servers[name] ??= { allow: [] };
|
|
91
|
-
servers[name].allowActions = entry.allowActions.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 };
|
|
92
100
|
}
|
|
93
101
|
}
|
|
94
102
|
const maxToolIterations = Number.isFinite(Number(chatAccess.maxToolIterations)) && Number(chatAccess.maxToolIterations) > 0
|
|
@@ -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
|
}
|
|
@@ -80,3 +80,82 @@ test('dispatcher completes when an executor-only agent reports succeeded', async
|
|
|
80
80
|
assert.equal(result.status, 'succeeded');
|
|
81
81
|
assert.equal(result.jobId, 'job-connectors');
|
|
82
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/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/repl.js
CHANGED
|
@@ -7,7 +7,7 @@ import path from 'node:path';
|
|
|
7
7
|
import { stdin as input, stdout as output } from 'node:process';
|
|
8
8
|
import { marked } from 'marked';
|
|
9
9
|
import { markedTerminal } from 'marked-terminal';
|
|
10
|
-
import { buildAgentSystemPrompt, formatLlmUnavailableMessage,
|
|
10
|
+
import { buildAgentSystemPrompt, formatLlmUnavailableMessage, isOrchestrationBypassTool } from '../agent/graph.js';
|
|
11
11
|
import { handleSlashCommand } from '../commands/slash.js';
|
|
12
12
|
import { serviceDescription, serviceNames as composeServiceNames } from '../core/compose.js';
|
|
13
13
|
import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
|
|
@@ -291,24 +291,43 @@ function isDonnaRole(role) {
|
|
|
291
291
|
return role === 'donna' || role === LEGACY_DONNA_ROLE;
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
|
|
294
|
+
// MCP tools exposed to /chat. `chatAccess` (mcp.endpoints.json) is a per-mode
|
|
295
|
+
// authorization layer deciding which tools /chat may use — nothing else. It is
|
|
296
|
+
// the operator's declaration and it is AUTHORITATIVE:
|
|
297
|
+
//
|
|
298
|
+
// - server absent → none of its tools exist in /chat. Closed by default.
|
|
299
|
+
// - "allow": ["*"] → every tool the server exposes.
|
|
300
|
+
// - "allow": [names] → exactly those. Taking the time to name a tool IS the
|
|
301
|
+
// decision, whatever the name looks like.
|
|
302
|
+
//
|
|
303
|
+
// /agent is unaffected: it uses every discovered, connected server with no
|
|
304
|
+
// chatAccess declaration at all, so plugging in a new MCP makes it work with
|
|
305
|
+
// Donna immediately.
|
|
306
|
+
//
|
|
307
|
+
// This used to be double-gated by a read-verb name heuristic (isDonnaReadTool),
|
|
308
|
+
// which silently dropped an explicitly allow-listed tool that did not look
|
|
309
|
+
// read-only — the reason a second `allowActions` key had to be invented for
|
|
310
|
+
// connectors_google_oauth_start. A tool name is not a contract: the heuristic
|
|
311
|
+
// also classified `collect` as a read while external-source.collect writes to
|
|
312
|
+
// the workspace, and it would mis-sort any third-party MCP naming its tools
|
|
313
|
+
// differently. Gone.
|
|
314
|
+
//
|
|
315
|
+
// isOrchestrationBypassTool stays: /chat carries no plan, only direct unitary
|
|
316
|
+
// actions. agent_plan/agent_execute/production_start_job and plan mutation
|
|
317
|
+
// would start work outside the plan and its approval gate.
|
|
318
|
+
export function chatAllowedTools(session) {
|
|
301
319
|
const servers = session?.chatAccess?.servers;
|
|
302
320
|
if (!servers) return [];
|
|
303
321
|
const scopedMcp = Object.fromEntries(
|
|
304
322
|
Object.entries(session.mcp ?? {}).filter(([name]) => Object.hasOwn(servers, name)),
|
|
305
323
|
);
|
|
306
324
|
return buildLlmTools(scopedMcp).filter((item) => {
|
|
307
|
-
const
|
|
325
|
+
const name = item.function.name;
|
|
326
|
+
if (isOrchestrationBypassTool(name)) return false;
|
|
327
|
+
const { server, tool } = parseToolCallName(name);
|
|
308
328
|
const entry = servers[server];
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
return (declared && isDonnaReadTool(item)) || declaredAction;
|
|
329
|
+
if (entry.allow === '*') return true;
|
|
330
|
+
return Array.isArray(entry.allow) && entry.allow.includes(tool);
|
|
312
331
|
});
|
|
313
332
|
}
|
|
314
333
|
|
|
@@ -1300,15 +1319,17 @@ async function runAgentTurn(input, { agent, session, onUpdate, onStep, displayIn
|
|
|
1300
1319
|
return {};
|
|
1301
1320
|
}
|
|
1302
1321
|
|
|
1303
|
-
// Bounded
|
|
1304
|
-
//
|
|
1305
|
-
//
|
|
1306
|
-
//
|
|
1307
|
-
|
|
1308
|
-
|
|
1322
|
+
// Bounded tool loop for /chat. Only the tools chatAccess authorizes for this
|
|
1323
|
+
// server are offered; every call goes through callMcpTool, and any tool the
|
|
1324
|
+
// model names outside the offered set is refused. /chat performs direct
|
|
1325
|
+
// unitary actions only — it never plans or delegates, which is why the
|
|
1326
|
+
// orchestration tools are filtered out upstream. maxToolIterations caps the
|
|
1327
|
+
// loop.
|
|
1328
|
+
async function runChatToolLoop({ input, session, history, donnaMessage, onUpdate, onStep, allowedTools, openWikiPages, contextMessages = [] }) {
|
|
1329
|
+
const allowed = new Set(allowedTools.map((item) => item.function.name));
|
|
1309
1330
|
// /chat only supplies the policy; the loop mechanic lives in runBoundedToolLoop.
|
|
1310
1331
|
// executeCall enforces the allow-list and turns each call into a text result;
|
|
1311
|
-
// it
|
|
1332
|
+
// it refuses anything outside the offered set.
|
|
1312
1333
|
const executeCall = async (call) => {
|
|
1313
1334
|
const rawName = call.function?.name ?? '';
|
|
1314
1335
|
const { server, tool } = resolveToolCallName(session.mcp, rawName);
|
|
@@ -1331,7 +1352,7 @@ async function runChatReadToolLoop({ input, session, history, donnaMessage, onUp
|
|
|
1331
1352
|
llm: session.llm,
|
|
1332
1353
|
system: buildDirectChatSystemPrompt(session, openWikiPages),
|
|
1333
1354
|
messages: [...history, ...contextMessages, { role: 'user', content: input }],
|
|
1334
|
-
tools:
|
|
1355
|
+
tools: allowedTools,
|
|
1335
1356
|
executeCall,
|
|
1336
1357
|
maxIterations: Math.min(8, Number(session?.chatAccess?.maxToolIterations) || 4),
|
|
1337
1358
|
signal: session._abortSignal,
|
|
@@ -1355,11 +1376,11 @@ async function runDirectChatTurn(input, { session, onUpdate, onStep }) {
|
|
|
1355
1376
|
const donnaMessage = { role: 'donna', content: '' };
|
|
1356
1377
|
messages.push(donnaMessage);
|
|
1357
1378
|
onUpdate?.();
|
|
1358
|
-
const
|
|
1359
|
-
const
|
|
1379
|
+
const allowedTools = chatAllowedTools(session);
|
|
1380
|
+
const canUseTools = allowedTools.length > 0 && typeof session.llm.completeWithTools === 'function';
|
|
1360
1381
|
try {
|
|
1361
|
-
if (
|
|
1362
|
-
await
|
|
1382
|
+
if (canUseTools) {
|
|
1383
|
+
await runChatToolLoop({ input, session, history, donnaMessage, onUpdate, onStep, allowedTools });
|
|
1363
1384
|
} else {
|
|
1364
1385
|
onStep?.('Chat: streaming direct answer…');
|
|
1365
1386
|
for await (const delta of session.llm.stream({
|
|
@@ -1392,14 +1413,14 @@ async function runDirectChatTurn(input, { session, onUpdate, onStep }) {
|
|
|
1392
1413
|
}
|
|
1393
1414
|
|
|
1394
1415
|
// Headless equivalent of runDirectChatTurn for HTTP callers (the runtime /turn
|
|
1395
|
-
// in chat mode). Reuses the exact same
|
|
1396
|
-
//
|
|
1416
|
+
// in chat mode). Reuses the exact same authorization policy — chatAllowedTools
|
|
1417
|
+
// + runChatToolLoop + buildDirectChatSystemPrompt — so there is no second
|
|
1397
1418
|
// implementation of chat access; it just returns the final text instead of
|
|
1398
1419
|
// driving a live repl bubble. The caller must have seeded session.chatAccess
|
|
1399
|
-
// (and session.mcp) so
|
|
1420
|
+
// (and session.mcp) so chatAllowedTools can resolve the allow-listed tools.
|
|
1400
1421
|
export async function runHeadlessChatTurn(session, input, { history = [], onStep, openWikiPages, openWikiPage } = {}) {
|
|
1401
1422
|
const donnaMessage = { role: 'donna', content: '' };
|
|
1402
|
-
const
|
|
1423
|
+
const allowedTools = chatAllowedTools(session);
|
|
1403
1424
|
// Keep the singular option as a compatibility input for older serve builds.
|
|
1404
1425
|
// Only paths enter the prompt; reading remains the model's generic tool call.
|
|
1405
1426
|
const selectedPages = sanitizeOpenWikiPages(openWikiPages ?? openWikiPage);
|
|
@@ -1408,9 +1429,9 @@ export async function runHeadlessChatTurn(session, input, { history = [], onStep
|
|
|
1408
1429
|
// (up to five) are each folded in as their own delimited block.
|
|
1409
1430
|
const attachedDocs = await readSelectedPageDocuments(session, selectedPages);
|
|
1410
1431
|
const contextMessages = buildAttachedDocMessages(attachedDocs);
|
|
1411
|
-
const
|
|
1412
|
-
if (
|
|
1413
|
-
await
|
|
1432
|
+
const canUseTools = allowedTools.length > 0 && typeof session.llm?.completeWithTools === 'function';
|
|
1433
|
+
if (canUseTools) {
|
|
1434
|
+
await runChatToolLoop({ input, session, history, donnaMessage, onStep, allowedTools, openWikiPages: selectedPages, contextMessages });
|
|
1414
1435
|
return donnaMessage.content;
|
|
1415
1436
|
}
|
|
1416
1437
|
if (typeof session.llm?.stream === 'function') {
|
package/src/shell/repl.test.js
CHANGED
|
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import {
|
|
7
7
|
applyRuntimeStateToShellSession,
|
|
8
|
-
|
|
8
|
+
chatAllowedTools,
|
|
9
9
|
createSession,
|
|
10
10
|
runHeadlessChatTurn,
|
|
11
11
|
sanitizeOpenWikiPage,
|
|
@@ -622,7 +622,7 @@ test('submitRuntimeRun sends a control message instead of /run while a run is ac
|
|
|
622
622
|
}
|
|
623
623
|
});
|
|
624
624
|
|
|
625
|
-
test('
|
|
625
|
+
test('chatAllowedTools exposes exactly the declared MCP tools to /chat', () => {
|
|
626
626
|
const session = {
|
|
627
627
|
chatAccess: {
|
|
628
628
|
servers: {
|
|
@@ -645,13 +645,14 @@ test('chatReadTools exposes only declared, read-only MCP tools to /chat', () =>
|
|
|
645
645
|
},
|
|
646
646
|
},
|
|
647
647
|
};
|
|
648
|
-
const names =
|
|
649
|
-
// cme_setup: not declared.
|
|
650
|
-
//
|
|
651
|
-
|
|
648
|
+
const names = chatAllowedTools(session).map((item) => item.function.name).sort();
|
|
649
|
+
// cme_setup: not declared. documents_status: server absent from chatAccess.
|
|
650
|
+
// cme_export_run: declared, so it is offered — the allow-list is the
|
|
651
|
+
// operator's explicit decision, not a suggestion filtered by a heuristic.
|
|
652
|
+
assert.deepEqual(names, ['cme__cme_export_run', 'cme__cme_sources_list', 'cme__cme_status']);
|
|
652
653
|
});
|
|
653
654
|
|
|
654
|
-
test('
|
|
655
|
+
test('chatAllowedTools offers every declared tool, reads and writes alike', () => {
|
|
655
656
|
const session = {
|
|
656
657
|
chatAccess: {
|
|
657
658
|
servers: {
|
|
@@ -669,39 +670,122 @@ test('chatReadTools accepts wiki_collect_context ("collect" is a read verb)', ()
|
|
|
669
670
|
},
|
|
670
671
|
},
|
|
671
672
|
};
|
|
672
|
-
const names =
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
673
|
+
const names = chatAllowedTools(session).map((item) => item.function.name).sort();
|
|
674
|
+
assert.deepEqual(
|
|
675
|
+
names,
|
|
676
|
+
['wiki__wiki_collect_context', 'wiki__wiki_search_context', 'wiki__wiki_write_page'],
|
|
677
|
+
);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
test('chatAllowedTools "*" offers every tool of the server, writes included', () => {
|
|
681
|
+
const session = {
|
|
682
|
+
chatAccess: { servers: { exa: { allow: '*' } } },
|
|
683
|
+
mcp: {
|
|
684
|
+
exa: {
|
|
685
|
+
status: 'connected',
|
|
686
|
+
tools: [
|
|
687
|
+
{ name: 'web_search_exa', inputSchema: { type: 'object', properties: {} } },
|
|
688
|
+
{ name: 'crawling_exa', inputSchema: { type: 'object', properties: {} } },
|
|
689
|
+
{ name: 'deep_researcher_start', inputSchema: { type: 'object', properties: {} } },
|
|
690
|
+
],
|
|
691
|
+
},
|
|
692
|
+
documents: {
|
|
693
|
+
status: 'connected',
|
|
694
|
+
tools: [{ name: 'documents_status', inputSchema: { type: 'object', properties: {} } }],
|
|
695
|
+
},
|
|
696
|
+
},
|
|
697
|
+
};
|
|
698
|
+
// No name is inspected: "*" is the operator saying "this whole server".
|
|
699
|
+
// documents stays out — it is absent from chatAccess, so it is agent-only.
|
|
700
|
+
assert.deepEqual(
|
|
701
|
+
chatAllowedTools(session).map((item) => item.function.name).sort(),
|
|
702
|
+
['exa__crawling_exa', 'exa__deep_researcher_start', 'exa__web_search_exa'],
|
|
703
|
+
);
|
|
676
704
|
});
|
|
677
705
|
|
|
678
|
-
test('
|
|
706
|
+
test('chatAllowedTools offers an action no read-verb heuristic would accept', () => {
|
|
679
707
|
const session = {
|
|
680
708
|
chatAccess: {
|
|
681
709
|
servers: {
|
|
682
|
-
connectors: {
|
|
710
|
+
connectors: {
|
|
711
|
+
allow: ['connectors_google_status', 'connectors_google_oauth_start'],
|
|
712
|
+
},
|
|
713
|
+
},
|
|
714
|
+
},
|
|
715
|
+
mcp: {
|
|
716
|
+
connectors: {
|
|
717
|
+
status: 'connected',
|
|
718
|
+
tools: [
|
|
719
|
+
{ name: 'connectors_google_status', inputSchema: { type: 'object', properties: {} } },
|
|
720
|
+
{ name: 'connectors_google_oauth_start', inputSchema: { type: 'object', properties: {} } },
|
|
721
|
+
],
|
|
683
722
|
},
|
|
684
723
|
},
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
assert.deepEqual(
|
|
727
|
+
chatAllowedTools(session).map((item) => item.function.name).sort(),
|
|
728
|
+
['connectors__connectors_google_oauth_start', 'connectors__connectors_google_status'],
|
|
729
|
+
);
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
// /chat carries no plan: it performs direct unitary actions only. The
|
|
733
|
+
// orchestration entry points stay out of it whichever way they are declared.
|
|
734
|
+
test('chatAllowedTools never offers orchestration tools, even when allow-listed', () => {
|
|
735
|
+
const session = {
|
|
736
|
+
chatAccess: {
|
|
737
|
+
servers: {
|
|
738
|
+
connectors: { allow: ['agent_execute', 'agent_plan', 'connectors_google_status'] },
|
|
739
|
+
},
|
|
740
|
+
},
|
|
741
|
+
mcp: {
|
|
742
|
+
connectors: {
|
|
743
|
+
status: 'connected',
|
|
744
|
+
tools: [
|
|
745
|
+
{ name: 'agent_execute', inputSchema: { type: 'object', properties: {} } },
|
|
746
|
+
{ name: 'agent_plan', inputSchema: { type: 'object', properties: {} } },
|
|
747
|
+
{ name: 'connectors_google_status', inputSchema: { type: 'object', properties: {} } },
|
|
748
|
+
],
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
assert.deepEqual(
|
|
754
|
+
chatAllowedTools(session).map((item) => item.function.name),
|
|
755
|
+
['connectors__connectors_google_status'],
|
|
756
|
+
);
|
|
757
|
+
|
|
758
|
+
const wildcard = { ...session, chatAccess: { servers: { connectors: { allow: '*' } } } };
|
|
759
|
+
assert.deepEqual(
|
|
760
|
+
chatAllowedTools(wildcard).map((item) => item.function.name),
|
|
761
|
+
['connectors__connectors_google_status'],
|
|
762
|
+
);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
test('chatAllowedTools folds a legacy allowActions entry into allow', () => {
|
|
766
|
+
const session = {
|
|
767
|
+
chatAccess: {
|
|
768
|
+
// Shape produced by readChatAccessConfig for a file written by an older
|
|
769
|
+
// manager: the legacy key is already merged, so chat keeps working.
|
|
770
|
+
servers: { connectors: { allow: ['connectors_google_oauth_start'] } },
|
|
771
|
+
},
|
|
685
772
|
mcp: {
|
|
686
773
|
connectors: {
|
|
687
774
|
status: 'connected',
|
|
688
|
-
tools: [{
|
|
689
|
-
name: 'connectors_google_oauth_start',
|
|
690
|
-
inputSchema: { type: 'object', properties: {} },
|
|
691
|
-
}],
|
|
775
|
+
tools: [{ name: 'connectors_google_oauth_start', inputSchema: { type: 'object', properties: {} } }],
|
|
692
776
|
},
|
|
693
777
|
},
|
|
694
778
|
};
|
|
695
779
|
|
|
696
780
|
assert.deepEqual(
|
|
697
|
-
|
|
781
|
+
chatAllowedTools(session).map((item) => item.function.name),
|
|
698
782
|
['connectors__connectors_google_oauth_start'],
|
|
699
783
|
);
|
|
700
784
|
});
|
|
701
785
|
|
|
702
|
-
test('
|
|
786
|
+
test('chatAllowedTools is empty when no chatAccess is configured', () => {
|
|
703
787
|
const session = { mcp: { cme: { status: 'connected', tools: [{ name: 'cme_status', inputSchema: {} }] } } };
|
|
704
|
-
assert.deepEqual(
|
|
788
|
+
assert.deepEqual(chatAllowedTools(session), []);
|
|
705
789
|
});
|
|
706
790
|
|
|
707
791
|
test('/chat uses the tool-capable path when read tools are declared', async () => {
|
package/wiki-workspace
CHANGED
|
@@ -487,18 +487,18 @@ if (enabled === 'true') {
|
|
|
487
487
|
};
|
|
488
488
|
config.chatAccess.servers.connectors ??= {};
|
|
489
489
|
const connectorAccess = config.chatAccess.servers.connectors;
|
|
490
|
+
// One shape for every server: a single `allow` list. The legacy
|
|
491
|
+
// `allowActions` split is folded back in and removed, so a file written by
|
|
492
|
+
// an older manager converges to the common shape on the next `agents up`.
|
|
490
493
|
connectorAccess.allow = [
|
|
491
494
|
...new Set([
|
|
492
495
|
...(Array.isArray(connectorAccess.allow) ? connectorAccess.allow : []),
|
|
493
|
-
'connectors_google_status',
|
|
494
|
-
].filter((tool) => tool !== 'connectors_google_oauth_start')),
|
|
495
|
-
];
|
|
496
|
-
connectorAccess.allowActions = [
|
|
497
|
-
...new Set([
|
|
498
496
|
...(Array.isArray(connectorAccess.allowActions) ? connectorAccess.allowActions : []),
|
|
497
|
+
'connectors_google_status',
|
|
499
498
|
'connectors_google_oauth_start',
|
|
500
499
|
]),
|
|
501
500
|
];
|
|
501
|
+
delete connectorAccess.allowActions;
|
|
502
502
|
} else {
|
|
503
503
|
delete config.mcpServers.connectors;
|
|
504
504
|
delete config.chatAccess.servers.connectors;
|