@dotdrelle/wiki-manager 0.12.11 → 0.14.0
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 +6 -0
- package/docker-compose.yml +1 -1
- package/package.json +1 -1
- package/src/agent/graph.js +377 -142
- package/src/agent/graph.test.js +576 -34
- package/src/agent/llm.js +5 -5
- package/src/cli/wiki-manager.js +294 -9
- package/src/cli/wiki-manager.test.js +28 -0
- package/src/commands/slash.js +80 -13
- package/src/commands/slash.test.js +9 -1
- package/src/contracts/schemas.js +33 -0
- package/src/contracts/schemas.test.js +14 -0
- package/src/core/agentEvents.js +6 -0
- package/src/core/agentEvents.test.js +26 -0
- package/src/core/agentLoop.js +15 -16
- package/src/core/agentLoop.test.js +9 -7
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +13 -6
- package/src/core/mcp.test.js +0 -12
- package/src/core/skills.js +0 -28
- package/src/orchestrator/capabilityRegistry.js +14 -0
- package/src/orchestrator/capabilityRegistry.test.js +12 -1
- package/src/orchestrator/dependencyResolver.js +10 -1
- package/src/orchestrator/dispatcher.js +34 -3
- package/src/orchestrator/dispatcher.test.js +34 -0
- package/src/orchestrator/objectiveResolver.js +79 -0
- package/src/orchestrator/objectiveResolver.test.js +50 -0
- package/src/orchestrator/scheduler.js +24 -0
- package/src/orchestrator/scheduler.test.js +65 -1
- package/src/runtime/client.js +34 -2
- package/src/runtime/lifecycle.js +1 -1
- package/src/runtime/recoveryManager.js +14 -7
- package/src/runtime/runner.js +214 -14
- package/src/runtime/runner.test.js +100 -2
- package/src/runtime/server.js +43 -3
- package/src/runtime/supervisor.js +65 -1
- package/src/runtime/supervisor.test.js +80 -0
- package/src/shell/LeftPane.tsx +9 -2
- package/src/shell/repl.js +57 -42
- package/src/shell/repl.test.js +81 -12
- package/src/shell/tui.tsx +26 -3
- package/src/shell/useSession.ts +15 -3
package/src/cli/wiki-manager.js
CHANGED
|
@@ -15,6 +15,8 @@ import { extractActivity, parseJsonText, sessionActivities, terminalFailures } f
|
|
|
15
15
|
import { syncActivitiesToPlan, formatPlanStatus } from '../core/plan.js';
|
|
16
16
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
17
17
|
import { runAgentTurn, runAgenticLoop } from '../core/agentLoop.js';
|
|
18
|
+
import { resolveCapabilityConcurrency } from '../orchestrator/scheduler.js';
|
|
19
|
+
import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
|
|
18
20
|
// Runtime modules use node:sqlite (Node.js built-in unavailable in Bun).
|
|
19
21
|
// They are imported dynamically so the shell / TUI path never loads them.
|
|
20
22
|
|
|
@@ -55,6 +57,11 @@ function createSession() {
|
|
|
55
57
|
};
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
export async function forwardRuntimeApproval(getWorkspaceContext, request = {}) {
|
|
61
|
+
const context = await getWorkspaceContext(request.workspace ?? null);
|
|
62
|
+
return context.approvalManager?.approve(request) ?? { approved: false };
|
|
63
|
+
}
|
|
64
|
+
|
|
58
65
|
function timestampForFile() {
|
|
59
66
|
return new Date().toISOString().replace(/[:.]/g, '-');
|
|
60
67
|
}
|
|
@@ -197,6 +204,109 @@ async function runHeadlessAgenticLoop(agent, session, initialInput, log, { timeo
|
|
|
197
204
|
return { exitCode: result.ok ? 0 : (result.waitResult?.exitCode ?? 1) };
|
|
198
205
|
}
|
|
199
206
|
|
|
207
|
+
// Observe a runtime-delegated run from headless: the run executes server-side,
|
|
208
|
+
// so poll /state and mirror status transitions, new logs and the final plan
|
|
209
|
+
// into the headless log until the run reaches a terminal state.
|
|
210
|
+
async function waitForRuntimeRun(session, log, { timeoutMs, pollMs = 1500, autoApprove = false, priorRunIds = [] } = {}) {
|
|
211
|
+
const { fetchRuntimeState, postRuntimeApprove } = await import('../runtime/client.js');
|
|
212
|
+
const url = session.runtime?.url;
|
|
213
|
+
const workspace = session.workspace ?? null;
|
|
214
|
+
if (!url) return { exitCode: 0 };
|
|
215
|
+
// Scope strictly to the run this turn created: any run already present before
|
|
216
|
+
// the turn (including a stuck/zombie run) must be ignored, or the wait would
|
|
217
|
+
// observe/approve the wrong run and never finish.
|
|
218
|
+
const priorSet = new Set((priorRunIds ?? []).map(String));
|
|
219
|
+
const terminal = new Set(['succeeded', 'success', 'done', 'complete', 'completed', 'failed', 'error', 'cancelled', 'canceled']);
|
|
220
|
+
const deadline = Date.now() + timeoutMs;
|
|
221
|
+
const graceDeadline = Date.now() + 8000;
|
|
222
|
+
let lastStatus = null;
|
|
223
|
+
let lastLogCount = 0;
|
|
224
|
+
let sawRun = false;
|
|
225
|
+
const approvedRevisions = new Set();
|
|
226
|
+
while (Date.now() < deadline) {
|
|
227
|
+
let state;
|
|
228
|
+
try {
|
|
229
|
+
state = await fetchRuntimeState({ url, workspace });
|
|
230
|
+
} catch (err) {
|
|
231
|
+
const line = `runtime-wait: state fetch failed (${err instanceof Error ? err.message : String(err)})`;
|
|
232
|
+
log.push(line); console.error(line);
|
|
233
|
+
return { exitCode: 1 };
|
|
234
|
+
}
|
|
235
|
+
const logs = Array.isArray(state?.logs) ? state.logs : [];
|
|
236
|
+
for (const entry of logs.slice(lastLogCount)) {
|
|
237
|
+
const text = typeof entry === 'string' ? entry : String(entry?.message ?? JSON.stringify(entry));
|
|
238
|
+
log.push(`runtime: ${text}`); console.log(`[runtime] ${text}`);
|
|
239
|
+
}
|
|
240
|
+
lastLogCount = logs.length;
|
|
241
|
+
const runs = Array.isArray(state?.runs) ? state.runs : [];
|
|
242
|
+
const currentRun = runs.find((run) => run?.id && !priorSet.has(String(run.id)));
|
|
243
|
+
if (!currentRun) {
|
|
244
|
+
if (sawRun) return { exitCode: 0 };
|
|
245
|
+
if (Date.now() >= graceDeadline) {
|
|
246
|
+
const line = 'runtime-wait: no run was delegated this turn (Donna answered without starting a run).';
|
|
247
|
+
log.push(line); console.log(line);
|
|
248
|
+
return { exitCode: 0 };
|
|
249
|
+
}
|
|
250
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
sawRun = true;
|
|
254
|
+
const status = String(currentRun.status ?? 'running').toLowerCase();
|
|
255
|
+
if (status !== lastStatus) {
|
|
256
|
+
log.push(`runtime-status: ${status} (run ${currentRun.id})`); console.log(`[runtime] status=${status}`);
|
|
257
|
+
lastStatus = status;
|
|
258
|
+
}
|
|
259
|
+
// Approval is granted per task, so the run status stays "running" while a
|
|
260
|
+
// task waits — detect the block via state.approvals, scoped to this run.
|
|
261
|
+
const pendingApprovals = (Array.isArray(state?.approvals) ? state.approvals : [])
|
|
262
|
+
.filter((approval) => approval.status === 'pending_approval'
|
|
263
|
+
&& (approval.runId == null || String(approval.runId) === String(currentRun.id)));
|
|
264
|
+
if (pendingApprovals.length > 0) {
|
|
265
|
+
if (!autoApprove) {
|
|
266
|
+
const line = `runtime-wait: run ${currentRun.id} waiting for approval (${pendingApprovals.length} task(s)); re-run with --auto-approve to drive it through.`;
|
|
267
|
+
log.push(line); console.log(line);
|
|
268
|
+
return { exitCode: 0 };
|
|
269
|
+
}
|
|
270
|
+
const planRevision = state?.planRevision ?? currentRun.planRevision ?? 0;
|
|
271
|
+
if (!approvedRevisions.has(planRevision)) {
|
|
272
|
+
approvedRevisions.add(planRevision);
|
|
273
|
+
const approvalClasses = [...new Set(pendingApprovals.flatMap((approval) => {
|
|
274
|
+
const value = approval.approvalClasses ?? approval.approvalClass ?? [];
|
|
275
|
+
return Array.isArray(value) ? value : [value];
|
|
276
|
+
}).map(String).filter(Boolean))];
|
|
277
|
+
try {
|
|
278
|
+
const result = await postRuntimeApprove({
|
|
279
|
+
url,
|
|
280
|
+
workspace,
|
|
281
|
+
runId: currentRun.id,
|
|
282
|
+
scope: 'run',
|
|
283
|
+
planRevision,
|
|
284
|
+
approvalClasses: approvalClasses.length > 0 ? approvalClasses : ['default'],
|
|
285
|
+
});
|
|
286
|
+
const line = `runtime-wait: auto-approved run ${currentRun.id} (revision ${planRevision})${result?.approved ? '' : ' [no pending approval matched]'}`;
|
|
287
|
+
log.push(line); console.log(line);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
const line = `runtime-wait: auto-approve failed (${err instanceof Error ? err.message : String(err)})`;
|
|
290
|
+
log.push(line); console.error(line);
|
|
291
|
+
return { exitCode: 1 };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (terminal.has(status)) {
|
|
296
|
+
const plan = Array.isArray(currentRun.plan) ? currentRun.plan
|
|
297
|
+
: (Array.isArray(state?.plan) ? state.plan : []);
|
|
298
|
+
if (plan.length > 0) {
|
|
299
|
+
log.push(`runtime-plan:\n${plan.map((planStep) => ` - ${planStep.description ?? planStep.id ?? planStep.step ?? ''}: ${planStep.status ?? ''}`).join('\n')}`);
|
|
300
|
+
}
|
|
301
|
+
return { exitCode: status === 'failed' || status === 'error' ? 1 : 0 };
|
|
302
|
+
}
|
|
303
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
304
|
+
}
|
|
305
|
+
const line = 'runtime-wait: timeout waiting for the delegated run to finish.';
|
|
306
|
+
log.push(line); console.error(line);
|
|
307
|
+
return { exitCode: 1 };
|
|
308
|
+
}
|
|
309
|
+
|
|
200
310
|
async function runHeadless(argv, agent) {
|
|
201
311
|
const workspaceName = valueAfter(argv, '--workspace');
|
|
202
312
|
const skillName = valueAfter(argv, '--skill');
|
|
@@ -228,6 +338,37 @@ async function runHeadless(argv, agent) {
|
|
|
228
338
|
if (!session.workspacePath) throw new Error(useResult.output || `Workspace not loaded: ${workspaceName}`);
|
|
229
339
|
if (!session.llm) throw new Error(`Workspace ${workspaceName} has no usable LLM config.`);
|
|
230
340
|
|
|
341
|
+
// Agent-mode parity: the interactive TUI runs every turn against the
|
|
342
|
+
// runtime (delegation + run control) with MCP connected. Without a
|
|
343
|
+
// runtime, the graph exposes no runtime__delegate tool, so any action
|
|
344
|
+
// request degrades to a chat-like text answer instead of a real delegated
|
|
345
|
+
// run — which is exactly why headless "looked like chat mode". Connect the
|
|
346
|
+
// same way the TUI does. Use --no-runtime for the legacy direct-MCP path.
|
|
347
|
+
if (!argv.includes('--no-runtime')) {
|
|
348
|
+
try {
|
|
349
|
+
const { ensureRuntime } = await import('../runtime/lifecycle.js');
|
|
350
|
+
const runtime = await ensureRuntime();
|
|
351
|
+
session.runtime = runtime?.url ? { url: runtime.url, started: Boolean(runtime.started) } : null;
|
|
352
|
+
step(`runtime: ${session.runtime ? `connected ${session.runtime.url}` : 'unavailable'}`);
|
|
353
|
+
} catch (err) {
|
|
354
|
+
session.runtime = null;
|
|
355
|
+
step(`runtime: unavailable (${err instanceof Error ? err.message : String(err)})`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
await refreshMcpRuntimeStatus(session);
|
|
359
|
+
step(`mcp: ${Object.values(session.mcp ?? {}).filter((value) => value.status === 'connected').length} connected`);
|
|
360
|
+
// Surface which tools Donna is actually offered this turn: if a factual
|
|
361
|
+
// question is answered without the matching read tool appearing here, the
|
|
362
|
+
// problem is discovery/connection, not the model.
|
|
363
|
+
for (const [name, value] of Object.entries(session.mcp ?? {})) {
|
|
364
|
+
if (value?.status !== 'connected') continue;
|
|
365
|
+
const toolNames = (value.tools ?? []).map((tool) => tool.name).join(', ');
|
|
366
|
+
step(`mcp-tools ${name}: ${toolNames || '(none discovered)'}`);
|
|
367
|
+
}
|
|
368
|
+
// Wire the graph's step trace (classification, tool calls, retries) into
|
|
369
|
+
// the headless log so the agent-mode decision is observable.
|
|
370
|
+
session._onStep = step;
|
|
371
|
+
|
|
231
372
|
let input = prompt;
|
|
232
373
|
if (skillName) {
|
|
233
374
|
const skillResult = await handleSlashCommand(`/skills run ${skillName}`, { packageJson, session, onStep: step });
|
|
@@ -259,11 +400,26 @@ async function runHeadless(argv, agent) {
|
|
|
259
400
|
if (useAgenticLoop) {
|
|
260
401
|
({ exitCode } = await runHeadlessAgenticLoop(agent, session, input, log, { timeoutMs, maxTurns }));
|
|
261
402
|
} else {
|
|
403
|
+
// Snapshot existing runs so the wait scopes strictly to the run this turn
|
|
404
|
+
// creates and never observes a pre-existing / zombie run.
|
|
405
|
+
let priorRunIds = [];
|
|
406
|
+
if (session.runtime?.url && wait) {
|
|
407
|
+
try {
|
|
408
|
+
const { fetchRuntimeState } = await import('../runtime/client.js');
|
|
409
|
+
const before = await fetchRuntimeState({ url: session.runtime.url, workspace: session.workspace ?? null });
|
|
410
|
+
priorRunIds = (Array.isArray(before?.runs) ? before.runs : []).map((run) => run?.id).filter(Boolean);
|
|
411
|
+
} catch { priorRunIds = []; }
|
|
412
|
+
}
|
|
262
413
|
const response = await runAgentTurn(agent, session, input);
|
|
263
414
|
log.push('response:');
|
|
264
415
|
log.push(response);
|
|
265
416
|
console.log(response);
|
|
266
|
-
|
|
417
|
+
// When connected to the runtime, an action turn delegates a run that
|
|
418
|
+
// executes server-side — its progress lives in runtime state, not in the
|
|
419
|
+
// local session. Poll it so the headless log shows the real outcome.
|
|
420
|
+
({ exitCode } = session.runtime?.url && wait
|
|
421
|
+
? await waitForRuntimeRun(session, log, { timeoutMs, autoApprove: argv.includes('--auto-approve'), priorRunIds })
|
|
422
|
+
: await runHeadlessActivityLoop(session, log, { wait, timeoutMs }));
|
|
267
423
|
}
|
|
268
424
|
const saved = await writeHeadlessLog(session, log, logFile);
|
|
269
425
|
console.log(`Headless log: ${saved}`);
|
|
@@ -590,6 +746,55 @@ async function runRuntime(argv, agent) {
|
|
|
590
746
|
};
|
|
591
747
|
}
|
|
592
748
|
|
|
749
|
+
async function prepareDelegation(context, { objective }) {
|
|
750
|
+
const { resolveObjective } = await import('../orchestrator/objectiveResolver.js');
|
|
751
|
+
const { validateFragment } = await import('../orchestrator/planValidator.js');
|
|
752
|
+
const session = context.session;
|
|
753
|
+
const selection = await resolveObjective(objective, session);
|
|
754
|
+
const provider = selection.provider;
|
|
755
|
+
const fragment = parseJsonText(formatMcpToolResult(await callMcpTool(
|
|
756
|
+
session.mcp,
|
|
757
|
+
provider.serverName,
|
|
758
|
+
'agent_plan',
|
|
759
|
+
{
|
|
760
|
+
capability: selection.capability,
|
|
761
|
+
operation: selection.operation,
|
|
762
|
+
objective,
|
|
763
|
+
workspace: { revision: String(Date.now()) },
|
|
764
|
+
constraints: {
|
|
765
|
+
maxConcurrency: resolveCapabilityConcurrency(
|
|
766
|
+
provider,
|
|
767
|
+
undefined,
|
|
768
|
+
process.env.WIKI_MANAGER_CAPABILITY_CONCURRENCY,
|
|
769
|
+
),
|
|
770
|
+
requireApprovalForMutations: true,
|
|
771
|
+
},
|
|
772
|
+
},
|
|
773
|
+
)));
|
|
774
|
+
if (!Array.isArray(fragment?.tasks) || fragment.tasks.length === 0) {
|
|
775
|
+
throw new Error(fragment?.summary?.initialSynthesis?.[0] ?? `No task was planned for ${selection.capability}/${selection.operation}.`);
|
|
776
|
+
}
|
|
777
|
+
const validation = validateFragment(fragment, {
|
|
778
|
+
registry: capabilityRegistryForSession(session),
|
|
779
|
+
run: { plannerAgentInstanceId: provider.agentInstanceId ?? provider.serverName },
|
|
780
|
+
});
|
|
781
|
+
if (!validation.ok) {
|
|
782
|
+
throw new Error(`Delegated plan rejected: ${validation.errors.map((error) => error.message ?? error.code ?? String(error)).join('; ')}`);
|
|
783
|
+
}
|
|
784
|
+
return {
|
|
785
|
+
capability: selection.capability,
|
|
786
|
+
operation: selection.operation,
|
|
787
|
+
provider: { serverName: provider.serverName, agentInstanceId: provider.agentInstanceId ?? provider.serverName },
|
|
788
|
+
fragment: validation.normalizedFragment,
|
|
789
|
+
summary: {
|
|
790
|
+
capability: selection.capability,
|
|
791
|
+
operation: selection.operation,
|
|
792
|
+
agent: provider.agentInstanceId ?? provider.serverName,
|
|
793
|
+
tasks: validation.normalizedFragment.tasks.length,
|
|
794
|
+
},
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
593
798
|
async function executeRun(context, body, { signal } = {}) {
|
|
594
799
|
const session = context.session;
|
|
595
800
|
const supervisor = context.supervisor;
|
|
@@ -628,6 +833,87 @@ async function runRuntime(argv, agent) {
|
|
|
628
833
|
: undefined;
|
|
629
834
|
supervisor?.setRunSignal(signal);
|
|
630
835
|
session._onStep = (message) => emitRuntimeLog(session, message);
|
|
836
|
+
if (body.preparedDelegation?.fragment) {
|
|
837
|
+
const { integrate } = await import('../orchestrator/planIntegrator.js');
|
|
838
|
+
const prepared = body.preparedDelegation;
|
|
839
|
+
const integrated = integrate(runId, prepared.fragment, {
|
|
840
|
+
registry: capabilityRegistryForSession(session),
|
|
841
|
+
session,
|
|
842
|
+
store,
|
|
843
|
+
workspace: session.workspace ?? null,
|
|
844
|
+
enforceApprovalCoverage: true,
|
|
845
|
+
});
|
|
846
|
+
if (!integrated.ok) {
|
|
847
|
+
throw new Error(`Delegated plan integration failed: ${(integrated.errors ?? []).map((error) => error.message ?? error.code ?? String(error)).join('; ')}`);
|
|
848
|
+
}
|
|
849
|
+
emitRuntimeLog(session, `delegation: ${prepared.fragment.tasks.length} validated task(s) integrated from ${prepared.provider.serverName}.agent_plan (${prepared.capability}/${prepared.operation})`);
|
|
850
|
+
body._planReady?.resolve?.({ runId, planRevision: session.agentProjection?.planRevision ?? 0 });
|
|
851
|
+
}
|
|
852
|
+
// Deterministic capability run (/ingest): ask the capable agent for its
|
|
853
|
+
// task-graph fragment and integrate it as the plan BEFORE any LLM turn.
|
|
854
|
+
// The parallel path must not depend on a small model deciding to call
|
|
855
|
+
// agent_plan by itself.
|
|
856
|
+
if (body.capabilityPlan?.capability) {
|
|
857
|
+
const { validateFragment } = await import('../orchestrator/planValidator.js');
|
|
858
|
+
const { integrate } = await import('../orchestrator/planIntegrator.js');
|
|
859
|
+
const registry = capabilityRegistryForSession(session);
|
|
860
|
+
const agents = session.agentRegistry?.snapshot?.() ?? session.agentRegistrySnapshot ?? [];
|
|
861
|
+
const provider = agents.find((item) => (item.description?.capabilities ?? [])
|
|
862
|
+
.some((capability) => capability.id === body.capabilityPlan.capability));
|
|
863
|
+
if (!provider?.serverName) {
|
|
864
|
+
throw new Error(`No agent provides capability ${body.capabilityPlan.capability}.`);
|
|
865
|
+
}
|
|
866
|
+
const fragment = parseJsonText(formatMcpToolResult(await callMcpTool(session.mcp, provider.serverName, 'agent_plan', {
|
|
867
|
+
capability: body.capabilityPlan.capability,
|
|
868
|
+
operation: body.capabilityPlan.operation ?? undefined,
|
|
869
|
+
workspace: { revision: String(Date.now()) },
|
|
870
|
+
constraints: {
|
|
871
|
+
// The agent declares its capacity. Request/env values are only
|
|
872
|
+
// constraints: they may lower that capacity, never raise it.
|
|
873
|
+
maxConcurrency: resolveCapabilityConcurrency(
|
|
874
|
+
provider,
|
|
875
|
+
body.capabilityPlan.maxConcurrency,
|
|
876
|
+
process.env.WIKI_MANAGER_CAPABILITY_CONCURRENCY,
|
|
877
|
+
),
|
|
878
|
+
requireApprovalForMutations: body.capabilityPlan.requireApproval !== false,
|
|
879
|
+
},
|
|
880
|
+
...(Array.isArray(body.capabilityPlan.inputs) && body.capabilityPlan.inputs.length > 0
|
|
881
|
+
? { arguments: { inputs: body.capabilityPlan.inputs } }
|
|
882
|
+
: {}),
|
|
883
|
+
})));
|
|
884
|
+
if (!Array.isArray(fragment?.tasks) || fragment.tasks.length === 0) {
|
|
885
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_message', {
|
|
886
|
+
origin: 'runtime',
|
|
887
|
+
runId,
|
|
888
|
+
payload: { content: `Aucune tâche à planifier pour ${body.capabilityPlan.capability} (${fragment?.summary?.initialSynthesis?.[0] ?? 'fragment vide'}).` },
|
|
889
|
+
}));
|
|
890
|
+
dispatchAgentEvent(session, createAgentEvent('run_done', { origin: 'runtime', runId, payload: { runId } }));
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
// Full official integration path — NOT a bare plan_set: integrate()
|
|
894
|
+
// validates the fragment, persists the tasks, and CREATES the
|
|
895
|
+
// approval requests the scheduler's approvalCovered() filter waits
|
|
896
|
+
// for. A bare plan_set left requiresApproval tasks unreachable
|
|
897
|
+
// forever (stalled as no_ready_plan_task with nothing to approve).
|
|
898
|
+
const validation = validateFragment(fragment, {
|
|
899
|
+
registry,
|
|
900
|
+
run: { plannerAgentInstanceId: provider.agentInstanceId ?? provider.serverName },
|
|
901
|
+
});
|
|
902
|
+
if (!validation.ok) {
|
|
903
|
+
throw new Error(`Capability plan rejected: ${validation.errors.map((error) => error.message ?? error.code ?? String(error)).join('; ')}`);
|
|
904
|
+
}
|
|
905
|
+
const integrated = integrate(runId, validation.normalizedFragment, {
|
|
906
|
+
registry,
|
|
907
|
+
session,
|
|
908
|
+
store,
|
|
909
|
+
workspace: session.workspace ?? null,
|
|
910
|
+
enforceApprovalCoverage: true,
|
|
911
|
+
});
|
|
912
|
+
if (!integrated.ok) {
|
|
913
|
+
throw new Error(`Capability plan integration failed: ${(integrated.errors ?? []).map((error) => error.message ?? error.code ?? String(error)).join('; ')}`);
|
|
914
|
+
}
|
|
915
|
+
emitRuntimeLog(session, `capability-plan: ${fragment.tasks.length} task(s) integrated from ${provider.serverName}.agent_plan (${body.capabilityPlan.capability}); approvals: ${(session.agentProjection?.approvals ?? []).filter((approval) => approval.status === 'pending_approval').length} pending`);
|
|
916
|
+
}
|
|
631
917
|
await runRuntimeAgenticWorkflow(agent, session, input, {
|
|
632
918
|
signal,
|
|
633
919
|
timeoutMs,
|
|
@@ -638,6 +924,7 @@ async function runRuntime(argv, agent) {
|
|
|
638
924
|
...(maxReplans === undefined ? {} : { maxReplans }),
|
|
639
925
|
});
|
|
640
926
|
} catch (err) {
|
|
927
|
+
body._planReady?.reject?.(err);
|
|
641
928
|
if (err?.name === 'AbortError') {
|
|
642
929
|
// Cancel the asynchronous agent jobs the run started: aborting only
|
|
643
930
|
// the manager loop left ingest subprocesses running for minutes with
|
|
@@ -681,12 +968,10 @@ async function runRuntime(argv, agent) {
|
|
|
681
968
|
.filter((context) => context?.running)
|
|
682
969
|
.map((context) => ({ workspace: context.workspace ?? null, runId: context.currentRunId ?? null })),
|
|
683
970
|
run: executeRun,
|
|
971
|
+
delegate: prepareDelegation,
|
|
684
972
|
cancel: (context) => emitRuntimeLog(context.session, 'runtime: cancel requested'),
|
|
685
973
|
resume: ({ workspace }) => recoverRuntime({ workspace, manual: true }),
|
|
686
|
-
approve:
|
|
687
|
-
const context = await getWorkspaceContext(workspace);
|
|
688
|
-
return context.approvalManager?.approve({ runId, itemId, approvalId }) ?? { approved: false };
|
|
689
|
-
},
|
|
974
|
+
approve: (request) => forwardRuntimeApproval(getWorkspaceContext, request),
|
|
690
975
|
configProfiles: async (context) => {
|
|
691
976
|
const profiles = listWikircProfiles(context.session.workspacePath);
|
|
692
977
|
return {
|
|
@@ -811,11 +1096,11 @@ export async function runCli(argv) {
|
|
|
811
1096
|
runtime = unavailableRuntime(err);
|
|
812
1097
|
console.error(`Runtime unavailable: ${runtime.error}`);
|
|
813
1098
|
}
|
|
1099
|
+
// The owned-runtime shutdown happens inside the TUI's own exit paths
|
|
1100
|
+
// (see tui.tsx onShellExit): render() resolves at MOUNT, so anything
|
|
1101
|
+
// after this await would run while the shell is still on screen —
|
|
1102
|
+
// 0.12.9 shipped exactly that bug and killed the runtime under the user.
|
|
814
1103
|
await runOpenTuiShell({ agent, packageJson, runtime });
|
|
815
|
-
if (runtime?.url) {
|
|
816
|
-
const { shutdownOwnedRuntime } = await import('../runtime/lifecycle.js');
|
|
817
|
-
await shutdownOwnedRuntime(runtime, { log: (message) => console.log(`[wiki-manager] ${message}`) });
|
|
818
|
-
}
|
|
819
1104
|
return;
|
|
820
1105
|
}
|
|
821
1106
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { forwardRuntimeApproval } from './wiki-manager.js';
|
|
4
|
+
|
|
5
|
+
test('runtime approval bridge preserves the complete run-scoped grant', async () => {
|
|
6
|
+
let forwarded = null;
|
|
7
|
+
const request = {
|
|
8
|
+
workspace: 'test4',
|
|
9
|
+
workspaceId: 'test4',
|
|
10
|
+
runId: 'run-1',
|
|
11
|
+
scope: 'run',
|
|
12
|
+
planRevision: 3,
|
|
13
|
+
approvalClasses: ['mutation'],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const result = await forwardRuntimeApproval(async (workspace) => ({
|
|
17
|
+
approvalManager: {
|
|
18
|
+
approve(value) {
|
|
19
|
+
assert.equal(workspace, 'test4');
|
|
20
|
+
forwarded = value;
|
|
21
|
+
return { approved: true };
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
}), request);
|
|
25
|
+
|
|
26
|
+
assert.deepEqual(forwarded, request);
|
|
27
|
+
assert.deepEqual(result, { approved: true });
|
|
28
|
+
});
|
package/src/commands/slash.js
CHANGED
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
listDocumentUploads,
|
|
44
44
|
storeAndMaybeConvertDocument,
|
|
45
45
|
} from '../core/documentIntake.js';
|
|
46
|
-
import { fetchRuntimeState, postRuntimeCancel, postRuntimeKill } from '../runtime/client.js';
|
|
46
|
+
import { fetchRuntimeState, postRuntimeCancel, postRuntimeControl, postRuntimeKill, postRuntimeRun } from '../runtime/client.js';
|
|
47
47
|
import { versionWithBuild } from '../core/buildInfo.js';
|
|
48
48
|
|
|
49
49
|
export function printVersion(packageJson) {
|
|
@@ -629,6 +629,8 @@ ${helpPair('/wiki', 'Run wiki index', '/wiki run <args>', 'Raw wiki CLI')}
|
|
|
629
629
|
${helpPair('/chat', 'Chat mode', '/agent', 'Agent mode')}
|
|
630
630
|
${helpPair('/openui', 'Open web UI in browser', '', '')}
|
|
631
631
|
${helpPair('/run status', 'Runtime status', '/run kill', 'Kill runtime run(s)')}
|
|
632
|
+
${helpPair('/run capability <id>', 'Deterministic capability run', '/approve', 'Grant pending approval')}
|
|
633
|
+
${helpPair('/cancel', 'Cancel active run', '', '')}
|
|
632
634
|
${helpPair('/run cancel', 'Cancel active run', '', '')}
|
|
633
635
|
${helpPair('/queue', 'MCP job queue', '/queue clear', 'Clear finished')}
|
|
634
636
|
${helpPair('/queue cancel <id>', 'Cancel queued/running', '', '')}
|
|
@@ -674,6 +676,20 @@ function rawCommandResult(command, output) {
|
|
|
674
676
|
};
|
|
675
677
|
}
|
|
676
678
|
|
|
679
|
+
export function localizedOperationResult({ operation, target, status = 'succeeded' }) {
|
|
680
|
+
const facts = JSON.stringify({ operation, target, status });
|
|
681
|
+
return {
|
|
682
|
+
output: facts,
|
|
683
|
+
rawOutput: true,
|
|
684
|
+
agentTrigger: [
|
|
685
|
+
'Formule le résultat structuré suivant dans la langue et le ton demandés par le profil du workspace.',
|
|
686
|
+
'Réponds par une seule phrase humaine et naturelle.',
|
|
687
|
+
'Ne mentionne aucune commande, syntaxe shell, étape suivante ou détail technique.',
|
|
688
|
+
`Résultat: ${facts}`,
|
|
689
|
+
].join('\n'),
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
677
693
|
function formatRuntimeRunStatus(state) {
|
|
678
694
|
const status = state?.status ?? 'unknown';
|
|
679
695
|
const runId = state?.runId ? ` run=${state.runId}` : '';
|
|
@@ -709,13 +725,14 @@ export async function handleSlashCommand(line, context) {
|
|
|
709
725
|
const args = line.slice(1).trim().split(/\s+/).filter(Boolean);
|
|
710
726
|
const [command] = args;
|
|
711
727
|
const step = context.onStep ?? (() => {});
|
|
712
|
-
const runAgentCommand = async (fn, verb
|
|
728
|
+
const runAgentCommand = async (fn, verb) => {
|
|
713
729
|
try {
|
|
714
730
|
step(`Agents: ${verb}ing external agents…`);
|
|
715
|
-
|
|
716
|
-
return
|
|
717
|
-
|
|
718
|
-
:
|
|
731
|
+
await fn();
|
|
732
|
+
return localizedOperationResult({
|
|
733
|
+
operation: verb,
|
|
734
|
+
target: 'agents',
|
|
735
|
+
});
|
|
719
736
|
} catch (err) {
|
|
720
737
|
step(formatActivityError('agents', verb, err));
|
|
721
738
|
return { output: err instanceof Error ? err.message : String(err) };
|
|
@@ -874,13 +891,16 @@ export async function handleSlashCommand(line, context) {
|
|
|
874
891
|
// do not remap it to undefined, that bypasses any custom "all" target list and always
|
|
875
892
|
// falls back to the hardcoded COMPOSE_SERVICES constant instead.
|
|
876
893
|
const service = args[1];
|
|
877
|
-
if (service === 'agents') return runAgentCommand(startAgents, 'start'
|
|
894
|
+
if (service === 'agents') return runAgentCommand(startAgents, 'start');
|
|
878
895
|
try {
|
|
879
896
|
step(`Services: starting ${service ?? 'workspace services'}…`);
|
|
880
|
-
|
|
897
|
+
await startService(context.session, service);
|
|
881
898
|
step('Services: refreshing MCP runtime…');
|
|
882
899
|
await refreshMcpRuntimeStatus(context.session);
|
|
883
|
-
return
|
|
900
|
+
return localizedOperationResult({
|
|
901
|
+
operation: 'start',
|
|
902
|
+
target: service || 'workspace-services',
|
|
903
|
+
});
|
|
884
904
|
} catch (err) {
|
|
885
905
|
const message = err instanceof Error ? err.message : String(err);
|
|
886
906
|
step(formatActivityError('services', 'stop', err));
|
|
@@ -889,13 +909,16 @@ export async function handleSlashCommand(line, context) {
|
|
|
889
909
|
}
|
|
890
910
|
case 'stop': {
|
|
891
911
|
const service = args[1];
|
|
892
|
-
if (service === 'agents') return runAgentCommand(stopAgents, 'stop'
|
|
912
|
+
if (service === 'agents') return runAgentCommand(stopAgents, 'stop');
|
|
893
913
|
try {
|
|
894
914
|
step(`Services: stopping ${service ?? 'workspace services'}…`);
|
|
895
|
-
|
|
915
|
+
await stopService(context.session, service);
|
|
896
916
|
step('Services: refreshing MCP runtime…');
|
|
897
917
|
await refreshMcpRuntimeStatus(context.session);
|
|
898
|
-
return
|
|
918
|
+
return localizedOperationResult({
|
|
919
|
+
operation: 'stop',
|
|
920
|
+
target: service || 'workspace-services',
|
|
921
|
+
});
|
|
899
922
|
} catch (err) {
|
|
900
923
|
const message = err instanceof Error ? err.message : String(err);
|
|
901
924
|
step(formatActivityError('services', 'logs', err));
|
|
@@ -966,6 +989,27 @@ export async function handleSlashCommand(line, context) {
|
|
|
966
989
|
}
|
|
967
990
|
return { output: 'Usage: /mcp <status|endpoints|tools|call> [mcp]' };
|
|
968
991
|
}
|
|
992
|
+
case 'cancel': {
|
|
993
|
+
// Alias of /run cancel — people type /cancel when they want out.
|
|
994
|
+
const runtime = context.runtime ?? {};
|
|
995
|
+
if (!runtime.url) return { output: 'Runtime unavailable. Start/connect the runtime before using /cancel.' };
|
|
996
|
+
const result = await postRuntimeCancel({ url: runtime.url, workspace: context.session.workspace ?? null });
|
|
997
|
+
return { output: result.cancelled ? 'Runtime cancel requested.' : `Nothing to cancel${result.reason ? ` (${result.reason})` : ''} — use /run kill to purge everything.` };
|
|
998
|
+
}
|
|
999
|
+
case 'approve': {
|
|
1000
|
+
// /approve was only wired in the legacy REPL — in the opentui TUI it
|
|
1001
|
+
// returned "Unknown command", which made every approval time out and
|
|
1002
|
+
// every requiresApproval plan stall forever.
|
|
1003
|
+
const runtime = context.runtime ?? {};
|
|
1004
|
+
if (!runtime.url) return { output: 'Runtime unavailable. Start/connect the runtime before using /approve.' };
|
|
1005
|
+
const result = await postRuntimeControl('message', {
|
|
1006
|
+
url: runtime.url,
|
|
1007
|
+
workspace: context.session.workspace ?? null,
|
|
1008
|
+
input: args.slice(1).join(' ') || 'approve',
|
|
1009
|
+
intent: 'approve',
|
|
1010
|
+
});
|
|
1011
|
+
return { output: String(result?.explanation ?? (result?.accepted ? 'Approval granted.' : 'No pending approval found.')) };
|
|
1012
|
+
}
|
|
969
1013
|
case 'run': {
|
|
970
1014
|
const subcommand = args[1] ?? 'status';
|
|
971
1015
|
const runtime = context.runtime ?? {};
|
|
@@ -979,11 +1023,34 @@ export async function handleSlashCommand(line, context) {
|
|
|
979
1023
|
const result = await postRuntimeCancel({ url, workspace: context.session.workspace ?? null });
|
|
980
1024
|
return { output: result.cancelled ? 'Runtime cancel requested.' : `Runtime cancel skipped: ${result.reason ?? 'no active run'}` };
|
|
981
1025
|
}
|
|
1026
|
+
if (subcommand === 'capability') {
|
|
1027
|
+
// Business-agnostic deterministic run: mirrors the capability
|
|
1028
|
+
// registry instead of hardcoding an application verb. The agent's
|
|
1029
|
+
// task graph is validated/integrated server-side before any LLM turn.
|
|
1030
|
+
const capability = args[2];
|
|
1031
|
+
if (!capability) return { output: 'Usage: /run capability <capability-id> [operation] [files…]' };
|
|
1032
|
+
if (!context.session.workspace) return { output: 'No workspace loaded. Use /use <workspace> first.' };
|
|
1033
|
+
const operation = args[3] && !args[3].includes('.') && !args[3].includes('/') ? args[3] : undefined;
|
|
1034
|
+
const inputs = args.slice(operation ? 4 : 3);
|
|
1035
|
+
const result = await postRuntimeRun(`Run de capability ${capability}${operation ? ` (${operation})` : ''} demandé via /run capability.`, {
|
|
1036
|
+
url,
|
|
1037
|
+
workspace: context.session.workspace,
|
|
1038
|
+
capabilityPlan: {
|
|
1039
|
+
capability,
|
|
1040
|
+
...(operation ? { operation } : {}),
|
|
1041
|
+
...(inputs.length > 0 ? { inputs } : {}),
|
|
1042
|
+
},
|
|
1043
|
+
});
|
|
1044
|
+
if (result?.runId) {
|
|
1045
|
+
return { output: `▶ Run de capability accepté (${String(result.runId).slice(0, 8)}) — le plan de l'agent sera intégré et dispatché en parallèle ; approbation demandée avant les mutations (« valide tout » ou /approve).` };
|
|
1046
|
+
}
|
|
1047
|
+
return { output: `Run non démarré: ${result?.explanation ?? result?.error ?? JSON.stringify(result)}` };
|
|
1048
|
+
}
|
|
982
1049
|
if (subcommand === 'kill') {
|
|
983
1050
|
const result = await postRuntimeKill({ url, workspace: context.session.workspace ?? null, runId: args[2] ?? null });
|
|
984
1051
|
return { output: `Runtime kill requested: ${result.runs ?? 0} run${result.runs === 1 ? '' : 's'}, ${result.tasks ?? 0} task${result.tasks === 1 ? '' : 's'} cancelled.` };
|
|
985
1052
|
}
|
|
986
|
-
return { output: 'Usage: /run [status|cancel|kill [runId]]' };
|
|
1053
|
+
return { output: 'Usage: /run [status|cancel|kill [runId]|capability <id> [operation] [files…]]' };
|
|
987
1054
|
}
|
|
988
1055
|
case 'queue': {
|
|
989
1056
|
const subcommand = args[1] ?? 'list';
|
|
@@ -4,9 +4,17 @@ import { mkdtemp } from 'node:fs/promises';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import test from 'node:test';
|
|
7
|
-
import { handleSlashCommand } from './slash.js';
|
|
7
|
+
import { handleSlashCommand, localizedOperationResult } from './slash.js';
|
|
8
8
|
import { completionContext } from '../shell/repl.js';
|
|
9
9
|
|
|
10
|
+
test('deterministic operation results ask Donna to localize compact facts without leaking commands', () => {
|
|
11
|
+
const result = localizedOperationResult({ operation: 'start', target: 'agents' });
|
|
12
|
+
assert.equal(result.rawOutput, true);
|
|
13
|
+
assert.deepEqual(JSON.parse(result.output), { operation: 'start', target: 'agents', status: 'succeeded' });
|
|
14
|
+
assert.match(result.agentTrigger, /une seule phrase humaine et naturelle/);
|
|
15
|
+
assert.doesNotMatch(result.agentTrigger, /\/start|Docker|compose/);
|
|
16
|
+
});
|
|
17
|
+
|
|
10
18
|
test('/workspace delete removes files and clears current session context after confirmation', async () => {
|
|
11
19
|
const root = await mkdtemp(join(tmpdir(), 'wiki-manager-delete-workspace-'));
|
|
12
20
|
const registryRoot = join(root, 'registry');
|
package/src/contracts/schemas.js
CHANGED
|
@@ -131,6 +131,38 @@ const agentDescriptionSchema = {
|
|
|
131
131
|
},
|
|
132
132
|
};
|
|
133
133
|
|
|
134
|
+
const pendingInputSchema = {
|
|
135
|
+
$id: 'https://dotdrelle.dev/wiki-manager/contracts/pending-input/v1',
|
|
136
|
+
title: 'PendingInput',
|
|
137
|
+
schemaVersion: '1',
|
|
138
|
+
type: 'object',
|
|
139
|
+
required: ['type', 'ref'],
|
|
140
|
+
additionalProperties: true,
|
|
141
|
+
properties: {
|
|
142
|
+
type: { type: 'string', minLength: 1 },
|
|
143
|
+
ref: { type: 'string', minLength: 1 },
|
|
144
|
+
label: nullableString,
|
|
145
|
+
mediaType: nullableString,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const capabilityStatusSchema = {
|
|
150
|
+
$id: 'https://dotdrelle.dev/wiki-manager/contracts/capability-status/v1',
|
|
151
|
+
title: 'CapabilityStatus',
|
|
152
|
+
schemaVersion: '1',
|
|
153
|
+
type: 'object',
|
|
154
|
+
required: ['contractVersion', 'agentInstanceId', 'capability', 'operation', 'available', 'pendingInputs'],
|
|
155
|
+
additionalProperties: true,
|
|
156
|
+
properties: {
|
|
157
|
+
contractVersion: { type: 'string', minLength: 1 },
|
|
158
|
+
agentInstanceId: { type: 'string', minLength: 1 },
|
|
159
|
+
capability: { type: 'string', minLength: 1 },
|
|
160
|
+
operation: { type: 'string', minLength: 1 },
|
|
161
|
+
available: { type: 'boolean' },
|
|
162
|
+
pendingInputs: { type: 'array', items: pendingInputSchema },
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
134
166
|
const taskGroupSchema = {
|
|
135
167
|
$id: 'https://dotdrelle.dev/wiki-manager/contracts/task-group/v1',
|
|
136
168
|
title: 'TaskGroup',
|
|
@@ -424,6 +456,7 @@ export const contractSchemas = {
|
|
|
424
456
|
outputReference: outputReferenceSchema,
|
|
425
457
|
capabilityDescription: capabilityDescriptionSchema,
|
|
426
458
|
agentDescription: agentDescriptionSchema,
|
|
459
|
+
capabilityStatus: capabilityStatusSchema,
|
|
427
460
|
retryPolicy: retryPolicySchema,
|
|
428
461
|
taskGroup: taskGroupSchema,
|
|
429
462
|
plannedTask: plannedTaskSchema,
|
|
@@ -236,3 +236,17 @@ test('agent description contract validates orchestrable agent capabilities', ()
|
|
|
236
236
|
assert.equal(validateContract('capabilityDescription', description.capabilities[0]).ok, true);
|
|
237
237
|
assert.equal(validateContract('agentDescription', { ...description, health: { status: 'offline' } }).ok, false);
|
|
238
238
|
});
|
|
239
|
+
|
|
240
|
+
test('capability status contract carries dynamic pending inputs without prescribing storage paths', () => {
|
|
241
|
+
const status = {
|
|
242
|
+
contractVersion: '1',
|
|
243
|
+
agentInstanceId: 'production-main',
|
|
244
|
+
capability: 'knowledge.update',
|
|
245
|
+
operation: 'ingest',
|
|
246
|
+
available: true,
|
|
247
|
+
pendingInputs: [{ type: 'file', ref: 'provider-owned/source-a', label: 'source-a.md', mediaType: 'text/markdown' }],
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
assert.equal(validateContract('capabilityStatus', status).ok, true);
|
|
251
|
+
assert.equal(validateContract('capabilityStatus', { ...status, pendingInputs: [{ type: 'file' }] }).ok, false);
|
|
252
|
+
});
|
package/src/core/agentEvents.js
CHANGED
|
@@ -490,6 +490,12 @@ function applyEvent(state, event) {
|
|
|
490
490
|
case 'run_error':
|
|
491
491
|
state.status = 'error';
|
|
492
492
|
state.logs.push(String(event.payload?.message ?? 'Agent run failed.'));
|
|
493
|
+
// A dead run must not leave "pending" plan steps and spinning
|
|
494
|
+
// activities in the persisted projection: they reappeared as ghosts
|
|
495
|
+
// at every relaunch ("des trucs dans le plan qui n'existent pas") and
|
|
496
|
+
// /kill honestly reported 0 because nothing was actually running.
|
|
497
|
+
cancelPendingPlanSteps(state.plan);
|
|
498
|
+
cancelActiveActivities(state.activities, event.ts);
|
|
493
499
|
finishControlByRun(state.controlQueue, event.runId ?? event.payload?.runId ?? null, 'failed', event.ts);
|
|
494
500
|
return;
|
|
495
501
|
case 'control_enqueued':
|