@dotdrelle/wiki-manager 0.15.66 → 0.15.70
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 +10 -3
- package/README.md +54 -0
- package/agent-runtimes.example.json +68 -0
- package/agents.docker-compose.yml +35 -1
- package/docker-compose.yml +3 -3
- package/package.json +3 -2
- package/src/agent/graph.js +12 -11
- package/src/agent/skillRecursion.test.js +13 -12
- package/src/cli/wiki-manager.js +124 -36
- package/src/commands/slash.js +38 -3
- package/src/contracts/schemas.js +67 -0
- package/src/core/activity.js +5 -0
- package/src/core/agentEvents.js +18 -1
- package/src/core/buildInfo.json +2 -2
- package/src/core/dockerCompose.test.js +8 -40
- package/src/core/env.js +14 -0
- package/src/core/env.test.js +19 -0
- package/src/core/googleGrants.test.js +1 -1
- package/src/core/mcp.js +1 -1
- package/src/core/runtimeEventAdapter.js +81 -0
- package/src/core/runtimeEventAdapter.test.js +61 -0
- package/src/core/skillChainView.test.js +2 -2
- package/src/core/skillCompiler.test.js +1 -1
- package/src/core/skillInvocation.js +13 -8
- package/src/core/startupCheck.js +58 -0
- package/src/core/startupCheck.test.js +29 -1
- package/src/orchestrator/agentRegistry.js +1 -22
- package/src/orchestrator/assignmentManager.js +16 -4
- package/src/orchestrator/capabilityRegistry.js +8 -1
- package/src/orchestrator/dispatcher.js +361 -2
- package/src/orchestrator/dispatcher.test.js +112 -1
- package/src/orchestrator/objectiveResolver.js +10 -6
- package/src/orchestrator/objectiveResolver.test.js +26 -27
- package/src/orchestrator/providers/deepAgentsProvider.js +168 -0
- package/src/orchestrator/providers/deepAgentsProvider.test.js +178 -0
- package/src/orchestrator/providers/dispatcherExternalRuntime.test.js +409 -0
- package/src/orchestrator/providers/fakeRuntimeProvider.js +164 -0
- package/src/orchestrator/providers/fakeRuntimeProvider.test.js +201 -0
- package/src/orchestrator/providers/runtimeProvider.js +101 -0
- package/src/orchestrator/providers/runtimeProviders.js +325 -0
- package/src/orchestrator/providers/runtimeProviders.test.js +361 -0
- package/src/orchestrator/resultAggregator.js +35 -2
- package/src/orchestrator/resultAggregator.test.js +62 -0
- package/src/runtime/recoveryManager.js +70 -5
- package/src/runtime/skillChain.e2e.test.js +2 -2
- package/src/runtime/supervisor.js +5 -10
- package/src/shell/RightPane.tsx +9 -1
- package/src/shell/StartupScreen.tsx +44 -7
- package/src/shell/repl.test.js +13 -0
- package/wiki-workspace +19 -3
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { normalizeActivity, parseJsonText } from '../core/activity.js';
|
|
2
2
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
3
3
|
import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
|
|
4
|
+
import { loadWorkspaceProfile } from '../core/profile.js';
|
|
5
|
+
import { mapRuntimeEvent } from '../core/runtimeEventAdapter.js';
|
|
4
6
|
import { emitRuntimeLog, pollActivitiesOnce } from '../runtime/supervisor.js';
|
|
7
|
+
import { APPROVAL_DEFAULT_CLASS, approvalCovered } from './approvalPolicy.js';
|
|
5
8
|
import { isSuccessful, isTerminal } from './taskStatuses.js';
|
|
6
9
|
|
|
7
10
|
|
|
@@ -32,6 +35,16 @@ export async function execute(task, assignment, {
|
|
|
32
35
|
pollBusy = new Set(),
|
|
33
36
|
pollIntervalMs = 2500,
|
|
34
37
|
} = {}) {
|
|
38
|
+
if (isExternalRuntimeAssignment(assignment)) {
|
|
39
|
+
return executeExternalRuntime(task, assignment, {
|
|
40
|
+
session,
|
|
41
|
+
signal,
|
|
42
|
+
runId,
|
|
43
|
+
attempt,
|
|
44
|
+
timeoutMs,
|
|
45
|
+
pollIntervalMs,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
35
48
|
if (!session) throw new Error('dispatcher.execute requires session.');
|
|
36
49
|
if (!assignment?.serverName) throw new Error(`No MCP server found for agent ${assignment?.agentInstanceId ?? '(unknown)'}.`);
|
|
37
50
|
const serverName = assignment.serverName;
|
|
@@ -157,6 +170,243 @@ export async function execute(task, assignment, {
|
|
|
157
170
|
}
|
|
158
171
|
}
|
|
159
172
|
|
|
173
|
+
function isExternalRuntimeAssignment(assignment) {
|
|
174
|
+
return assignment?.providerKind === 'external-runtime'
|
|
175
|
+
&& typeof assignment?.runtimeProvider?.execute === 'function';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// A proposal without mutations still waits for a human grant: the request is
|
|
179
|
+
// treated as the default approval class, covered by a run-scope grant like
|
|
180
|
+
// any other. Every announced class must be covered — the "global" approval is
|
|
181
|
+
// bounded by the proposal.
|
|
182
|
+
function pendingApprovalCovered(classes, approvals, context) {
|
|
183
|
+
const list = Array.isArray(classes) && classes.length > 0
|
|
184
|
+
? classes
|
|
185
|
+
: [APPROVAL_DEFAULT_CLASS];
|
|
186
|
+
return list.every((approvalClass) => approvalCovered(
|
|
187
|
+
{
|
|
188
|
+
// Without an id, grantCoversTask's task/tool-scope match (which keys
|
|
189
|
+
// off task.id/localId/taskId) can never succeed, so a per-task
|
|
190
|
+
// `/approve item <id>` grant silently fails to cover this task and only
|
|
191
|
+
// a run-scope "approve all" can ever unblock it.
|
|
192
|
+
id: context?.taskId ?? null,
|
|
193
|
+
taskId: context?.taskId ?? null,
|
|
194
|
+
groupId: context?.groupId ?? null,
|
|
195
|
+
requiresApproval: true,
|
|
196
|
+
approvalClass: String(approvalClass),
|
|
197
|
+
},
|
|
198
|
+
approvals,
|
|
199
|
+
context,
|
|
200
|
+
));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function executeExternalRuntime(task, assignment, {
|
|
204
|
+
session,
|
|
205
|
+
signal = null,
|
|
206
|
+
runId = null,
|
|
207
|
+
attempt = null,
|
|
208
|
+
timeoutMs = null,
|
|
209
|
+
pollIntervalMs = 2500,
|
|
210
|
+
} = {}) {
|
|
211
|
+
if (!session) throw new Error('dispatcher.execute requires session.');
|
|
212
|
+
const runtimeProvider = assignment.runtimeProvider;
|
|
213
|
+
const taskId = String(task.id ?? task.step);
|
|
214
|
+
const taskTimeoutMs = resolvedTimeoutMs(assignment, timeoutMs);
|
|
215
|
+
let deadline = Date.now() + taskTimeoutMs;
|
|
216
|
+
let runtimeRunId = null;
|
|
217
|
+
let unsubscribe = null;
|
|
218
|
+
let pendingApproval = null;
|
|
219
|
+
try {
|
|
220
|
+
emitRuntimeLog(session, taskLogPayload('runtime.execute', task, assignment, {
|
|
221
|
+
runId,
|
|
222
|
+
attempt,
|
|
223
|
+
detail: 'external-runtime',
|
|
224
|
+
}));
|
|
225
|
+
const mcpPool = activeProfileMcp(session);
|
|
226
|
+
if (!mcpPool) {
|
|
227
|
+
// A degradation must announce itself. The runtime's whole value is its
|
|
228
|
+
// eyes: dispatched without the workspace wiki MCP, the Deep Agent
|
|
229
|
+
// improvises with its built-in backend (it ran `ls /` on the gateway
|
|
230
|
+
// container and answered "the workspace is empty" — a plausible,
|
|
231
|
+
// confident, entirely wrong answer). Say it in the journal, keep the
|
|
232
|
+
// run (the objective may still be answerable from the model alone),
|
|
233
|
+
// but never let blindness pass silently.
|
|
234
|
+
emitRuntimeLog(session, taskLogPayload('runtime.blind', task, assignment, {
|
|
235
|
+
runId,
|
|
236
|
+
attempt,
|
|
237
|
+
detail: 'no workspace wiki MCP pool — the endpoint is down or declares no read tools; the runtime will run without its eyes',
|
|
238
|
+
}));
|
|
239
|
+
}
|
|
240
|
+
const accepted = await runtimeProvider.execute({
|
|
241
|
+
objective: task.label ?? task.description ?? taskId,
|
|
242
|
+
operation: task.operation ?? null,
|
|
243
|
+
capability: task.requiredCapability ?? null,
|
|
244
|
+
arguments: task.arguments && typeof task.arguments === 'object' ? task.arguments : {},
|
|
245
|
+
workspace: workspaceRequest(session),
|
|
246
|
+
model: activeProfileModel(session),
|
|
247
|
+
language: session?.language ?? session?.wikircConfig?.language ?? null,
|
|
248
|
+
mcp: mcpPool,
|
|
249
|
+
systemPrompt: activeRuntimeSystemPrompt(session, task, assignment),
|
|
250
|
+
});
|
|
251
|
+
runtimeRunId = String(accepted?.runId ?? '');
|
|
252
|
+
if (!runtimeRunId) throw new Error('runtime.execute did not return runId.');
|
|
253
|
+
emitRuntimeLog(session, taskLogPayload('runtime.accepted', task, assignment, {
|
|
254
|
+
runId,
|
|
255
|
+
attempt,
|
|
256
|
+
jobId: runtimeRunId,
|
|
257
|
+
detail: String(accepted?.status ?? 'running'),
|
|
258
|
+
}));
|
|
259
|
+
dispatchAgentEvent(session, createAgentEvent('task.started', {
|
|
260
|
+
origin: 'dispatcher',
|
|
261
|
+
runId,
|
|
262
|
+
taskId,
|
|
263
|
+
payload: {
|
|
264
|
+
runId,
|
|
265
|
+
taskId,
|
|
266
|
+
attemptId: attempt?.attemptId ?? null,
|
|
267
|
+
agentInstanceId: assignment.agentInstanceId,
|
|
268
|
+
jobId: runtimeRunId,
|
|
269
|
+
startedAt: new Date().toISOString(),
|
|
270
|
+
},
|
|
271
|
+
}));
|
|
272
|
+
dispatchExternalRuntimeActivity(session, task, assignment, runtimeRunId, 'running', runId);
|
|
273
|
+
if (typeof runtimeProvider.subscribe === 'function') {
|
|
274
|
+
unsubscribe = runtimeProvider.subscribe(runtimeRunId, (event) => {
|
|
275
|
+
for (const mapped of mapRuntimeEvent(event)) {
|
|
276
|
+
dispatchAgentEvent(session, createAgentEvent(mapped.type, {
|
|
277
|
+
origin: 'runtime_provider',
|
|
278
|
+
runId,
|
|
279
|
+
taskId,
|
|
280
|
+
payload: mapped.payload,
|
|
281
|
+
}));
|
|
282
|
+
if (mapped.type === 'approval.requested') {
|
|
283
|
+
pendingApproval = {
|
|
284
|
+
approvalId: mapped.payload?.approvalId ?? null,
|
|
285
|
+
approvalClasses: Array.isArray(mapped.payload?.approvalClasses)
|
|
286
|
+
? mapped.payload.approvalClasses
|
|
287
|
+
: [],
|
|
288
|
+
};
|
|
289
|
+
const summary = String(mapped.payload?.proposal?.summary ?? mapped.payload?.reason ?? '').trim();
|
|
290
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_message', {
|
|
291
|
+
origin: 'runtime_provider',
|
|
292
|
+
runId,
|
|
293
|
+
payload: {
|
|
294
|
+
content: [
|
|
295
|
+
'⏸ Approval required before execution:',
|
|
296
|
+
...(summary ? [` ${summary}`] : []),
|
|
297
|
+
'Type /approve (or click "Approve") to proceed, "cancel" to abandon.',
|
|
298
|
+
].filter(Boolean).join('\n'),
|
|
299
|
+
},
|
|
300
|
+
}));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
let approvalWaitStartedAt = null;
|
|
306
|
+
while (true) {
|
|
307
|
+
throwIfAborted(signal);
|
|
308
|
+
// While a proposal waits for a human decision, the task timeout does
|
|
309
|
+
// not tick — the human decides, not the clock (the scheduler applies
|
|
310
|
+
// the same rule to its approval waits). The elapsed wait is credited
|
|
311
|
+
// back onto the deadline once the gate clears (below), rather than
|
|
312
|
+
// merely skipped from this check, so a late approval does not
|
|
313
|
+
// immediately expire the task on the very next iteration.
|
|
314
|
+
if (!pendingApproval && Date.now() > deadline) {
|
|
315
|
+
await runtimeProvider.cancel(runtimeRunId).catch(() => null);
|
|
316
|
+
throw new Error(`Task timed out after ${taskTimeoutMs}ms.`);
|
|
317
|
+
}
|
|
318
|
+
if (pendingApproval) {
|
|
319
|
+
approvalWaitStartedAt ??= Date.now();
|
|
320
|
+
const approvalBeingChecked = pendingApproval;
|
|
321
|
+
const approvals = session.agentProjection?.approvals ?? session.approvals ?? [];
|
|
322
|
+
const covered = pendingApprovalCovered(approvalBeingChecked.approvalClasses, approvals, {
|
|
323
|
+
runId,
|
|
324
|
+
taskId,
|
|
325
|
+
groupId: task.groupId ?? null,
|
|
326
|
+
workspaceId: session.workspace ?? null,
|
|
327
|
+
planRevision: session.planRevision ?? session.agentProjection?.planRevision ?? null,
|
|
328
|
+
});
|
|
329
|
+
if (covered && typeof runtimeProvider.approve === 'function') {
|
|
330
|
+
emitRuntimeLog(session, taskLogPayload('runtime.approval_granted', task, assignment, {
|
|
331
|
+
runId,
|
|
332
|
+
attempt,
|
|
333
|
+
jobId: runtimeRunId,
|
|
334
|
+
detail: 'unblocking runtime HITL',
|
|
335
|
+
}));
|
|
336
|
+
await runtimeProvider.approve(runtimeRunId, { approved: true, scope: approvalBeingChecked.approvalClasses });
|
|
337
|
+
// Only clear the approval just resolved: the runtime's event stream
|
|
338
|
+
// may have raised a second, unrelated one while the approve() round
|
|
339
|
+
// trip above was in flight.
|
|
340
|
+
if (pendingApproval === approvalBeingChecked) {
|
|
341
|
+
pendingApproval = null;
|
|
342
|
+
deadline += Date.now() - approvalWaitStartedAt;
|
|
343
|
+
approvalWaitStartedAt = null;
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
await delay(pollIntervalMs, signal);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const lastStatus = await runtimeProvider.status(runtimeRunId);
|
|
351
|
+
if (isTerminal(lastStatus?.status)) {
|
|
352
|
+
const refusedParams = Array.isArray(lastStatus?.result?.refusedParams)
|
|
353
|
+
? lastStatus.result.refusedParams
|
|
354
|
+
: [];
|
|
355
|
+
if (refusedParams.length > 0) {
|
|
356
|
+
emitRuntimeLog(session, taskLogPayload('runtime.params_refused', task, assignment, {
|
|
357
|
+
runId,
|
|
358
|
+
attempt,
|
|
359
|
+
jobId: runtimeRunId,
|
|
360
|
+
detail: `model refused sampling parameters (${refusedParams.join(', ')}) — remove them from the workspace .wikirc (llm.<key>) so they are no longer sent`,
|
|
361
|
+
}));
|
|
362
|
+
}
|
|
363
|
+
emitRuntimeLog(session, taskLogPayload('runtime.result_returned', task, assignment, {
|
|
364
|
+
runId,
|
|
365
|
+
attempt,
|
|
366
|
+
jobId: runtimeRunId,
|
|
367
|
+
status: lastStatus.status,
|
|
368
|
+
detail: 'terminal status',
|
|
369
|
+
}));
|
|
370
|
+
dispatchExternalRuntimeActivity(session, task, assignment, runtimeRunId, lastStatus.status, runId);
|
|
371
|
+
return taskResultFromStatus(task, assignment, runtimeRunId, lastStatus, attempt);
|
|
372
|
+
}
|
|
373
|
+
await delay(pollIntervalMs, signal);
|
|
374
|
+
}
|
|
375
|
+
} catch (error) {
|
|
376
|
+
if (isAbortError(error) && runtimeRunId) {
|
|
377
|
+
await runtimeProvider.cancel(runtimeRunId).catch(() => null);
|
|
378
|
+
}
|
|
379
|
+
throw error;
|
|
380
|
+
} finally {
|
|
381
|
+
unsubscribe?.();
|
|
382
|
+
emitRuntimeLog(session, taskLogPayload('lock.released', task, assignment, {
|
|
383
|
+
runId,
|
|
384
|
+
attempt,
|
|
385
|
+
jobId: runtimeRunId,
|
|
386
|
+
detail: attempt?.locks?.join(',') || 'no locks',
|
|
387
|
+
}));
|
|
388
|
+
attempt?.release?.();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function dispatchExternalRuntimeActivity(session, task, assignment, runtimeRunId, status, runId) {
|
|
393
|
+
const activity = normalizeActivity({
|
|
394
|
+
id: runtimeRunId,
|
|
395
|
+
source: assignment.runtimeId ?? 'external-runtime',
|
|
396
|
+
kind: task.operation ?? task.requiredCapability ?? 'task',
|
|
397
|
+
label: task.label ?? task.description ?? String(task.id ?? task.step),
|
|
398
|
+
status,
|
|
399
|
+
progress: { percent: isTerminal(status) ? 100 : 0, stepId: String(task.id ?? task.step) },
|
|
400
|
+
outputRefs: [],
|
|
401
|
+
});
|
|
402
|
+
dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
|
|
403
|
+
origin: 'dispatcher',
|
|
404
|
+
runId,
|
|
405
|
+
taskId: String(task.id ?? task.step),
|
|
406
|
+
payload: { activity },
|
|
407
|
+
}));
|
|
408
|
+
}
|
|
409
|
+
|
|
160
410
|
function executeRequest(task, session, runId) {
|
|
161
411
|
return {
|
|
162
412
|
taskId: String(task.id ?? task.step),
|
|
@@ -165,7 +415,20 @@ function executeRequest(task, session, runId) {
|
|
|
165
415
|
idempotencyKey: task.idempotencyKey ?? undefined,
|
|
166
416
|
operation: task.operation,
|
|
167
417
|
workspace: workspaceRequest(session),
|
|
168
|
-
arguments:
|
|
418
|
+
arguments: {
|
|
419
|
+
...(task.arguments && typeof task.arguments === 'object' ? task.arguments : {}),
|
|
420
|
+
// The production agent's confirmation guard
|
|
421
|
+
// (PRODUCTION_REQUIRE_CONFIRMATION=true) has one contract: "only an
|
|
422
|
+
// approved task may run a mutating operation — Donna passes
|
|
423
|
+
// confirm=true after the run-scope approval". The scheduler never
|
|
424
|
+
// dispatches a requiresApproval task before coverage
|
|
425
|
+
// (dependencyResolver.readyTasks), so reaching dispatch IS the
|
|
426
|
+
// approval. Without this, an operator who enables the guard sees every
|
|
427
|
+
// mutating production job fail with "requires confirm=true" — the
|
|
428
|
+
// first E2E ingest plan dispatched by the deep agent's
|
|
429
|
+
// planExpansionRequest failed exactly that way, 19 tasks in one batch.
|
|
430
|
+
...(task.requiresApproval === true ? { confirm: true } : {}),
|
|
431
|
+
},
|
|
169
432
|
constraints: {
|
|
170
433
|
requireApprovalForMutations: task.requiresApproval === true,
|
|
171
434
|
},
|
|
@@ -178,6 +441,93 @@ function workspaceRequest(session) {
|
|
|
178
441
|
return { name: String(workspace ?? 'workspace') };
|
|
179
442
|
}
|
|
180
443
|
|
|
444
|
+
// The model travels WITH the run: the runtime is workspace-agnostic and must
|
|
445
|
+
// follow the active profile — default or `/config use` — without a config
|
|
446
|
+
// sync. Numeric LLM parameters declared by the profile (temperature, …) ride
|
|
447
|
+
// along too: nothing is hardcoded here, the workspace config is the source.
|
|
448
|
+
function activeProfileModel(session) {
|
|
449
|
+
const llm = session?.wikircConfig?.llm ?? {};
|
|
450
|
+
const model = {
|
|
451
|
+
...(llm.baseUrl ? { baseUrl: String(llm.baseUrl) } : {}),
|
|
452
|
+
...(llm.model ? { model: String(llm.model) } : {}),
|
|
453
|
+
...(llm.apiKey ? { apiKey: String(llm.apiKey) } : {}),
|
|
454
|
+
};
|
|
455
|
+
for (const key of ['temperature', 'maxTokens', 'topP', 'seed']) {
|
|
456
|
+
const value = Number(llm[key]);
|
|
457
|
+
if (Number.isFinite(value)) model[key] = value;
|
|
458
|
+
}
|
|
459
|
+
return Object.keys(model).length > 0 ? model : null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// The read-only wiki MCP tools the runtime is allowed to see. An explicit
|
|
463
|
+
// allow-list, not a denylist: the runtime has eyes (read tools) and a mouth
|
|
464
|
+
// (gated side-effects through the DAG), never hands on the workspace. A denylist
|
|
465
|
+
// keyed on "write_page|add_source|…" already silently let build_context_write
|
|
466
|
+
// and template_write through, and would leak any future wiki_delete_page /
|
|
467
|
+
// wiki_move_page / wiki_rename the moment it is added upstream.
|
|
468
|
+
const READ_ONLY_WIKI_TOOLS = new Set([
|
|
469
|
+
'help_list', 'help_read', 'help_search',
|
|
470
|
+
'profile_read', 'template_read',
|
|
471
|
+
'wiki_collect_context', 'wiki_list_ingested_sources', 'wiki_list_pages',
|
|
472
|
+
'wiki_outline', 'wiki_read_deliverable', 'wiki_read_ingested_source',
|
|
473
|
+
'wiki_read_page', 'wiki_read_pages', 'wiki_search_context',
|
|
474
|
+
'wiki_workspace_status',
|
|
475
|
+
]);
|
|
476
|
+
|
|
477
|
+
// The runtime's EYES, per run: the active workspace's wiki MCP, read tools
|
|
478
|
+
// only. Workspace-scoped endpoints are per-run by nature — they cannot live
|
|
479
|
+
// in a static gateway file. The allow-list here is the authority: nothing
|
|
480
|
+
// else reaches the runtime.
|
|
481
|
+
export function activeProfileMcp(session) {
|
|
482
|
+
const wiki = session?.mcp?.wiki;
|
|
483
|
+
if (!wiki?.url || wiki.status !== 'connected') return null;
|
|
484
|
+
const tools = (wiki.tools ?? [])
|
|
485
|
+
.map((tool) => String(tool.name ?? ''))
|
|
486
|
+
.filter((name) => {
|
|
487
|
+
if (!name) return false;
|
|
488
|
+
const base = name.includes('__') ? name.slice(name.lastIndexOf('__') + 2) : name;
|
|
489
|
+
return READ_ONLY_WIKI_TOOLS.has(base);
|
|
490
|
+
});
|
|
491
|
+
if (tools.length === 0) return null;
|
|
492
|
+
// Same credential contract as the manager's own MCP client (mcp.js
|
|
493
|
+
// `authorization: Bearer ${endpoint.token}`): the wiki detail carries
|
|
494
|
+
// `token`, not `headers` — without it the gateway's MCP connection is
|
|
495
|
+
// rejected by the workspace MCP server ("invalid or missing bearer token")
|
|
496
|
+
// and the Deep Agent runs blind.
|
|
497
|
+
const headers = {
|
|
498
|
+
...(wiki.headers && typeof wiki.headers === 'object' ? wiki.headers : {}),
|
|
499
|
+
...(wiki.token ? { Authorization: `Bearer ${wiki.token}` } : {}),
|
|
500
|
+
};
|
|
501
|
+
return [{
|
|
502
|
+
name: 'wiki',
|
|
503
|
+
url: String(wiki.url),
|
|
504
|
+
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
|
505
|
+
tools,
|
|
506
|
+
}];
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// The Deep Agent's system prompt, built per run from the same ingredients
|
|
510
|
+
// Donna uses: role, the ONE capability being executed (with its declared
|
|
511
|
+
// description), the eyes/bouche/mains boundary, the workspace profile and the
|
|
512
|
+
// reply language. Without it, the runtime falls back to deepagents' generic
|
|
513
|
+
// assistant prompt — which is exactly the "upload your project" hallucination.
|
|
514
|
+
function activeRuntimeSystemPrompt(session, task, assignment) {
|
|
515
|
+
const capability = assignment?.capability ?? null;
|
|
516
|
+
const description = String(capability?.description ?? '').trim();
|
|
517
|
+
const language = session?.language ?? session?.wikircConfig?.language ?? null;
|
|
518
|
+
const profile = loadWorkspaceProfile(session?.workspacePath);
|
|
519
|
+
return [
|
|
520
|
+
'You are the agentic analysis engine of a knowledge workspace (wikiLLM), executed behind the manager Donna.',
|
|
521
|
+
`Execute exactly ONE capability: ${task?.requiredCapability ?? 'unknown'}${description ? ` — ${description}` : ''}.`,
|
|
522
|
+
`Operation: ${task?.operation ?? 'run'}.`,
|
|
523
|
+
'Boundary: you have READ tools only (the workspace wiki). You never modify the workspace — structural changes are proposals you return in your final answer (a planExpansionRequest), the manager integrates them under human approval. Side-effects on the outside world are gated by approval.',
|
|
524
|
+
'Ground every claim in what the read tools return. Never invent pages, names, facts, jobs or results.',
|
|
525
|
+
'Tool discipline: discover real page paths with the list/search tools BEFORE reading. Never guess a path — a read refused for "path not allowed" means the path was invented, so list/search first, then read exactly what exists.',
|
|
526
|
+
...(language ? [`Reply in the workspace language: ${language}.`] : []),
|
|
527
|
+
...(profile ? [`Workspace preferences — apply them to every reply:\n${profile}`] : []),
|
|
528
|
+
].join('\n');
|
|
529
|
+
}
|
|
530
|
+
|
|
181
531
|
function dispatchTaskActivity(session, task, assignment, jobId, statusTool, runId) {
|
|
182
532
|
const activity = normalizeActivity({
|
|
183
533
|
id: jobId,
|
|
@@ -215,7 +565,16 @@ function taskResultFromStatus(task, assignment, jobId, statusPayload, attempt =
|
|
|
215
565
|
status: result.status ?? statusPayload?.status,
|
|
216
566
|
outputRefs: Array.isArray(result.outputRefs) ? result.outputRefs : [],
|
|
217
567
|
metrics: result.metrics ?? {},
|
|
218
|
-
|
|
568
|
+
// The gateway reports its failure at the TOP level of the status payload
|
|
569
|
+
// ({ runId, status, error }), not inside `result`. Reading only
|
|
570
|
+
// `result.error` dropped the only actionable sentence ("no model…") and
|
|
571
|
+
// left Donna to invent a cause.
|
|
572
|
+
error: normalizeTaskError(result.error ?? statusPayload.error),
|
|
573
|
+
// Agent -> DAG (RFC § 30/31/41): a result may request the execution of a
|
|
574
|
+
// deterministic capability. Propagated here so resultAggregator.maybeExpandPlan
|
|
575
|
+
// picks it up and routes it through the manager's own resolution + approval
|
|
576
|
+
// — never a direct scheduler call by the runtime.
|
|
577
|
+
...(result.planExpansionRequest ? { planExpansionRequest: result.planExpansionRequest } : {}),
|
|
219
578
|
rawStatus: statusPayload,
|
|
220
579
|
};
|
|
221
580
|
}
|
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import { createDispatcher, normalizeTaskError } from './dispatcher.js';
|
|
3
|
+
import { activeProfileMcp, createDispatcher, normalizeTaskError } from './dispatcher.js';
|
|
4
|
+
|
|
5
|
+
test('activeProfileMcp forwards only the read-only wiki tools to the external runtime', () => {
|
|
6
|
+
const session = {
|
|
7
|
+
mcp: {
|
|
8
|
+
wiki: {
|
|
9
|
+
url: 'http://wiki:3000/mcp',
|
|
10
|
+
status: 'connected',
|
|
11
|
+
token: 't',
|
|
12
|
+
tools: [
|
|
13
|
+
{ name: 'wiki_read_page' },
|
|
14
|
+
{ name: 'wiki_search_context' },
|
|
15
|
+
{ name: 'wiki_workspace_status' },
|
|
16
|
+
{ name: 'help_read' },
|
|
17
|
+
// every mutation tool must be dropped, including the ones the old
|
|
18
|
+
// substring denylist ("write_page|add_source|…") let through:
|
|
19
|
+
{ name: 'wiki_write_page' },
|
|
20
|
+
{ name: 'wiki_add_source' },
|
|
21
|
+
{ name: 'profile_update' },
|
|
22
|
+
{ name: 'template_write' },
|
|
23
|
+
{ name: 'build_context_write' },
|
|
24
|
+
// and a hypothetical future mutation tool the denylist could not know:
|
|
25
|
+
{ name: 'wiki_delete_page' },
|
|
26
|
+
{ name: 'wiki_move_page' },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
const [pool] = activeProfileMcp(session);
|
|
32
|
+
assert.deepEqual(
|
|
33
|
+
[...pool.tools].sort(),
|
|
34
|
+
['help_read', 'wiki_read_page', 'wiki_search_context', 'wiki_workspace_status'],
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('activeProfileMcp tolerates namespaced tool names', () => {
|
|
39
|
+
const session = {
|
|
40
|
+
mcp: {
|
|
41
|
+
wiki: {
|
|
42
|
+
url: 'http://wiki:3000/mcp',
|
|
43
|
+
status: 'connected',
|
|
44
|
+
tools: [{ name: 'wiki__wiki_read_page' }, { name: 'wiki__wiki_write_page' }],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
assert.deepEqual(activeProfileMcp(session)[0].tools, ['wiki__wiki_read_page']);
|
|
49
|
+
});
|
|
4
50
|
|
|
5
51
|
test('dispatcher returns a retryable logical failure when agent_execute reports workspace_busy', async () => {
|
|
6
52
|
const session = {
|
|
@@ -215,3 +261,68 @@ test('normalizeTaskError falls back only when the agent reports no reason at all
|
|
|
215
261
|
assert.equal(error.code, 'execution_rejected');
|
|
216
262
|
assert.equal(error.message, 'agent_execute rejected task');
|
|
217
263
|
});
|
|
264
|
+
|
|
265
|
+
test('dispatcher passes confirm=true to agent_execute for an approval-gated task', async () => {
|
|
266
|
+
let executeArgs;
|
|
267
|
+
const session = {
|
|
268
|
+
workspace: 'test',
|
|
269
|
+
mcp: {
|
|
270
|
+
production: {
|
|
271
|
+
tools: [{ name: 'agent_execute' }, { name: 'agent_status' }, { name: 'agent_cancel' }],
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
activities: {},
|
|
275
|
+
};
|
|
276
|
+
const dispatcher = createDispatcher({
|
|
277
|
+
session,
|
|
278
|
+
pollIntervalMs: 1,
|
|
279
|
+
callTool: async (_mcp, _server, tool, args) => {
|
|
280
|
+
if (tool === 'agent_execute') {
|
|
281
|
+
executeArgs = args;
|
|
282
|
+
return { accepted: true, jobId: 'job-ingest', status: 'queued' };
|
|
283
|
+
}
|
|
284
|
+
return { jobId: 'job-ingest', status: 'succeeded', terminal: true, result: { status: 'succeeded' } };
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
await dispatcher.execute(
|
|
289
|
+
{ id: 'ingest-a', requiredCapability: 'knowledge.update', operation: 'ingest_apply', arguments: {}, requiresApproval: true },
|
|
290
|
+
{ serverName: 'production', agentInstanceId: 'production-main' },
|
|
291
|
+
{ runId: 'run-donna-confirm', attempt: { attemptId: 'ingest-a:attempt-1', locks: [], release() {} } },
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
assert.equal(executeArgs.arguments.confirm, true, 'covered mutating task must carry confirm=true');
|
|
295
|
+
assert.equal(executeArgs.constraints.requireApprovalForMutations, true);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('dispatcher does not invent confirm=true for a non-gated task', async () => {
|
|
299
|
+
let executeArgs;
|
|
300
|
+
const session = {
|
|
301
|
+
workspace: 'test',
|
|
302
|
+
mcp: {
|
|
303
|
+
production: {
|
|
304
|
+
tools: [{ name: 'agent_execute' }, { name: 'agent_status' }, { name: 'agent_cancel' }],
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
activities: {},
|
|
308
|
+
};
|
|
309
|
+
const dispatcher = createDispatcher({
|
|
310
|
+
session,
|
|
311
|
+
pollIntervalMs: 1,
|
|
312
|
+
callTool: async (_mcp, _server, tool, args) => {
|
|
313
|
+
if (tool === 'agent_execute') {
|
|
314
|
+
executeArgs = args;
|
|
315
|
+
return { accepted: true, jobId: 'job-doctor', status: 'queued' };
|
|
316
|
+
}
|
|
317
|
+
return { jobId: 'job-doctor', status: 'succeeded', terminal: true, result: { status: 'succeeded' } };
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
await dispatcher.execute(
|
|
322
|
+
{ id: 'doctor-a', requiredCapability: 'workspace.diagnose', operation: 'doctor', arguments: {} },
|
|
323
|
+
{ serverName: 'production', agentInstanceId: 'production-main' },
|
|
324
|
+
{ runId: 'run-donna-doctor', attempt: { attemptId: 'doctor-a:attempt-1', locks: [], release() {} } },
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
assert.equal(executeArgs.arguments.confirm, undefined);
|
|
328
|
+
});
|
|
@@ -113,11 +113,10 @@ function resolveMentionedRegistryOperation(objective, candidates) {
|
|
|
113
113
|
// A capability with more than one operation may declare
|
|
114
114
|
// aliasOperations, mapping the specific alias phrase that matched to
|
|
115
115
|
// the operation it actually names. Without it, operations[0]
|
|
116
|
-
// (alphabetical) is a silent guess
|
|
117
|
-
//
|
|
118
|
-
// the
|
|
119
|
-
//
|
|
120
|
-
// making that operation structurally unreachable from natural language.
|
|
116
|
+
// (alphabetical) is a silent guess — a multi-operation capability's
|
|
117
|
+
// other operations become structurally unreachable from natural
|
|
118
|
+
// language, and the alphabetical first one may well be the destructive
|
|
119
|
+
// one.
|
|
121
120
|
const operation = candidate.aliasOperations?.[matchedAlias] ?? candidate.operations[0];
|
|
122
121
|
return { capability: candidate.id, operation };
|
|
123
122
|
})
|
|
@@ -176,7 +175,10 @@ function registrySnapshot(session) {
|
|
|
176
175
|
const registry = session?.capabilityRegistry;
|
|
177
176
|
if (registry?.snapshot) return registry.snapshot();
|
|
178
177
|
if (registry && typeof registry === 'object') return registry;
|
|
179
|
-
const agents =
|
|
178
|
+
const agents = [
|
|
179
|
+
...(session?.agentRegistry?.snapshot?.() ?? session?.agentRegistrySnapshot ?? []),
|
|
180
|
+
...(session?.runtimeProviderAgents ?? []),
|
|
181
|
+
];
|
|
180
182
|
const snapshot = {};
|
|
181
183
|
for (const agent of agents) {
|
|
182
184
|
for (const capability of agent?.description?.capabilities ?? []) {
|
|
@@ -187,6 +189,8 @@ function registrySnapshot(session) {
|
|
|
187
189
|
capability,
|
|
188
190
|
description: agent.description,
|
|
189
191
|
health: agent.health,
|
|
192
|
+
providerKind: agent.providerKind ?? null,
|
|
193
|
+
runtimeProvider: agent.runtimeProvider ?? null,
|
|
190
194
|
});
|
|
191
195
|
}
|
|
192
196
|
}
|
|
@@ -122,53 +122,52 @@ test('resolveObjective resolves diagnose via alias despite the notification "sen
|
|
|
122
122
|
assert.equal(result.operation, 'doctor');
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
-
const
|
|
126
|
-
operations: ['
|
|
127
|
-
aliases: ['
|
|
125
|
+
const snapshot = makeCapability('data.snapshot', {
|
|
126
|
+
operations: ['snapshot', 'prune'],
|
|
127
|
+
aliases: ['take a snapshot', 'prune snapshots', 'clean old snapshots'],
|
|
128
128
|
aliasOperations: {
|
|
129
|
-
'
|
|
130
|
-
'
|
|
131
|
-
'
|
|
129
|
+
'take a snapshot': 'snapshot',
|
|
130
|
+
'prune snapshots': 'prune',
|
|
131
|
+
'clean old snapshots': 'prune',
|
|
132
132
|
},
|
|
133
|
-
description: '
|
|
133
|
+
description: 'Take a snapshot of the workspace or prune old snapshots.',
|
|
134
134
|
});
|
|
135
135
|
|
|
136
136
|
/*
|
|
137
137
|
Regression: with two operations, [...new Set(supportedOperations)].sort()
|
|
138
|
-
alphabetizes to ["
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
first.
|
|
138
|
+
alphabetizes to ["prune", "snapshot"], so a naive alias hit defaulting to
|
|
139
|
+
operations[0] would ALWAYS resolve to "prune" — the destructive operation —
|
|
140
|
+
even for aliases explicitly authored to reach the safe "snapshot" one.
|
|
141
|
+
aliasOperations must be consulted first.
|
|
143
142
|
*/
|
|
144
|
-
test('resolveObjective routes "
|
|
145
|
-
const session = sessionWith([provider('production-1',
|
|
143
|
+
test('resolveObjective routes "prune snapshots" to prune, not operations[0]', async () => {
|
|
144
|
+
const session = sessionWith([provider('production-1', snapshot)]);
|
|
146
145
|
session.llm.completeWithTools = async () => {
|
|
147
146
|
throw new Error('the aliased operation must not depend on LLM selection');
|
|
148
147
|
};
|
|
149
|
-
const result = await resolveObjective('Please
|
|
150
|
-
assert.equal(result.capability, '
|
|
151
|
-
assert.equal(result.operation, '
|
|
148
|
+
const result = await resolveObjective('Please prune the old snapshots', session);
|
|
149
|
+
assert.equal(result.capability, 'data.snapshot');
|
|
150
|
+
assert.equal(result.operation, 'prune');
|
|
152
151
|
});
|
|
153
152
|
|
|
154
|
-
test('resolveObjective routes "
|
|
155
|
-
const session = sessionWith([provider('production-1',
|
|
153
|
+
test('resolveObjective routes "clean old snapshots" to prune', async () => {
|
|
154
|
+
const session = sessionWith([provider('production-1', snapshot)]);
|
|
156
155
|
session.llm.completeWithTools = async () => {
|
|
157
156
|
throw new Error('the aliased operation must not depend on LLM selection');
|
|
158
157
|
};
|
|
159
|
-
const result = await resolveObjective('
|
|
160
|
-
assert.equal(result.capability, '
|
|
161
|
-
assert.equal(result.operation, '
|
|
158
|
+
const result = await resolveObjective('Clean old snapshots of the workspace', session);
|
|
159
|
+
assert.equal(result.capability, 'data.snapshot');
|
|
160
|
+
assert.equal(result.operation, 'prune');
|
|
162
161
|
});
|
|
163
162
|
|
|
164
|
-
test('resolveObjective routes "
|
|
165
|
-
const session = sessionWith([provider('production-1',
|
|
163
|
+
test('resolveObjective routes "take a snapshot" to the snapshot operation', async () => {
|
|
164
|
+
const session = sessionWith([provider('production-1', snapshot)]);
|
|
166
165
|
session.llm.completeWithTools = async () => {
|
|
167
166
|
throw new Error('the aliased operation must not depend on LLM selection');
|
|
168
167
|
};
|
|
169
|
-
const result = await resolveObjective('
|
|
170
|
-
assert.equal(result.capability, '
|
|
171
|
-
assert.equal(result.operation, '
|
|
168
|
+
const result = await resolveObjective('Take a snapshot of the workspace', session);
|
|
169
|
+
assert.equal(result.capability, 'data.snapshot');
|
|
170
|
+
assert.equal(result.operation, 'snapshot');
|
|
172
171
|
});
|
|
173
172
|
|
|
174
173
|
test('resolveObjective falls back to operations[0] when a matched alias has no aliasOperations entry', async () => {
|