@dotdrelle/wiki-manager 0.15.84 → 0.15.91
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/agent-runtimes.example.json +11 -1
- package/agents.docker-compose.yml +6 -0
- package/docker-compose.yml +1 -1
- package/mcp.endpoints.example.json +1 -1
- package/package.json +1 -1
- package/src/agent/graph.js +46 -7
- package/src/agent/graph.test.js +17 -0
- package/src/cli/wiki-manager.js +33 -5
- package/src/commands/slash.js +6 -6
- package/src/core/agentEvents.js +48 -0
- package/src/core/agentEvents.test.js +36 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/env.js +3 -3
- package/src/core/env.test.js +17 -0
- package/src/core/mcp.js +1 -1
- package/src/core/openWikiPages.js +17 -0
- package/src/core/progressNotes.js +17 -2
- package/src/core/runtimeEventAdapter.js +5 -2
- package/src/core/runtimeEventAdapter.test.js +7 -2
- package/src/core/skillChainView.js +5 -5
- package/src/core/skillChainView.test.js +3 -2
- package/src/core/skillCompiler.js +19 -1
- package/src/core/skillCompiler.test.js +34 -2
- package/src/core/skills.js +28 -0
- package/src/core/workflow.js +31 -2
- package/src/core/workflow.test.js +31 -0
- package/src/orchestrator/dispatcher.js +47 -10
- package/src/orchestrator/dispatcher.test.js +90 -1
- package/src/orchestrator/providers/dispatcherExternalRuntime.test.js +2 -2
- package/src/orchestrator/providers/runtimeProviders.test.js +3 -1
- package/src/orchestrator/resultAggregator.js +67 -0
- package/src/runtime/server.js +53 -25
- package/src/runtime/skillChain.e2e.test.js +20 -2
- package/src/runtime/skillRun.js +41 -0
- package/src/shell/repl.js +73 -20
- package/src/shell/repl.test.js +91 -2
- package/src/shell/useSession.ts +1 -1
- package/wiki-workspace +7 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { objectiveForResolution } from '../orchestrator/objectiveResolver.js';
|
|
1
2
|
const OPTIONAL_RE = /^(?:si disponible|si possible|optionnellement|if available|if possible|optionally)\b[\s,:-]*/i;
|
|
2
3
|
const STRONG_CONNECTOR_RE = /\n\s*(?=(?:puis|ensuite|après cela|après .{0,80}?terminé|then|next|after .{0,80}?complete|si disponible|si possible|optionnellement|if available|if possible|optionally)\b)/gi;
|
|
3
4
|
const FORBIDDEN_FIELDS = /\b(?:agent|capability|capabilityPlan|MCP|tool(?: name)?)\s*:/i;
|
|
@@ -117,8 +118,25 @@ function objectiveFromText(raw) {
|
|
|
117
118
|
};
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
// A guardrail is not an intention. "It never ingests", "Never ask which source
|
|
122
|
+
// to export", "It never builds, exports or publishes" all name an action the
|
|
123
|
+
// skill must NOT take — and counting them made a body MORE ambiguous the more
|
|
124
|
+
// carefully its boundaries were written. Three of wiki-sync's five triggers
|
|
125
|
+
// were guardrails, which is what pushed the best-documented skill in the
|
|
126
|
+
// scaffold over the threshold and handed its split to the LLM.
|
|
127
|
+
// objectiveResolver already strips negative guardrails before resolving; the
|
|
128
|
+
// ambiguity count has to agree with it, or the two read the same sentence as
|
|
129
|
+
// opposite things.
|
|
120
130
|
function looksAmbiguous(text) {
|
|
121
|
-
|
|
131
|
+
// Count what the resolver will actually resolve, not the raw prose. A second
|
|
132
|
+
// guardrail regex living here drifted from objectiveResolver's within one
|
|
133
|
+
// edit: "Check the sources without asking, then export and build." was
|
|
134
|
+
// dropped by one and kept whole by the other, so the two read the same
|
|
135
|
+
// sentence as opposite things. Reusing objectiveForResolution makes them
|
|
136
|
+
// agree by construction — there is one definition of "this clause is a
|
|
137
|
+
// constraint, not an intention", and it lives with the resolver.
|
|
138
|
+
const resolvable = objectiveForResolution(text);
|
|
139
|
+
return (resolvable.match(/(?:^|[.!?]\s+)[A-ZÀ-Ý][^.!?]{0,80}\b(?:export|ingest|build|send|create|delete|sync|publish|diagnos|analyse|constru|envoi|cré|supprim)/gi)?.length ?? 0) > 2;
|
|
122
140
|
}
|
|
123
141
|
|
|
124
142
|
function normalizeFallback(value) {
|
|
@@ -30,12 +30,44 @@ test('validation rejects technical routing details', () => {
|
|
|
30
30
|
assert.throws(() => validateCompiledObjectives([{ text: 'agent: cme' }]), { code: 'skill_compile_failed' });
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
test('every shipped scaffold skill compiles to a single intention', async () => {
|
|
33
|
+
test('every shipped scaffold skill compiles to a single intention, deterministically', async () => {
|
|
34
34
|
const expected = { pipeline: 1, 'wiki-sync': 1, 'wiki-ingest': 1, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1 };
|
|
35
|
+
// Passing no llmFallback used to make this test assert the one path
|
|
36
|
+
// production never takes: an ambiguous body silently returns the safe
|
|
37
|
+
// mono-intention fallback, so the count was 1 and the test was green while
|
|
38
|
+
// production called the LLM and got 3. A shipped skill reaching the LLM
|
|
39
|
+
// splitter is a build-time defect, not a runtime coin flip — so the fallback
|
|
40
|
+
// here throws, and the deterministic pass must never need it.
|
|
41
|
+
const llmFallback = () => { throw new Error('a shipped skill must not need the LLM splitter'); };
|
|
35
42
|
for (const [name, count] of Object.entries(expected)) {
|
|
36
43
|
const raw = readFileSync(resolve('../llm-wiki/scaffold/workspace/.wiki/skills', `${name}.md`), 'utf8');
|
|
37
44
|
const { meta, body } = parseFrontmatter(raw);
|
|
38
|
-
assert.equal((
|
|
45
|
+
assert.equal(deterministicObjectives(body).ambiguous, false, `${name} is ambiguous for the deterministic pass`);
|
|
46
|
+
assert.equal((await compileSkillObjectives({ ...meta, body }, {}, { llmFallback })).length, count, name);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('every orchestrated scaffold skill declares the capability it targets', () => {
|
|
51
|
+
// Without a declaration the capability is inferred from the body's prose by
|
|
52
|
+
// alias matching, which any runtime added to agent-runtimes.json can break by
|
|
53
|
+
// declaring a bare English word as an alias. Declared, the run is routed by
|
|
54
|
+
// registry lookup and no text is matched at all.
|
|
55
|
+
// Only the skills whose declaration is actually APPLIED, and only where the
|
|
56
|
+
// target agent accepts it. The list is deliberately short:
|
|
57
|
+
// - parameterised skills are dropped by skillRun (the capabilityPlan route
|
|
58
|
+
// skips the argument extraction a selector like <template> needs);
|
|
59
|
+
// - pipeline keeps text resolution until an E2E test can assert its agent
|
|
60
|
+
// still plans its own DAG;
|
|
61
|
+
// - diagnose declared `workspace.diagnose/doctor` and BROKE: agent_plan's
|
|
62
|
+
// operation allow-list has no `doctor`, so the plan was refused, the
|
|
63
|
+
// refusal swallowed, and the run reported done without diagnosing
|
|
64
|
+
// anything. Declaring a capability the executor cannot plan is worse than
|
|
65
|
+
// not declaring one.
|
|
66
|
+
const orchestrated = ['wiki-sync'];
|
|
67
|
+
for (const name of orchestrated) {
|
|
68
|
+
const raw = readFileSync(resolve('../llm-wiki/scaffold/workspace/.wiki/skills', `${name}.md`), 'utf8');
|
|
69
|
+
const { meta } = parseFrontmatter(raw);
|
|
70
|
+
assert.match(String(meta.capability ?? ''), /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9_-]*)+$/, `${name} declares no capability`);
|
|
39
71
|
}
|
|
40
72
|
});
|
|
41
73
|
|
package/src/core/skills.js
CHANGED
|
@@ -5,6 +5,8 @@ const SKILL_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
|
|
5
5
|
const SKILL_PARAM_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
|
6
6
|
const DANGEROUS_PARAM_NAMES = new Set(['__proto__', 'prototype', 'constructor']);
|
|
7
7
|
const DEFAULT_UI_SKILL_DIR = '.wiki/skills';
|
|
8
|
+
const SKILL_CAPABILITY_RE = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9_-]*)+$/;
|
|
9
|
+
const SKILL_OPERATION_RE = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
8
10
|
// The CSI branch must come FIRST. `[` is 0x5B, inside the `[@-_]` range, so the
|
|
9
11
|
// two-character alternative would otherwise consume `ESC [` alone and leave the
|
|
10
12
|
// parameter bytes behind as literal text: "\x1B[31m" would become "31m".
|
|
@@ -71,6 +73,30 @@ function inspectSkillFile(filePath, fallbackName, scope, root) {
|
|
|
71
73
|
return { rejected: { relativePath, name, reason: 'invalid_param' } };
|
|
72
74
|
}
|
|
73
75
|
const description = descriptionMetadata(meta.description);
|
|
76
|
+
// A skill may DECLARE the capability it targets. Without it, the capability
|
|
77
|
+
// is inferred from the body's prose by alias matching in objectiveResolver —
|
|
78
|
+
// which is text-similarity executor selection under another name, the very
|
|
79
|
+
// thing this repo removed once and must not reintroduce. Worse, the aliases
|
|
80
|
+
// come from `agent-runtimes.json`, user-editable config: adding any runtime
|
|
81
|
+
// whose alias is a bare English word ("report", "check") makes two aliases
|
|
82
|
+
// hit at once, and `aliasHits.length > 1` abandons the deterministic path for
|
|
83
|
+
// the LLM resolver — silently, for every shipped skill at once.
|
|
84
|
+
//
|
|
85
|
+
// Declaring it in FRONTMATTER, never in the body, keeps both rules intact:
|
|
86
|
+
// the body stays a business intention naming no agent, tool or server
|
|
87
|
+
// (skillCompiler's FORBIDDEN_FIELDS still enforces that), while routing
|
|
88
|
+
// targets a capability — the same abstraction plans already target.
|
|
89
|
+
const capability = String(meta.capability || '').trim();
|
|
90
|
+
if (capability && !SKILL_CAPABILITY_RE.test(capability)) {
|
|
91
|
+
return { rejected: { relativePath, name, reason: 'invalid_capability' } };
|
|
92
|
+
}
|
|
93
|
+
const operation = String(meta.operation || '').trim();
|
|
94
|
+
if (operation && !SKILL_OPERATION_RE.test(operation)) {
|
|
95
|
+
return { rejected: { relativePath, name, reason: 'invalid_operation' } };
|
|
96
|
+
}
|
|
97
|
+
if (operation && !capability) {
|
|
98
|
+
return { rejected: { relativePath, name, reason: 'operation_without_capability' } };
|
|
99
|
+
}
|
|
74
100
|
const execution = String(meta.execution || 'orchestrated').trim().toLowerCase();
|
|
75
101
|
if (!['orchestrated', 'direct'].includes(execution)) {
|
|
76
102
|
return { rejected: { relativePath, name, reason: 'invalid_execution' } };
|
|
@@ -83,6 +109,8 @@ function inspectSkillFile(filePath, fallbackName, scope, root) {
|
|
|
83
109
|
execution,
|
|
84
110
|
scope,
|
|
85
111
|
path: filePath,
|
|
112
|
+
...(capability ? { capability } : {}),
|
|
113
|
+
...(operation ? { operation } : {}),
|
|
86
114
|
};
|
|
87
115
|
const warnings = [];
|
|
88
116
|
if (description.missing) warnings.push({ relativePath, name, reason: 'missing_description' });
|
package/src/core/workflow.js
CHANGED
|
@@ -38,7 +38,22 @@ export function projectWorkflow(state = {}, events = []) {
|
|
|
38
38
|
const approvalNodes = approvals.map(approvalNode);
|
|
39
39
|
nodes.push(...planNodes, ...activityNodes, ...queueNodes, ...approvalNodes);
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
// The external runtime's collective (lot 2): each named subagent becomes a
|
|
42
|
+
// child node of the run node, so the Canvas shows the run's internal
|
|
43
|
+
// timeline instead of burying the roles in log lines.
|
|
44
|
+
const subagentNodes = (Array.isArray(state.subagents) ? state.subagents : [])
|
|
45
|
+
.map((entry, index) => ({
|
|
46
|
+
id: `subagent:${String(entry.subagent ?? 'subagent')}:${index}`,
|
|
47
|
+
type: 'subagent',
|
|
48
|
+
label: String(entry.subagent ?? 'subagent'),
|
|
49
|
+
status: entry.status === 'done' ? 'done' : 'running',
|
|
50
|
+
startedAt: entry.startedAt ?? null,
|
|
51
|
+
finishedAt: entry.finishedAt ?? null,
|
|
52
|
+
subagent: String(entry.subagent ?? 'subagent'),
|
|
53
|
+
}));
|
|
54
|
+
nodes.push(...subagentNodes);
|
|
55
|
+
|
|
56
|
+
for (const node of [...planNodes, ...activityNodes, ...queueNodes, ...approvalNodes, ...subagentNodes]) {
|
|
42
57
|
if (run) relations.push({ type: 'contains', from: run.id, to: node.id });
|
|
43
58
|
}
|
|
44
59
|
|
|
@@ -191,6 +206,15 @@ function metricNumber(value) {
|
|
|
191
206
|
return Number.isFinite(number) && number >= 0 ? number : null;
|
|
192
207
|
}
|
|
193
208
|
|
|
209
|
+
|
|
210
|
+
// The Plan panel names a run; it does not reproduce it. One line, bounded.
|
|
211
|
+
const RUN_LABEL_MAX = 80;
|
|
212
|
+
function runLabel(value) {
|
|
213
|
+
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
214
|
+
if (!text) return 'Runtime run';
|
|
215
|
+
return text.length > RUN_LABEL_MAX ? `${text.slice(0, RUN_LABEL_MAX - 1)}…` : text;
|
|
216
|
+
}
|
|
217
|
+
|
|
194
218
|
function currentRun(state, events) {
|
|
195
219
|
const runId = state.runId ?? state.runs?.find((run) => isActiveStatus(run.status))?.id ?? events.findLast?.((event) => event.runId)?.runId ?? null;
|
|
196
220
|
if (!runId && !state.status) return null;
|
|
@@ -198,7 +222,12 @@ function currentRun(state, events) {
|
|
|
198
222
|
id: runId ? `run:${runId}` : 'run:current',
|
|
199
223
|
type: 'run',
|
|
200
224
|
runId,
|
|
201
|
-
|
|
225
|
+
// A skill run's `input` is the COMPILED objective — the private body's
|
|
226
|
+
// business intention, often a full paragraph. Printing it whole turned the
|
|
227
|
+
// Plan panel into a prompt dump, where the reader wanted the run's identity
|
|
228
|
+
// and its status. Prefer the summary, then the public invocation the
|
|
229
|
+
// control item already carries, and cap whatever is left.
|
|
230
|
+
label: runLabel(state.summary || state.publicInput || state.input),
|
|
202
231
|
status: normalizeStatus(state.status ?? 'idle'),
|
|
203
232
|
workspace: state.workspace ?? null,
|
|
204
233
|
startedAt: state.startedAt ?? state.runs?.find((run) => run.id === runId)?.createdAt ?? null,
|
|
@@ -121,3 +121,34 @@ test('projectWorkflow derives per-task timing (start, finish, duration) from lif
|
|
|
121
121
|
assert.equal(workflow.timingByTask.ingest.finishedAt, Date.parse('2026-07-23T10:00:12.500Z'));
|
|
122
122
|
assert.equal(workflow.timingByTask.ingest.durationMs, 12500);
|
|
123
123
|
});
|
|
124
|
+
|
|
125
|
+
test('projectWorkflow renders the collective subagents as child nodes of the run', () => {
|
|
126
|
+
const workflow = projectWorkflow({
|
|
127
|
+
status: 'running',
|
|
128
|
+
runId: 'run-1',
|
|
129
|
+
workspace: 'docs',
|
|
130
|
+
plan: [],
|
|
131
|
+
activities: [],
|
|
132
|
+
queue: [],
|
|
133
|
+
approvals: [],
|
|
134
|
+
subagents: [
|
|
135
|
+
{ subagent: 'scout', status: 'done', startedAt: '2026-09-09T10:00:00.000Z', finishedAt: '2026-09-09T10:00:04.000Z' },
|
|
136
|
+
{ subagent: 'critique', status: 'running', startedAt: '2026-09-09T10:00:05.000Z' },
|
|
137
|
+
],
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const subagentNodes = workflow.nodes.filter((node) => node.type === 'subagent');
|
|
141
|
+
assert.equal(subagentNodes.length, 2);
|
|
142
|
+
assert.equal(subagentNodes[0].label, 'scout');
|
|
143
|
+
assert.equal(subagentNodes[0].status, 'done');
|
|
144
|
+
assert.equal(subagentNodes[1].label, 'critique');
|
|
145
|
+
assert.equal(subagentNodes[1].status, 'running');
|
|
146
|
+
|
|
147
|
+
const run = workflow.nodes.find((node) => node.type === 'run');
|
|
148
|
+
for (const node of subagentNodes) {
|
|
149
|
+
assert.ok(
|
|
150
|
+
workflow.relations.some((rel) => rel.type === 'contains' && rel.from === run.id && rel.to === node.id),
|
|
151
|
+
`${node.label} hangs off the run node`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
@@ -2,12 +2,24 @@ 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
4
|
import { loadWorkspaceProfile } from '../core/profile.js';
|
|
5
|
+
import { containerReachableUrl } from '../core/wikiSetup.js';
|
|
5
6
|
import { mapRuntimeEvent } from '../core/runtimeEventAdapter.js';
|
|
6
7
|
import { emitRuntimeLog, pollActivitiesOnce } from '../runtime/supervisor.js';
|
|
7
8
|
import { APPROVAL_DEFAULT_CLASS, approvalCovered } from './approvalPolicy.js';
|
|
8
9
|
import { isSuccessful, isTerminal } from './taskStatuses.js';
|
|
9
10
|
|
|
10
11
|
|
|
12
|
+
// Distinguishes "the manager process is shutting down" from a genuine task
|
|
13
|
+
// cancellation (/run cancel, /stop, /control cancel) on the SAME shared
|
|
14
|
+
// AbortController + signal (server.js's context.currentAbortController) —
|
|
15
|
+
// the dispatcher otherwise cannot tell the two apart, and calling agent_cancel
|
|
16
|
+
// on a shutdown-abort was cancelling a still-healthy job the very recovery
|
|
17
|
+
// mechanism (recoveryManager.js's idempotency requeue) exists to reattach to
|
|
18
|
+
// on the next boot. A shutdown must still abort the poll loop — the `finally`
|
|
19
|
+
// block's lock release has to run before the process exits — it must just not
|
|
20
|
+
// tell the agent to give up on real work.
|
|
21
|
+
export const RUNTIME_SHUTDOWN_ABORT_REASON = 'runtime_shutdown';
|
|
22
|
+
|
|
11
23
|
export function createDispatcher({
|
|
12
24
|
session = null,
|
|
13
25
|
callTool = callMcpTool,
|
|
@@ -66,7 +78,7 @@ export async function execute(task, assignment, {
|
|
|
66
78
|
session.mcp,
|
|
67
79
|
serverName,
|
|
68
80
|
executeTool,
|
|
69
|
-
executeRequest(task, session, runId),
|
|
81
|
+
executeRequest(task, session, runId, assignment),
|
|
70
82
|
signal,
|
|
71
83
|
));
|
|
72
84
|
if (accepted?.accepted === false || accepted?.ok === false) {
|
|
@@ -155,7 +167,7 @@ export async function execute(task, assignment, {
|
|
|
155
167
|
}
|
|
156
168
|
}
|
|
157
169
|
} catch (error) {
|
|
158
|
-
if (isAbortError(error) && jobId) {
|
|
170
|
+
if (isAbortError(error) && jobId && error.reason !== RUNTIME_SHUTDOWN_ABORT_REASON) {
|
|
159
171
|
await callTool(session.mcp, serverName, cancelTool, { jobId }, null).catch(() => null);
|
|
160
172
|
}
|
|
161
173
|
throw error;
|
|
@@ -407,7 +419,20 @@ function dispatchExternalRuntimeActivity(session, task, assignment, runtimeRunId
|
|
|
407
419
|
}));
|
|
408
420
|
}
|
|
409
421
|
|
|
410
|
-
function executeRequest(task, session, runId) {
|
|
422
|
+
function executeRequest(task, session, runId, assignment) {
|
|
423
|
+
// The assignment's `capability` field is the capability ID (a string) for
|
|
424
|
+
// MCP agents; the OBJECT with the inputSchema lives on the registry agent's
|
|
425
|
+
// description. Resolve it properly — reading inputSchema off the string is
|
|
426
|
+
// what silently disabled the configPath injection below.
|
|
427
|
+
const capabilityId = task?.requiredCapability;
|
|
428
|
+
const capabilityObject = (assignment?.capability && typeof assignment.capability === 'object'
|
|
429
|
+
? assignment.capability
|
|
430
|
+
: null)
|
|
431
|
+
?? assignment?.agent?.description?.capabilities?.find(
|
|
432
|
+
(capability) => String(capability?.id ?? '') === String(capabilityId ?? ''),
|
|
433
|
+
)
|
|
434
|
+
?? null;
|
|
435
|
+
const schemaProperties = capabilityObject?.inputSchema?.properties ?? {};
|
|
411
436
|
return {
|
|
412
437
|
taskId: String(task.id ?? task.step),
|
|
413
438
|
...(runId ? { runId: String(runId) } : {}),
|
|
@@ -428,6 +453,17 @@ function executeRequest(task, session, runId) {
|
|
|
428
453
|
// first E2E ingest plan dispatched by the deep agent's
|
|
429
454
|
// planExpansionRequest failed exactly that way, 19 tasks in one batch.
|
|
430
455
|
...(task.requiresApproval === true ? { confirm: true } : {}),
|
|
456
|
+
// The ACTIVE profile must reach the job: a task planned without an
|
|
457
|
+
// explicit configPath (the common case) otherwise runs on the workspace
|
|
458
|
+
// default .wikirc, so /config use <profile> changes the runtime's own
|
|
459
|
+
// LLM but silently not the production job's. The dispatcher is the one
|
|
460
|
+
// place every executor sees — inject the session's current profile when
|
|
461
|
+
// the capability declares the field and the task did not set one.
|
|
462
|
+
...(task.arguments?.configPath === undefined
|
|
463
|
+
&& 'configPath' in schemaProperties
|
|
464
|
+
&& session?.wikirc?.fileName
|
|
465
|
+
? { configPath: session.wikirc.fileName }
|
|
466
|
+
: {}),
|
|
431
467
|
},
|
|
432
468
|
constraints: {
|
|
433
469
|
requireApprovalForMutations: task.requiresApproval === true,
|
|
@@ -448,7 +484,7 @@ function workspaceRequest(session) {
|
|
|
448
484
|
function activeProfileModel(session) {
|
|
449
485
|
const llm = session?.wikircConfig?.llm ?? {};
|
|
450
486
|
const model = {
|
|
451
|
-
...(llm.baseUrl ? { baseUrl: String(llm.baseUrl) } : {}),
|
|
487
|
+
...(llm.baseUrl ? { baseUrl: containerReachableUrl(String(llm.baseUrl)).url } : {}),
|
|
452
488
|
...(llm.model ? { model: String(llm.model) } : {}),
|
|
453
489
|
...(llm.apiKey ? { apiKey: String(llm.apiKey) } : {}),
|
|
454
490
|
};
|
|
@@ -521,7 +557,7 @@ export function activeProfileMcp(session) {
|
|
|
521
557
|
};
|
|
522
558
|
blocks.push({
|
|
523
559
|
name: 'wiki',
|
|
524
|
-
url: String(wiki.url),
|
|
560
|
+
url: containerReachableUrl(String(wiki.url)).url,
|
|
525
561
|
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
|
526
562
|
tools,
|
|
527
563
|
});
|
|
@@ -542,7 +578,7 @@ export function activeProfileMcp(session) {
|
|
|
542
578
|
if (tools.length === 0) continue;
|
|
543
579
|
blocks.push({
|
|
544
580
|
name,
|
|
545
|
-
url: String(entry.url),
|
|
581
|
+
url: containerReachableUrl(String(entry.url)).url,
|
|
546
582
|
...(entry.headers && typeof entry.headers === 'object' ? { headers: entry.headers } : {}),
|
|
547
583
|
tools,
|
|
548
584
|
});
|
|
@@ -739,24 +775,25 @@ function taskLogPayload(event, task, assignment, {
|
|
|
739
775
|
function delay(ms, signal) {
|
|
740
776
|
return new Promise((resolve, reject) => {
|
|
741
777
|
if (signal?.aborted) {
|
|
742
|
-
reject(abortError());
|
|
778
|
+
reject(abortError(signal));
|
|
743
779
|
return;
|
|
744
780
|
}
|
|
745
781
|
const timer = setTimeout(resolve, Math.max(0, Number(ms) || 0));
|
|
746
782
|
signal?.addEventListener('abort', () => {
|
|
747
783
|
clearTimeout(timer);
|
|
748
|
-
reject(abortError());
|
|
784
|
+
reject(abortError(signal));
|
|
749
785
|
}, { once: true });
|
|
750
786
|
});
|
|
751
787
|
}
|
|
752
788
|
|
|
753
789
|
function throwIfAborted(signal) {
|
|
754
|
-
if (signal?.aborted) throw abortError();
|
|
790
|
+
if (signal?.aborted) throw abortError(signal);
|
|
755
791
|
}
|
|
756
792
|
|
|
757
|
-
function abortError() {
|
|
793
|
+
function abortError(signal) {
|
|
758
794
|
const error = new Error('Runtime run cancelled.');
|
|
759
795
|
error.name = 'AbortError';
|
|
796
|
+
error.reason = signal?.reason;
|
|
760
797
|
return error;
|
|
761
798
|
}
|
|
762
799
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import { activeProfileMcp, createDispatcher, normalizeTaskError } from './dispatcher.js';
|
|
3
|
+
import { activeProfileMcp, createDispatcher, normalizeTaskError, RUNTIME_SHUTDOWN_ABORT_REASON } from './dispatcher.js';
|
|
4
4
|
|
|
5
5
|
test('activeProfileMcp forwards only the read-only wiki tools to the external runtime', () => {
|
|
6
6
|
const session = {
|
|
@@ -91,6 +91,29 @@ test('activeProfileMcp tolerates namespaced tool names', () => {
|
|
|
91
91
|
assert.deepEqual(activeProfileMcp(session)[0].tools, ['wiki__wiki_read_page']);
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
test('activeProfileMcp rewrites loopback URLs for the container the runtime runs in', () => {
|
|
95
|
+
const session = {
|
|
96
|
+
mcp: {
|
|
97
|
+
wiki: {
|
|
98
|
+
url: 'http://127.0.0.1:3201/mcp', status: 'connected',
|
|
99
|
+
tools: [{ name: 'wiki_read_page' }],
|
|
100
|
+
},
|
|
101
|
+
exa: {
|
|
102
|
+
url: 'http://localhost:9999/mcp/', status: 'connected', external: true,
|
|
103
|
+
tools: [{ name: 'web_search_exa' }],
|
|
104
|
+
},
|
|
105
|
+
hosted: {
|
|
106
|
+
url: 'https://mcp.exa.ai/mcp', status: 'connected', external: true,
|
|
107
|
+
tools: [{ name: 'web_search_exa' }],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
const pool = activeProfileMcp(session);
|
|
112
|
+
assert.equal(pool.find((block) => block.name === 'wiki').url, 'http://host.docker.internal:3201/mcp');
|
|
113
|
+
assert.equal(pool.find((block) => block.name === 'exa').url, 'http://host.docker.internal:9999/mcp');
|
|
114
|
+
assert.equal(pool.find((block) => block.name === 'hosted').url, 'https://mcp.exa.ai/mcp');
|
|
115
|
+
});
|
|
116
|
+
|
|
94
117
|
test('dispatcher returns a retryable logical failure when agent_execute reports workspace_busy', async () => {
|
|
95
118
|
const session = {
|
|
96
119
|
workspace: 'test',
|
|
@@ -203,6 +226,72 @@ test('dispatcher completes when an executor-only agent reports succeeded', async
|
|
|
203
226
|
assert.equal(result.jobId, 'job-connectors');
|
|
204
227
|
});
|
|
205
228
|
|
|
229
|
+
test('dispatcher cancels the agent job when genuinely aborted mid-poll', async () => {
|
|
230
|
+
const session = {
|
|
231
|
+
workspace: 'test',
|
|
232
|
+
mcp: { production: { status: 'connected', tools: [{ name: 'agent_execute' }, { name: 'agent_status' }, { name: 'agent_cancel' }] } },
|
|
233
|
+
activities: {},
|
|
234
|
+
};
|
|
235
|
+
const calledTools = [];
|
|
236
|
+
const dispatcher = createDispatcher({
|
|
237
|
+
session,
|
|
238
|
+
pollIntervalMs: 5,
|
|
239
|
+
callTool: async (_mcp, _server, tool) => {
|
|
240
|
+
calledTools.push(tool);
|
|
241
|
+
if (tool === 'agent_execute') return { accepted: true, jobId: 'job-1', status: 'queued' };
|
|
242
|
+
if (tool === 'agent_cancel') return { ok: true };
|
|
243
|
+
// agent_status: never terminal, forces the poll loop to keep waiting
|
|
244
|
+
// until the abort fires.
|
|
245
|
+
return { jobId: 'job-1', status: 'running' };
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
const controller = new AbortController();
|
|
249
|
+
setTimeout(() => controller.abort(), 10);
|
|
250
|
+
|
|
251
|
+
await assert.rejects(
|
|
252
|
+
dispatcher.execute(
|
|
253
|
+
{ id: 'analyze-doc', label: 'Analyze doc', requiredCapability: 'knowledge.update', operation: 'ingest_plan', arguments: {} },
|
|
254
|
+
{ serverName: 'production', agentInstanceId: 'production-main' },
|
|
255
|
+
{ attempt: { attemptId: 'analyze-doc:attempt-1', locks: [], release() {} }, signal: controller.signal },
|
|
256
|
+
),
|
|
257
|
+
);
|
|
258
|
+
assert.ok(calledTools.includes('agent_cancel'), 'a genuine cancel/abort must still cancel the agent job');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('dispatcher does NOT cancel the agent job when the manager is only shutting down', async () => {
|
|
262
|
+
// The exact scenario that caused a real incident: restarting the runtime
|
|
263
|
+
// mid-ingest orphaned in-flight sources because this path cancelled their
|
|
264
|
+
// still-healthy jobs instead of leaving them for recoveryManager.js's
|
|
265
|
+
// idempotency requeue to reattach to on the next boot.
|
|
266
|
+
const session = {
|
|
267
|
+
workspace: 'test',
|
|
268
|
+
mcp: { production: { status: 'connected', tools: [{ name: 'agent_execute' }, { name: 'agent_status' }, { name: 'agent_cancel' }] } },
|
|
269
|
+
activities: {},
|
|
270
|
+
};
|
|
271
|
+
const calledTools = [];
|
|
272
|
+
const dispatcher = createDispatcher({
|
|
273
|
+
session,
|
|
274
|
+
pollIntervalMs: 5,
|
|
275
|
+
callTool: async (_mcp, _server, tool) => {
|
|
276
|
+
calledTools.push(tool);
|
|
277
|
+
if (tool === 'agent_execute') return { accepted: true, jobId: 'job-2', status: 'queued' };
|
|
278
|
+
if (tool === 'agent_cancel') return { ok: true };
|
|
279
|
+
return { jobId: 'job-2', status: 'running' };
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
const controller = new AbortController();
|
|
283
|
+
setTimeout(() => controller.abort(RUNTIME_SHUTDOWN_ABORT_REASON), 10);
|
|
284
|
+
|
|
285
|
+
await assert.rejects(
|
|
286
|
+
dispatcher.execute(
|
|
287
|
+
{ id: 'analyze-doc', label: 'Analyze doc', requiredCapability: 'knowledge.update', operation: 'ingest_plan', arguments: {} },
|
|
288
|
+
{ serverName: 'production', agentInstanceId: 'production-main' },
|
|
289
|
+
{ attempt: { attemptId: 'analyze-doc:attempt-1', locks: [], release() {} }, signal: controller.signal },
|
|
290
|
+
),
|
|
291
|
+
);
|
|
292
|
+
assert.ok(!calledTools.includes('agent_cancel'), 'a shutdown-reason abort must leave the still-healthy agent job running');
|
|
293
|
+
});
|
|
294
|
+
|
|
206
295
|
test('dispatcher normalizes a bare string error reported on a terminal agent_status', async () => {
|
|
207
296
|
const session = {
|
|
208
297
|
workspace: 'test',
|
|
@@ -298,7 +298,7 @@ test('dispatcher sends the active profile model with the run', async () => {
|
|
|
298
298
|
assert.equal(requests[0].language, 'fr', 'the workspace language travels with the run');
|
|
299
299
|
assert.deepEqual(requests[0].mcp, [{
|
|
300
300
|
name: 'wiki',
|
|
301
|
-
url: 'http://
|
|
301
|
+
url: 'http://host.docker.internal:3335/mcp',
|
|
302
302
|
headers: { Authorization: 'Bearer wiki-token' },
|
|
303
303
|
tools: ['wiki_search_context', 'wiki_read_page'],
|
|
304
304
|
}], 'the wiki MCP travels per run, read tools only — write tools never leave');
|
|
@@ -378,7 +378,7 @@ test('dispatcher sends the active profile model with the run', async () => {
|
|
|
378
378
|
);
|
|
379
379
|
|
|
380
380
|
assert.equal(received[0].model.model, 'openai/gpt-test');
|
|
381
|
-
assert.equal(received[0].model.baseUrl, 'http://
|
|
381
|
+
assert.equal(received[0].model.baseUrl, 'http://host.docker.internal:9/v1');
|
|
382
382
|
assert.equal(received[0].model.apiKey, 'k');
|
|
383
383
|
assert.equal(received[0].model.temperature, 0.2);
|
|
384
384
|
});
|
|
@@ -157,8 +157,10 @@ test('resolveRuntimeProviders maps enabled entries and skips unknown types', ()
|
|
|
157
157
|
test('loadAgentRuntimesConfig reads both array and object forms', () => {
|
|
158
158
|
const dir = mkdtempSync(join(tmpdir(), 'agent-runtimes-'));
|
|
159
159
|
try {
|
|
160
|
+
// env: {} — the machine's manager .env (GATEWAY_ENABLED) must not leak
|
|
161
|
+
// into this parse-shape test through the implied-gateway path.
|
|
160
162
|
writeFileSync(join(dir, 'agent-runtimes.json'), JSON.stringify({ runtimes: [{ id: 'a', type: 'fake' }] }));
|
|
161
|
-
assert.deepEqual(loadAgentRuntimesConfig({ stateDir: dir }), [{ id: 'a', type: 'fake' }]);
|
|
163
|
+
assert.deepEqual(loadAgentRuntimesConfig({ stateDir: dir, env: {} }), [{ id: 'a', type: 'fake' }]);
|
|
162
164
|
} finally {
|
|
163
165
|
rmSync(dir, { recursive: true, force: true });
|
|
164
166
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
1
3
|
import { validateContract } from '../contracts/schemas.js';
|
|
2
4
|
import { parseJsonText } from '../core/activity.js';
|
|
3
5
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
@@ -63,6 +65,26 @@ export async function accept(result, {
|
|
|
63
65
|
taskId,
|
|
64
66
|
payload,
|
|
65
67
|
})));
|
|
68
|
+
// A worktree proposal is a review item, not a log line: persist it into the
|
|
69
|
+
// workspace review queue (.wiki/agent-proposals/) where the served review
|
|
70
|
+
// surface reads it, and announce it — a proposal nobody is told about is a
|
|
71
|
+
// proposal nobody merges, and a merge is the approval.
|
|
72
|
+
const worktreePersisted = persistWorktreeProposal(session, result, { runId, taskId, ok, status });
|
|
73
|
+
if (worktreePersisted.error) {
|
|
74
|
+
persistDispatch(store, dispatchAgentEvent(session, createAgentEvent('runtime_log', {
|
|
75
|
+
origin: 'result_aggregator',
|
|
76
|
+
runId,
|
|
77
|
+
taskId,
|
|
78
|
+
payload: { message: `agent-proposal: could not persist the worktree proposal for ${taskId}: ${worktreePersisted.error}` },
|
|
79
|
+
})));
|
|
80
|
+
} else if (worktreePersisted.path) {
|
|
81
|
+
persistDispatch(store, dispatchAgentEvent(session, createAgentEvent('runtime_log', {
|
|
82
|
+
origin: 'result_aggregator',
|
|
83
|
+
runId,
|
|
84
|
+
taskId,
|
|
85
|
+
payload: { message: `agent-proposal: ${taskId} is waiting for review — ${worktreePersisted.path}` },
|
|
86
|
+
})));
|
|
87
|
+
}
|
|
66
88
|
persistDispatch(store, dispatchAgentEvent(session, createAgentEvent('plan_step_updated', {
|
|
67
89
|
origin: 'result_aggregator',
|
|
68
90
|
runId,
|
|
@@ -227,6 +249,51 @@ function persistDispatch(store, event) {
|
|
|
227
249
|
store?.persistEvent?.(event);
|
|
228
250
|
}
|
|
229
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Worktree proposals (agent.curate): the external runtime's run result carries
|
|
254
|
+
* `worktreeProposal` — the confined branch's changed files, their new content
|
|
255
|
+
* and the unified diff. The manager writes it into the workspace review queue
|
|
256
|
+
* (`.wiki/agent-proposals/<id>.json`, gitignored state) where the served
|
|
257
|
+
* review surface reads it; the MERGE happens there, through the engine's own
|
|
258
|
+
* write machinery — this function only records, it never touches wiki content.
|
|
259
|
+
*/
|
|
260
|
+
function persistWorktreeProposal(session, result, { runId, taskId }) {
|
|
261
|
+
const proposal = result?.result?.worktreeProposal ?? result?.worktreeProposal;
|
|
262
|
+
if (!proposal || typeof proposal !== 'object') return { path: null };
|
|
263
|
+
const changes = Array.isArray(proposal.changes) ? proposal.changes : [];
|
|
264
|
+
if (changes.length === 0) return { path: null };
|
|
265
|
+
const workspacePath = session?.workspacePath;
|
|
266
|
+
if (!workspacePath || typeof workspacePath !== 'string') {
|
|
267
|
+
return { error: 'no workspace path on the session — the proposal stays in the run result only' };
|
|
268
|
+
}
|
|
269
|
+
const record = {
|
|
270
|
+
id: String(taskId),
|
|
271
|
+
runId: String(runId ?? ''),
|
|
272
|
+
workspace: String(proposal.workspace ?? session.workspace ?? ''),
|
|
273
|
+
branch: String(proposal.branch ?? ''),
|
|
274
|
+
worktreePath: String(proposal.worktreePath ?? ''),
|
|
275
|
+
worktreeRelativePath: String(proposal.worktreeRelativePath ?? ''),
|
|
276
|
+
createdAt: new Date().toISOString(),
|
|
277
|
+
justification: String(proposal.justification ?? ''),
|
|
278
|
+
...(Array.isArray(proposal.objections) && proposal.objections.length > 0
|
|
279
|
+
? { objections: proposal.objections }
|
|
280
|
+
: {}),
|
|
281
|
+
changedFiles: Array.isArray(proposal.changedFiles) ? proposal.changedFiles : [],
|
|
282
|
+
changes,
|
|
283
|
+
diff: String(proposal.diff ?? ''),
|
|
284
|
+
};
|
|
285
|
+
try {
|
|
286
|
+
const safeId = String(taskId).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
287
|
+
const dir = join(workspacePath, '.wiki', 'agent-proposals');
|
|
288
|
+
mkdirSync(dir, { recursive: true });
|
|
289
|
+
const path = join(dir, `${safeId}.json`);
|
|
290
|
+
writeFileSync(path, JSON.stringify(record, null, 2));
|
|
291
|
+
return { path };
|
|
292
|
+
} catch (error) {
|
|
293
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
230
297
|
function agentPlanRequest(request, session) {
|
|
231
298
|
return {
|
|
232
299
|
capability: request.capability,
|