@dotdrelle/wiki-manager 0.15.85 → 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 +41 -6
- 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 +1 -1
- 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/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/shell/repl.js +61 -16
- package/src/shell/repl.test.js +86 -1
- package/wiki-workspace +7 -1
|
@@ -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,
|
package/src/runtime/server.js
CHANGED
|
@@ -8,6 +8,7 @@ import { runtimeTokenFromEnv } from './auth.js';
|
|
|
8
8
|
import { controlMessage } from './controlMessages.js';
|
|
9
9
|
import { tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
|
|
10
10
|
import { approvalClassForTask } from '../orchestrator/approvalPolicy.js';
|
|
11
|
+
import { RUNTIME_SHUTDOWN_ABORT_REASON } from '../orchestrator/dispatcher.js';
|
|
11
12
|
import { matchSkillInvocation } from '../core/skillInvocation.js';
|
|
12
13
|
import { reconcileControlQueue } from './controlDrain.js';
|
|
13
14
|
import { cancelControlChain, cancelQueuedControlItem } from './controlCancellation.js';
|
|
@@ -413,7 +414,7 @@ export function startRuntimeServer({
|
|
|
413
414
|
// Read-only chat turns intentionally remain available while an agent
|
|
414
415
|
// run is active. Other interactive turns still become control
|
|
415
416
|
// messages so they cannot start a competing agent decision.
|
|
416
|
-
|
|
417
|
+
let readOnlyChat = String(body.mode ?? '').toLowerCase() === 'chat';
|
|
417
418
|
// An explicit /skill invocation has deterministic meaning. Keep the
|
|
418
419
|
// conversational /turn boundary, but do not ask the LLM to rediscover
|
|
419
420
|
// the skill from prose: it could choose a direct mutation instead and
|
|
@@ -433,14 +434,25 @@ export function startRuntimeServer({
|
|
|
433
434
|
return;
|
|
434
435
|
}
|
|
435
436
|
if (context.running && !readOnlyChat) {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
437
|
+
// Agent-mode message while a run is active. Classify once: control
|
|
438
|
+
// verbs and new tasks go to the control lane, plain conversation is
|
|
439
|
+
// ANSWERED read-only (like a chat turn) instead of parking the
|
|
440
|
+
// reader in a choice menu — the chat must stay usable during runs.
|
|
441
|
+
const classification = await classifyControlMessage(input, controlStatus(context, store), {
|
|
442
|
+
llm: context?.session?.llm,
|
|
443
|
+
session: context?.session,
|
|
441
444
|
});
|
|
442
|
-
|
|
443
|
-
|
|
445
|
+
if (classification.kind !== 'converse') {
|
|
446
|
+
const result = await handleControlMessage(context, store, input, {
|
|
447
|
+
intent: body.intent,
|
|
448
|
+
startNextControlRequest,
|
|
449
|
+
cancel,
|
|
450
|
+
approve,
|
|
451
|
+
});
|
|
452
|
+
sendJson(response, result.statusCode, result.body);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
readOnlyChat = true;
|
|
444
456
|
}
|
|
445
457
|
if (typeof turn !== 'function') {
|
|
446
458
|
sendJson(response, 501, { error: 'Runtime interactive turns are unavailable.' });
|
|
@@ -454,21 +466,27 @@ export function startRuntimeServer({
|
|
|
454
466
|
// after this request was accepted. Reclassify against the fresh
|
|
455
467
|
// state instead of starting another interactive decision in parallel.
|
|
456
468
|
if (context.running && !readOnlyChat) {
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
cancel,
|
|
461
|
-
approve,
|
|
469
|
+
const classification = await classifyControlMessage(input, controlStatus(context, store), {
|
|
470
|
+
llm: context?.session?.llm,
|
|
471
|
+
session: context?.session,
|
|
462
472
|
});
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
473
|
+
if (classification.kind !== 'converse') {
|
|
474
|
+
const result = await handleControlMessage(context, store, input, {
|
|
475
|
+
intent: body.intent,
|
|
476
|
+
startNextControlRequest,
|
|
477
|
+
cancel,
|
|
478
|
+
approve,
|
|
479
|
+
});
|
|
480
|
+
publish(createAgentEvent('assistant_message', {
|
|
481
|
+
origin: 'runtime_turn',
|
|
482
|
+
turnId,
|
|
483
|
+
workspace: context.workspace ?? null,
|
|
484
|
+
payload: { content: result.body?.explanation ?? 'Runtime control request processed.' },
|
|
485
|
+
}));
|
|
486
|
+
return result.body;
|
|
487
|
+
}
|
|
470
488
|
}
|
|
471
|
-
return turn(context, { ...body, input }, {
|
|
489
|
+
return turn(context, { ...body, input, mode: readOnlyChat ? 'chat' : body.mode }, {
|
|
472
490
|
signal: controller.signal,
|
|
473
491
|
turnId,
|
|
474
492
|
});
|
|
@@ -560,7 +578,13 @@ export function startRuntimeServer({
|
|
|
560
578
|
const workspace = workspaceFromUrl(url);
|
|
561
579
|
const context = await resolveContext({ workspace });
|
|
562
580
|
if (context?.running && context.currentAbortController) {
|
|
563
|
-
|
|
581
|
+
// Reason-tagged: the poll loop still has to unwind so its `finally`
|
|
582
|
+
// releases locks before the process exits, but this is the manager
|
|
583
|
+
// going away, not a user cancellation — the dispatcher must not
|
|
584
|
+
// read it as "give up on the agent job too". recoveryManager.js's
|
|
585
|
+
// idempotency requeue exists precisely to reattach to that job on
|
|
586
|
+
// the next boot; cancelling it here defeats that on every restart.
|
|
587
|
+
context.currentAbortController.abort(RUNTIME_SHUTDOWN_ABORT_REASON);
|
|
564
588
|
await cancel?.(context);
|
|
565
589
|
}
|
|
566
590
|
sendJson(response, 202, { shutdown: true });
|
|
@@ -1484,16 +1508,20 @@ async function classifyControlMessage(input, status, { forcedIntent = null, llm
|
|
|
1484
1508
|
const kind = String(reply ?? '').trim().toLowerCase();
|
|
1485
1509
|
if (kind.startsWith('action')) return { kind: 'enqueue_run', confidence: 0.85, reason: 'llm_classified_action' };
|
|
1486
1510
|
if (kind.startsWith('conversation')) return { kind: 'converse', confidence: 0.85, reason: 'llm_classified_conversation' };
|
|
1487
|
-
emitRuntimeLog(session, `control-classify: LLM returned an unrecognized reply,
|
|
1511
|
+
emitRuntimeLog(session, `control-classify: LLM returned an unrecognized reply, answering as read-only conversation — ${JSON.stringify(kind).slice(0, 200)}`);
|
|
1488
1512
|
} catch (err) {
|
|
1489
1513
|
// A degradation must announce itself: silently falling through here
|
|
1490
1514
|
// hides the difference between "no LLM configured" (expected) and "the
|
|
1491
1515
|
// configured LLM is failing every call" (a real problem) — both would
|
|
1492
1516
|
// otherwise look identical from the Shell or serve UI.
|
|
1493
|
-
emitRuntimeLog(session, `control-classify: LLM call failed,
|
|
1517
|
+
emitRuntimeLog(session, `control-classify: LLM call failed, answering as read-only conversation — ${err instanceof Error ? err.message : String(err)}`);
|
|
1494
1518
|
}
|
|
1495
1519
|
}
|
|
1496
|
-
|
|
1520
|
+
// A choice menu IS the block the reader complains about: while a run is
|
|
1521
|
+
// active, every unanswered message turned into a menu. Falling back to
|
|
1522
|
+
// read-only conversation keeps the chat usable — a wrong converse only
|
|
1523
|
+
// answers as chat, it can never mutate or queue anything.
|
|
1524
|
+
return { kind: 'converse', confidence: 0.3, reason: 'fallback_to_readonly_conversation' };
|
|
1497
1525
|
}
|
|
1498
1526
|
|
|
1499
1527
|
function isAuthorized(request, token) {
|
package/src/shell/repl.js
CHANGED
|
@@ -437,31 +437,71 @@ export function sanitizeOpenWikiPages(values) {
|
|
|
437
437
|
}
|
|
438
438
|
|
|
439
439
|
|
|
440
|
+
// Tolerant [src: path] matcher, mirroring llm-wiki's own
|
|
441
|
+
// src/utils/markdown.ts#extractSourceCitations (chained "[src: a.md ; src:
|
|
442
|
+
// b.md]" included) without importing across the repo boundary for one regex.
|
|
443
|
+
const SOURCE_CITATION_PATTERN = /\[\s*src\s*:\s*([^\]]+?)\s*\]/gi;
|
|
444
|
+
|
|
445
|
+
function extractSourceCitationPaths(content) {
|
|
446
|
+
return [...content.matchAll(SOURCE_CITATION_PATTERN)].flatMap((match) =>
|
|
447
|
+
(match[1] ?? '')
|
|
448
|
+
.split(';')
|
|
449
|
+
.map((part) => part.trim().replace(/^src\s*:\s*/i, ''))
|
|
450
|
+
.filter(Boolean),
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function readWorkspaceFile(session, relPath, maxCharsPerDoc) {
|
|
455
|
+
try {
|
|
456
|
+
const absPath = path.resolve(session.workspacePath, relPath);
|
|
457
|
+
const rel = path.relative(session.workspacePath, absPath);
|
|
458
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) return { path: relPath, content: null };
|
|
459
|
+
const raw = await readFile(absPath, 'utf8');
|
|
460
|
+
const content = raw.length > maxCharsPerDoc
|
|
461
|
+
? `${raw.slice(0, maxCharsPerDoc).trimEnd()}\n[truncated]`
|
|
462
|
+
: raw;
|
|
463
|
+
return { path: relPath, content };
|
|
464
|
+
} catch {
|
|
465
|
+
return { path: relPath, content: null };
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
440
469
|
// Read the selected documents' content so chat can summarize them directly,
|
|
441
470
|
// without depending on the model choosing to call a read tool (and without the
|
|
442
471
|
// tool being offered at all). Paths are already sanitized to wiki/ or
|
|
443
472
|
// raw/untracked/ .md files; the path.relative check is defence in depth. A doc
|
|
444
473
|
// that cannot be read yields { content: null } so the caller can note it.
|
|
445
|
-
|
|
474
|
+
//
|
|
475
|
+
// A wiki page is a digest, not the evidence: it names its real sources inline
|
|
476
|
+
// as [src: ...] citations. Reading only the digest is exactly the shortcut
|
|
477
|
+
// that produced an answer covering one facet of a source ingest had split
|
|
478
|
+
// into several concept leaves — the model reused the one digest already in
|
|
479
|
+
// hand instead of going back to the source, even though it was told to.
|
|
480
|
+
// Rather than hope a differently-prompted model chooses to call a read tool
|
|
481
|
+
// for that source, follow each selected page's own citations one level deep
|
|
482
|
+
// and attach them the same deterministic way, up to maxCitedDocs.
|
|
483
|
+
export async function readSelectedPageDocuments(session, pages, { maxCharsPerDoc = 16000, maxCitedDocs = 5 } = {}) {
|
|
446
484
|
if (!Array.isArray(pages) || pages.length === 0 || !session?.workspacePath) return [];
|
|
447
485
|
const docs = [];
|
|
486
|
+
const seen = new Set();
|
|
448
487
|
for (const relPath of pages) {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
: raw;
|
|
460
|
-
docs.push({ path: relPath, content });
|
|
461
|
-
} catch {
|
|
462
|
-
docs.push({ path: relPath, content: null });
|
|
488
|
+
docs.push(await readWorkspaceFile(session, relPath, maxCharsPerDoc));
|
|
489
|
+
seen.add(relPath);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const citedBy = new Map();
|
|
493
|
+
for (const doc of docs) {
|
|
494
|
+
if (typeof doc.content !== 'string') continue;
|
|
495
|
+
for (const citation of extractSourceCitationPaths(doc.content)) {
|
|
496
|
+
if (seen.has(citation) || citedBy.has(citation)) continue;
|
|
497
|
+
citedBy.set(citation, doc.path);
|
|
463
498
|
}
|
|
464
499
|
}
|
|
500
|
+
for (const [citation, citingPath] of [...citedBy.entries()].slice(0, maxCitedDocs)) {
|
|
501
|
+
const doc = await readWorkspaceFile(session, citation, maxCharsPerDoc);
|
|
502
|
+
docs.push({ ...doc, citedBy: citingPath });
|
|
503
|
+
seen.add(citation);
|
|
504
|
+
}
|
|
465
505
|
return docs;
|
|
466
506
|
}
|
|
467
507
|
|
|
@@ -473,7 +513,12 @@ export function buildAttachedDocMessages(docs) {
|
|
|
473
513
|
const readable = (docs ?? []).filter((doc) => typeof doc?.content === 'string' && doc.content.trim());
|
|
474
514
|
if (readable.length === 0) return [];
|
|
475
515
|
const body = readable
|
|
476
|
-
.map((doc) =>
|
|
516
|
+
.map((doc) => {
|
|
517
|
+
const label = doc.citedBy
|
|
518
|
+
? `${doc.path} (cited source of ${doc.citedBy} — the real evidence, not a digest)`
|
|
519
|
+
: doc.path;
|
|
520
|
+
return `--- BEGIN ATTACHED DOCUMENT ${label} ---\n${doc.content}\n--- END ATTACHED DOCUMENT ${label} ---`;
|
|
521
|
+
})
|
|
477
522
|
.join('\n\n');
|
|
478
523
|
return [{
|
|
479
524
|
role: 'user',
|