@dotdrelle/wiki-manager 0.15.42 → 0.15.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +139 -27
- package/mcp.endpoints.example.json +1 -1
- package/package.json +2 -2
- package/src/agent/graph.js +290 -29
- package/src/agent/graph.test.js +551 -1
- package/src/agent/skillRecursion.test.js +98 -0
- package/src/cli/wiki-manager.js +209 -7
- package/src/cli/wiki-manager.test.js +89 -0
- package/src/commands/slash.js +28 -10
- package/src/contracts/schemas.js +1 -1
- package/src/core/agentEvents.js +50 -0
- package/src/core/agentEvents.test.js +52 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/env.js +20 -1
- package/src/core/env.test.js +34 -0
- package/src/core/mcp.js +1 -1
- package/src/core/profile.js +19 -0
- package/src/core/runtimeLog.js +15 -0
- package/src/core/runtimeLog.test.js +15 -1
- package/src/core/skillChainView.js +84 -0
- package/src/core/skillChainView.test.js +50 -0
- package/src/core/skillCompiler.js +135 -0
- package/src/core/skillCompiler.test.js +91 -0
- package/src/core/skillInvocation.js +79 -0
- package/src/core/skillInvocation.test.js +73 -0
- package/src/core/skills.js +81 -19
- package/src/core/wikiWorkspace.test.js +34 -0
- package/src/core/workspaceProfile.test.js +55 -0
- package/src/runtime/client.js +45 -4
- package/src/runtime/controlCancellation.js +33 -0
- package/src/runtime/controlCancellation.test.js +49 -0
- package/src/runtime/controlDrain.js +50 -0
- package/src/runtime/controlDrain.test.js +38 -0
- package/src/runtime/server.js +341 -20
- package/src/runtime/server.test.js +344 -2
- package/src/runtime/skillChain.e2e.test.js +394 -0
- package/src/runtime/skillRun.js +104 -0
- package/src/runtime/skillRun.test.js +84 -0
- package/src/runtime/store.js +69 -0
- package/src/runtime/store.test.js +11 -0
- package/src/runtime/workspaceIsolation.test.js +178 -0
- package/src/shell/RightPane.tsx +3 -2
- package/src/shell/repl.js +51 -6
- package/src/shell/repl.test.js +41 -0
- package/src/shell/useSession.ts +43 -9
- package/wiki-workspace +137 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @statuses-vocabulary
|
|
3
|
+
* Control items are queued run requests, not orchestrator tasks. Their
|
|
4
|
+
* terminal vocabulary deliberately includes chain-level `skipped` and is
|
|
5
|
+
* projected by core/agentEvents.js rather than taskStatuses.js.
|
|
6
|
+
*/
|
|
7
|
+
const TERMINAL = new Set(['done', 'failed', 'cancelled', 'skipped']);
|
|
8
|
+
|
|
9
|
+
export function reconcileControlQueue(context, { startItem, skipItem } = {}) {
|
|
10
|
+
if (!context?.session || context.running || context.controlDrainActive) return false;
|
|
11
|
+
context.controlDrainActive = true;
|
|
12
|
+
try {
|
|
13
|
+
const queue = Array.isArray(context.session.controlQueue) ? context.session.controlQueue : [];
|
|
14
|
+
applySkipPropagation(queue, skipItem);
|
|
15
|
+
// Event dispatch replaces the projection array and its objects. Re-read it
|
|
16
|
+
// after propagation so eligibility never runs against stale queued items.
|
|
17
|
+
const refreshed = Array.isArray(context.session.controlQueue) ? context.session.controlQueue : [];
|
|
18
|
+
const next = refreshed.find((item) => isRunnableControlItem(item, refreshed));
|
|
19
|
+
if (!next) return false;
|
|
20
|
+
startItem?.(next);
|
|
21
|
+
return true;
|
|
22
|
+
} finally {
|
|
23
|
+
context.controlDrainActive = false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isRunnableControlItem(item, queue) {
|
|
28
|
+
if (item?.status !== 'queued') return false;
|
|
29
|
+
if (!item.chainId) return true;
|
|
30
|
+
const predecessors = queue.filter((candidate) => candidate.chainId === item.chainId && Number(candidate.chainSequence) < Number(item.chainSequence));
|
|
31
|
+
if (predecessors.some((candidate) => !TERMINAL.has(candidate.status))) return false;
|
|
32
|
+
return !predecessors.some((candidate) => candidate.optional !== true && candidate.continueOnFailure !== true && ['failed', 'cancelled', 'skipped'].includes(candidate.status));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function applySkipPropagation(queue, skipItem = null) {
|
|
36
|
+
let changed = 0;
|
|
37
|
+
for (const item of queue) {
|
|
38
|
+
if (item.status !== 'queued' || !item.chainId) continue;
|
|
39
|
+
const predecessors = queue.filter((candidate) => candidate.chainId === item.chainId && Number(candidate.chainSequence) < Number(item.chainSequence));
|
|
40
|
+
const blocker = predecessors.find((candidate) => candidate.optional !== true && candidate.continueOnFailure !== true && ['failed', 'cancelled', 'skipped'].includes(candidate.status));
|
|
41
|
+
if (!blocker) continue;
|
|
42
|
+
skipItem?.(item, blocker.status === 'cancelled' ? 'chain_cancelled' : 'required_predecessor_failed');
|
|
43
|
+
// Event dispatch replaces the projected queue instead of mutating this
|
|
44
|
+
// snapshot. Mirror the transition locally so later siblings in this same
|
|
45
|
+
// pass can observe the cascade (failed -> skipped -> skipped).
|
|
46
|
+
item.status = 'skipped';
|
|
47
|
+
changed += 1;
|
|
48
|
+
}
|
|
49
|
+
return changed;
|
|
50
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { applySkipPropagation, reconcileControlQueue } from './controlDrain.js';
|
|
4
|
+
|
|
5
|
+
test('drain starts only the first runnable chain item', async () => {
|
|
6
|
+
const started = [];
|
|
7
|
+
const context = { running: false, session: { controlQueue: [
|
|
8
|
+
{ id: 'a1', chainId: 'a', chainSequence: 0, status: 'queued' },
|
|
9
|
+
{ id: 'a2', chainId: 'a', chainSequence: 1, status: 'queued' },
|
|
10
|
+
] } };
|
|
11
|
+
assert.equal(await reconcileControlQueue(context, { startItem: (item) => { started.push(item.id); context.running = true; } }), true);
|
|
12
|
+
assert.deepEqual(started, ['a1']);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('failed required predecessors skip remaining items but preserve another chain', () => {
|
|
16
|
+
const queue = [
|
|
17
|
+
{ id: 'a1', chainId: 'a', chainSequence: 0, status: 'failed' },
|
|
18
|
+
{ id: 'a2', chainId: 'a', chainSequence: 1, status: 'queued' },
|
|
19
|
+
{ id: 'b1', chainId: 'b', chainSequence: 0, status: 'queued' },
|
|
20
|
+
];
|
|
21
|
+
const skipped = [];
|
|
22
|
+
applySkipPropagation(queue, (item) => { item.status = 'skipped'; skipped.push(item.id); });
|
|
23
|
+
assert.deepEqual(skipped, ['a2']);
|
|
24
|
+
assert.equal(queue[2].status, 'queued');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('one propagation pass skips every required descendant in a long chain', () => {
|
|
28
|
+
const queue = [
|
|
29
|
+
{ id: 'a1', chainId: 'a', chainSequence: 0, status: 'failed' },
|
|
30
|
+
{ id: 'a2', chainId: 'a', chainSequence: 1, status: 'queued' },
|
|
31
|
+
{ id: 'a3', chainId: 'a', chainSequence: 2, status: 'queued' },
|
|
32
|
+
{ id: 'a4', chainId: 'a', chainSequence: 3, status: 'queued' },
|
|
33
|
+
];
|
|
34
|
+
const skipped = [];
|
|
35
|
+
assert.equal(applySkipPropagation(queue, (item) => skipped.push(item.id)), 3);
|
|
36
|
+
assert.deepEqual(skipped, ['a2', 'a3', 'a4']);
|
|
37
|
+
assert.deepEqual(queue.map((item) => item.status), ['failed', 'skipped', 'skipped', 'skipped']);
|
|
38
|
+
});
|
package/src/runtime/server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
|
-
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import { conversationEventSequences, createAgentEvent, dispatchAgentEvent, resetSessionProjection, reduceAgentEvents } from '../core/agentEvents.js';
|
|
4
4
|
import { activeCacertPath } from '../core/cacert.js';
|
|
5
5
|
import { normalizePlanPatch, rebasePlanPatch } from '../core/planPatch.js';
|
|
@@ -8,6 +8,23 @@ 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 { matchSkillInvocation } from '../core/skillInvocation.js';
|
|
12
|
+
import { reconcileControlQueue } from './controlDrain.js';
|
|
13
|
+
import { cancelControlChain, cancelQueuedControlItem } from './controlCancellation.js';
|
|
14
|
+
import { runSkillChain } from './skillRun.js';
|
|
15
|
+
import { findSkill, listSkills } from '../core/skills.js';
|
|
16
|
+
|
|
17
|
+
const PRIVATE_CONTROL_INPUTS = new WeakMap();
|
|
18
|
+
|
|
19
|
+
function privateControlInputsFor(session) {
|
|
20
|
+
if (!session || (typeof session !== 'object' && typeof session !== 'function')) return new Map();
|
|
21
|
+
let inputs = PRIVATE_CONTROL_INPUTS.get(session);
|
|
22
|
+
if (!inputs) {
|
|
23
|
+
inputs = new Map();
|
|
24
|
+
PRIVATE_CONTROL_INPUTS.set(session, inputs);
|
|
25
|
+
}
|
|
26
|
+
return inputs;
|
|
27
|
+
}
|
|
11
28
|
|
|
12
29
|
export function startRuntimeServer({
|
|
13
30
|
host = '127.0.0.1',
|
|
@@ -31,6 +48,8 @@ export function startRuntimeServer({
|
|
|
31
48
|
exitOnShutdown = process.env.WIKI_MANAGER_RUNTIME_CHILD === '1',
|
|
32
49
|
} = {}) {
|
|
33
50
|
const clients = new Set();
|
|
51
|
+
// Compiled objectives are private execution material. They deliberately do
|
|
52
|
+
// not enter events, projections, SSE, audit output or the runs table.
|
|
34
53
|
// When this runtime process started — used by ensureRuntime to detect that
|
|
35
54
|
// the manager source has been edited since (dev staleness) and auto-restart.
|
|
36
55
|
const runtimeStartedAtMs = Date.now();
|
|
@@ -200,6 +219,21 @@ export function startRuntimeServer({
|
|
|
200
219
|
});
|
|
201
220
|
return;
|
|
202
221
|
}
|
|
222
|
+
if (action === 'cancel_item') {
|
|
223
|
+
const itemId = String(body.itemId ?? body.id ?? '').trim();
|
|
224
|
+
if (!itemId) {
|
|
225
|
+
sendJson(response, 400, { error: 'Missing itemId.' });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
validateContractInDev('controlMessage', { ...body, action, id: itemId });
|
|
229
|
+
const result = cancelQueuedControlItem(context.session, itemId, {
|
|
230
|
+
cancelItem: (item, reason) => emitControlCancelled(context, item, reason),
|
|
231
|
+
skipItem: (item, reason) => emitControlSkipped(context, item, reason),
|
|
232
|
+
});
|
|
233
|
+
void drainControlQueue(context);
|
|
234
|
+
sendJson(response, result.cancelled ? 202 : 200, { ...result, ...controlStatus(context, store) });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
203
237
|
sendJson(response, 400, { error: 'Unsupported control action.' });
|
|
204
238
|
return;
|
|
205
239
|
}
|
|
@@ -282,6 +316,45 @@ export function startRuntimeServer({
|
|
|
282
316
|
return;
|
|
283
317
|
}
|
|
284
318
|
validateContractInDev('runRequest', { ...body, input });
|
|
319
|
+
const explicitSkillName = String(body.skillName ?? '').trim().toLowerCase();
|
|
320
|
+
const invokedSkillName = /^\/([A-Za-z0-9_-]+)/.exec(input)?.[1]?.toLowerCase() ?? '';
|
|
321
|
+
if (body.skillArguments !== undefined && explicitSkillName !== invokedSkillName) {
|
|
322
|
+
sendJson(response, 400, { ok: false, terminal: true, code: 'skill_name_mismatch' });
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
// This structured API call is itself the caller's explicit skill
|
|
326
|
+
// designation, including for reserved names. The equality check
|
|
327
|
+
// above prevents `skillName: status` from turning unrelated prose
|
|
328
|
+
// into the homonymous skill. Model-originated calls additionally
|
|
329
|
+
// pass explicitSkillReference() before reaching this endpoint.
|
|
330
|
+
if (explicitSkillName && body.skillArguments !== undefined) {
|
|
331
|
+
const result = await context.session._runSkillWithinRun(explicitSkillName, body.skillArguments, {
|
|
332
|
+
idempotencyKey: body.idempotencyKey ?? null,
|
|
333
|
+
turnId: body.turnId ?? null,
|
|
334
|
+
selectionKind: body.selectionKind ?? null,
|
|
335
|
+
// Pile de l'appelant : c'est le seul canal par lequel elle peut
|
|
336
|
+
// franchir la frontière HTTP.
|
|
337
|
+
skillStack: Array.isArray(body.skillStack) ? body.skillStack : [],
|
|
338
|
+
});
|
|
339
|
+
sendJson(response, result.accepted ? 202 : skillResultErrorStatus(result), result);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const skillMatch = matchSkillInvocation(context.session, input, {
|
|
343
|
+
// Reserved names are unlocked only by a matching structured skill
|
|
344
|
+
// invocation, never by an unrelated truthy marker in the body.
|
|
345
|
+
allowReserved: Boolean(explicitSkillName && explicitSkillName === invokedSkillName),
|
|
346
|
+
});
|
|
347
|
+
if (skillMatch) {
|
|
348
|
+
try {
|
|
349
|
+
const result = await enqueueSkillInvocation(context, skillMatch);
|
|
350
|
+
sendJson(response, 202, { accepted: true, kind: 'skill_chain', ...result, ...controlStatus(context, store) });
|
|
351
|
+
} catch (err) {
|
|
352
|
+
const error = skillInvocationErrorMessage(err);
|
|
353
|
+
publishSkillInvocationFailure(context, input, error);
|
|
354
|
+
sendJson(response, skillInvocationErrorStatus(err), { error, code: err?.code ?? 'skill_compile_failed' });
|
|
355
|
+
}
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
285
358
|
if (context.running) {
|
|
286
359
|
// A structured capability plan must never degrade into a free-text
|
|
287
360
|
// control message: its exact arguments and approval policy would
|
|
@@ -339,6 +412,23 @@ export function startRuntimeServer({
|
|
|
339
412
|
// run is active. Other interactive turns still become control
|
|
340
413
|
// messages so they cannot start a competing agent decision.
|
|
341
414
|
const readOnlyChat = String(body.mode ?? '').toLowerCase() === 'chat';
|
|
415
|
+
// An explicit /skill invocation has deterministic meaning. Keep the
|
|
416
|
+
// conversational /turn boundary, but do not ask the LLM to rediscover
|
|
417
|
+
// the skill from prose: it could choose a direct mutation instead and
|
|
418
|
+
// silently bypass the private workflow. Informational prose merely
|
|
419
|
+
// mentioning a skill never matches this anchored invocation parser.
|
|
420
|
+
const skillMatch = !readOnlyChat ? matchSkillInvocation(context.session, input) : null;
|
|
421
|
+
if (skillMatch) {
|
|
422
|
+
try {
|
|
423
|
+
const result = await enqueueSkillInvocation(context, skillMatch);
|
|
424
|
+
sendJson(response, 202, { accepted: true, kind: 'skill_chain', ...result, ...controlStatus(context, store) });
|
|
425
|
+
} catch (err) {
|
|
426
|
+
const error = skillInvocationErrorMessage(err);
|
|
427
|
+
publishSkillInvocationFailure(context, input, error);
|
|
428
|
+
sendJson(response, skillInvocationErrorStatus(err), { error, code: err?.code ?? 'skill_compile_failed' });
|
|
429
|
+
}
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
342
432
|
if (context.running && !readOnlyChat) {
|
|
343
433
|
const result = await handleControlMessage(context, store, input, {
|
|
344
434
|
intent: body.intent,
|
|
@@ -436,6 +526,10 @@ export function startRuntimeServer({
|
|
|
436
526
|
sendJson(response, 200, { cancelled: false, reason: 'no active run' });
|
|
437
527
|
return;
|
|
438
528
|
}
|
|
529
|
+
cancelControlChain(context.session, {
|
|
530
|
+
runId: context.currentRunId,
|
|
531
|
+
cancelItem: (item, reason) => emitControlSkipped(context, item, reason),
|
|
532
|
+
});
|
|
439
533
|
context.currentAbortController.abort();
|
|
440
534
|
await cancel?.(context);
|
|
441
535
|
sendJson(response, 202, { cancelled: true, workspace: context.workspace ?? workspace ?? null });
|
|
@@ -469,10 +563,16 @@ export function startRuntimeServer({
|
|
|
469
563
|
});
|
|
470
564
|
return;
|
|
471
565
|
}
|
|
472
|
-
// Redo:
|
|
473
|
-
// everything
|
|
474
|
-
//
|
|
475
|
-
//
|
|
566
|
+
// Redo: drop the conversation entry at `index` (the question) AND
|
|
567
|
+
// everything recorded after it. The conversation is derived from the
|
|
568
|
+
// event log, so a UI-side deletion alone would be undone by the next
|
|
569
|
+
// /state merge — the truncation has to happen here.
|
|
570
|
+
//
|
|
571
|
+
// The question goes too because every caller of this route immediately
|
|
572
|
+
// resubmits it. Keeping it server-side meant the resubmission appended a
|
|
573
|
+
// second copy, and the next /state merge brought the first one back into
|
|
574
|
+
// a UI that had just removed it — the question ended up displayed twice
|
|
575
|
+
// in both the served chat and the shell.
|
|
476
576
|
if (request.method === 'POST' && url.pathname === '/conversation/truncate') {
|
|
477
577
|
const { body, workspace, context } = await resolveBodyContext(request, url);
|
|
478
578
|
if (context?.running) {
|
|
@@ -500,7 +600,11 @@ export function startRuntimeServer({
|
|
|
500
600
|
sendJson(response, 400, { truncated: false, reason: 'index_out_of_range' });
|
|
501
601
|
return;
|
|
502
602
|
}
|
|
503
|
-
|
|
603
|
+
// `boundary` is the sequence of the question's own event; deleting
|
|
604
|
+
// strictly after `boundary - 1` removes it along with its answers.
|
|
605
|
+
// Sequences are integers and strictly increasing, so this cannot catch
|
|
606
|
+
// an unrelated event between the two values.
|
|
607
|
+
const removedEvents = store.deleteEventsAfter(boundary - 1, { workspace: resolvedWorkspace });
|
|
504
608
|
// getState prefers the in-memory projection over the event log, so the
|
|
505
609
|
// deleted answers would survive in RAM without this rehydration.
|
|
506
610
|
if (context?.session) {
|
|
@@ -551,7 +655,7 @@ export function startRuntimeServer({
|
|
|
551
655
|
host,
|
|
552
656
|
port: typeof address === 'object' && address ? address.port : port,
|
|
553
657
|
publish,
|
|
554
|
-
drainControl: (context) =>
|
|
658
|
+
drainControl: (context) => drainControlQueue(context),
|
|
555
659
|
close: () => new Promise((closeResolve, closeReject) => {
|
|
556
660
|
for (const client of clients) client.response.end();
|
|
557
661
|
clients.clear();
|
|
@@ -562,7 +666,70 @@ export function startRuntimeServer({
|
|
|
562
666
|
});
|
|
563
667
|
|
|
564
668
|
async function resolveContext({ workspace = null } = {}) {
|
|
565
|
-
|
|
669
|
+
const context = await resolvedGetContext(workspace);
|
|
670
|
+
if (context?.session) {
|
|
671
|
+
context.session._runSkillWithinRun = async (skillName, args = {}, metadata = {}) => {
|
|
672
|
+
const skill = findSkill(context.session, skillName);
|
|
673
|
+
if (!skill) return {
|
|
674
|
+
ok: false,
|
|
675
|
+
terminal: true,
|
|
676
|
+
code: 'skill_not_found',
|
|
677
|
+
availableSkills: listSkills(context.session).map((item) => item.name),
|
|
678
|
+
};
|
|
679
|
+
try {
|
|
680
|
+
const idempotencyKey = metadata.idempotencyKey
|
|
681
|
+
? String(metadata.idempotencyKey)
|
|
682
|
+
: metadata.turnId
|
|
683
|
+
? skillIdempotencyKey(context.workspace ?? context.session.workspace, metadata.turnId, skill.name, args)
|
|
684
|
+
: null;
|
|
685
|
+
context.skillRuns ??= new Map();
|
|
686
|
+
context.pendingSkillRuns ??= new Map();
|
|
687
|
+
const persistedChainId = idempotencyKey && typeof store.findSkillRun === 'function'
|
|
688
|
+
? store.findSkillRun({ workspace: context.workspace ?? null, idempotencyKey })
|
|
689
|
+
: null;
|
|
690
|
+
const existingChainId = persistedChainId ?? (idempotencyKey ? context.skillRuns.get(idempotencyKey) : null);
|
|
691
|
+
if (existingChainId) return publicSkillRunProjection(context, skill.name, existingChainId, metadata.selectionKind, true);
|
|
692
|
+
if (idempotencyKey && context.pendingSkillRuns.has(idempotencyKey)) {
|
|
693
|
+
const pendingChainId = await context.pendingSkillRuns.get(idempotencyKey);
|
|
694
|
+
return publicSkillRunProjection(context, skill.name, pendingChainId, metadata.selectionKind, true);
|
|
695
|
+
}
|
|
696
|
+
const creation = runSkillChain(context, skill, {
|
|
697
|
+
args,
|
|
698
|
+
enqueueControlRequest,
|
|
699
|
+
drainControlQueue,
|
|
700
|
+
selectionKind: metadata.selectionKind,
|
|
701
|
+
// La pile du run appelant : elle sera empilée sur chaque élément mis
|
|
702
|
+
// en file, et c'est elle seule qui survit au hand-off.
|
|
703
|
+
skillStack: Array.isArray(metadata.skillStack) ? metadata.skillStack : [],
|
|
704
|
+
});
|
|
705
|
+
if (idempotencyKey) context.pendingSkillRuns.set(idempotencyKey, creation.then((item) => item.chainId));
|
|
706
|
+
const result = await creation;
|
|
707
|
+
if (idempotencyKey) {
|
|
708
|
+
context.skillRuns.set(idempotencyKey, result.chainId);
|
|
709
|
+
store.persistSkillRun?.({
|
|
710
|
+
workspace: context.workspace ?? context.session.workspace ?? null,
|
|
711
|
+
idempotencyKey,
|
|
712
|
+
chainId: result.chainId,
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
return publicSkillRunProjection(context, skill.name, result.chainId, metadata.selectionKind, false);
|
|
716
|
+
} catch (error) {
|
|
717
|
+
return {
|
|
718
|
+
ok: false,
|
|
719
|
+
terminal: true,
|
|
720
|
+
code: error?.code ?? 'skill_compile_failed',
|
|
721
|
+
};
|
|
722
|
+
} finally {
|
|
723
|
+
const key = metadata.idempotencyKey
|
|
724
|
+
? String(metadata.idempotencyKey)
|
|
725
|
+
: metadata.turnId
|
|
726
|
+
? skillIdempotencyKey(context.workspace ?? context.session.workspace, metadata.turnId, skill.name, args)
|
|
727
|
+
: null;
|
|
728
|
+
if (key) context.pendingSkillRuns?.delete(key);
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
return context;
|
|
566
733
|
}
|
|
567
734
|
|
|
568
735
|
// Shared by POST handlers that take a JSON body carrying an optional
|
|
@@ -639,22 +806,103 @@ export function startRuntimeServer({
|
|
|
639
806
|
context.currentRunId = null;
|
|
640
807
|
context.currentRunWorkspace = null;
|
|
641
808
|
publishState(runWorkspace, context);
|
|
642
|
-
void
|
|
809
|
+
void drainControlQueue(context);
|
|
643
810
|
});
|
|
644
811
|
return { accepted: true, runId, workspace: runWorkspace, ...(ready ? { ready } : {}) };
|
|
645
812
|
}
|
|
646
813
|
|
|
647
814
|
function startNextControlRequest(context) {
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
815
|
+
return drainControlQueue(context);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function drainControlQueue(context) {
|
|
819
|
+
return reconcileControlQueue(context, {
|
|
820
|
+
/*
|
|
821
|
+
La provenance de chaîne suit le run.
|
|
822
|
+
|
|
823
|
+
`chainId` et `skillName` vivaient sur l'item de contrôle et s'arrêtaient
|
|
824
|
+
là : le run ne savait pas qu'il exécutait une intention compilée depuis
|
|
825
|
+
une compétence. L'agent voyait donc un objectif métier — « ingérer les
|
|
826
|
+
fichiers en attente » — qui ressemble par construction à la description
|
|
827
|
+
de la compétence dont il sort, et la resélectionnait. En headless, où
|
|
828
|
+
personne n'interrompt, cela boucle jusqu'à épuisement du budget LLM.
|
|
829
|
+
*/
|
|
830
|
+
startItem: (item) => startRuntimeRun(context, {
|
|
831
|
+
input: takePrivateControlInput(context.session, item),
|
|
832
|
+
publicInput: item.input,
|
|
833
|
+
workspace: item.workspace ?? context.workspace ?? null,
|
|
834
|
+
// Interactive skill runs never approve their own mutations. Headless
|
|
835
|
+
// may still grant the pending run explicitly through --auto-approve.
|
|
836
|
+
...(item.chainId ? { requireApproval: true } : {}),
|
|
837
|
+
...(item.capabilityPlan !== undefined ? { capabilityPlan: item.capabilityPlan } : {}),
|
|
838
|
+
...(item.chainId
|
|
839
|
+
? {
|
|
840
|
+
skillChain: {
|
|
841
|
+
chainId: item.chainId,
|
|
842
|
+
skillName: item.skillName ?? null,
|
|
843
|
+
execution: item.skillExecution === 'direct' ? 'direct' : 'orchestrated',
|
|
844
|
+
// La pile des ancêtres, sans quoi le run ne peut pas savoir qu'il
|
|
845
|
+
// referme un cycle commencé deux hand-offs plus tôt.
|
|
846
|
+
skillStack: Array.isArray(item.skillStack) ? item.skillStack : [],
|
|
847
|
+
},
|
|
848
|
+
}
|
|
849
|
+
: {}),
|
|
850
|
+
}, { controlItemId: item.id }),
|
|
851
|
+
skipItem: (item, reason) => {
|
|
852
|
+
privateControlInputsFor(context.session).delete(item.id);
|
|
853
|
+
emitControlSkipped(context, item, reason);
|
|
854
|
+
},
|
|
855
|
+
});
|
|
657
856
|
}
|
|
857
|
+
|
|
858
|
+
function takePrivateControlInput(session, item) {
|
|
859
|
+
const privateControlInputs = privateControlInputsFor(session);
|
|
860
|
+
const input = privateControlInputs.get(item.id) ?? item.input;
|
|
861
|
+
privateControlInputs.delete(item.id);
|
|
862
|
+
return input;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
async function enqueueSkillInvocation(context, match) {
|
|
866
|
+
const result = await runSkillChain(context, match.skill, {
|
|
867
|
+
rawArgs: match.rawArgs,
|
|
868
|
+
enqueueControlRequest,
|
|
869
|
+
drainControlQueue,
|
|
870
|
+
selectionKind: 'explicit_name',
|
|
871
|
+
});
|
|
872
|
+
// Publish only after compilation succeeds. The marker prevents an
|
|
873
|
+
// invocation queued during another run from inheriting that run's id.
|
|
874
|
+
dispatchAgentEvent(context.session, createAgentEvent('user_message', {
|
|
875
|
+
origin: 'user',
|
|
876
|
+
workspace: context.workspace ?? null,
|
|
877
|
+
payload: { content: `/${match.skill.name}${match.rawArgs ? ` ${match.rawArgs}` : ''}`, independent: true },
|
|
878
|
+
}));
|
|
879
|
+
return result;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function publishSkillInvocationFailure(context, input, error) {
|
|
883
|
+
const workspace = context.workspace ?? context.session?.workspace ?? null;
|
|
884
|
+
dispatchAgentEvent(context.session, createAgentEvent('user_message', {
|
|
885
|
+
origin: 'user', workspace, payload: { content: input, independent: true },
|
|
886
|
+
}));
|
|
887
|
+
dispatchAgentEvent(context.session, createAgentEvent('assistant_message', {
|
|
888
|
+
origin: 'runtime', workspace, payload: { content: error, independent: true },
|
|
889
|
+
}));
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function skillInvocationErrorStatus(error) {
|
|
894
|
+
return ['skill_compile_failed', 'skill_arguments_invalid'].includes(error?.code) ? 400 : 422;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function skillResultErrorStatus(result) {
|
|
898
|
+
if (result?.code === 'skill_not_found') return 404;
|
|
899
|
+
if (result?.code === 'skill_arguments_invalid') return 400;
|
|
900
|
+
return 422;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function skillInvocationErrorMessage(error) {
|
|
904
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
905
|
+
return `Skill invocation could not be compiled: ${message}`;
|
|
658
906
|
}
|
|
659
907
|
|
|
660
908
|
function workspaceFromUrl(url) {
|
|
@@ -738,6 +986,37 @@ function controlQueueFor(session) {
|
|
|
738
986
|
return Array.isArray(session?.controlQueue) ? session.controlQueue : [];
|
|
739
987
|
}
|
|
740
988
|
|
|
989
|
+
function skillIdempotencyKey(workspace, turnId, skillName, args) {
|
|
990
|
+
const canonicalArguments = JSON.stringify(Object.fromEntries(
|
|
991
|
+
Object.entries(args ?? {}).sort(([left], [right]) => left.localeCompare(right)),
|
|
992
|
+
));
|
|
993
|
+
return createHash('sha256')
|
|
994
|
+
.update(`${workspace ?? ''}\0${turnId}\0${String(skillName).toLowerCase()}\0${canonicalArguments}`)
|
|
995
|
+
.digest('hex');
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function publicSkillRunProjection(context, skillName, chainId, selectionKind, deduplicated) {
|
|
999
|
+
const chainItems = controlQueueFor(context?.session).filter((item) => item.chainId === chainId);
|
|
1000
|
+
const resolvedSelectionKind = selectionKind ?? chainItems.find((item) => item.selectionKind)?.selectionKind ?? null;
|
|
1001
|
+
const items = chainItems
|
|
1002
|
+
.sort((left, right) => Number(left.chainSequence ?? 0) - Number(right.chainSequence ?? 0))
|
|
1003
|
+
.map((item) => ({
|
|
1004
|
+
id: item.id,
|
|
1005
|
+
sequence: Number(item.chainSequence ?? 0),
|
|
1006
|
+
status: item.status,
|
|
1007
|
+
optional: item.optional === true,
|
|
1008
|
+
}));
|
|
1009
|
+
return {
|
|
1010
|
+
accepted: true,
|
|
1011
|
+
skill: skillName,
|
|
1012
|
+
chainId,
|
|
1013
|
+
objectiveCount: items.length,
|
|
1014
|
+
items,
|
|
1015
|
+
...(resolvedSelectionKind ? { selectionKind: resolvedSelectionKind } : {}),
|
|
1016
|
+
...(deduplicated ? { deduplicated: true } : {}),
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
|
|
741
1020
|
function cancelQueuedControlItems(session, workspace = null) {
|
|
742
1021
|
const now = new Date().toISOString();
|
|
743
1022
|
const counted = new Set();
|
|
@@ -750,6 +1029,7 @@ function cancelQueuedControlItems(session, workspace = null) {
|
|
|
750
1029
|
item.finishedAt = now;
|
|
751
1030
|
item.updatedAt = now;
|
|
752
1031
|
const id = item.id ?? item.runId ?? `${item.input}:${item.createdAt}`;
|
|
1032
|
+
privateControlInputsFor(session).delete(item.id);
|
|
753
1033
|
if (!counted.has(id)) {
|
|
754
1034
|
counted.add(id);
|
|
755
1035
|
if (session) {
|
|
@@ -865,18 +1145,42 @@ async function handleControlMessage(context, store, input, { intent = null, star
|
|
|
865
1145
|
: controlMessage(context?.session, 'converse_while_idle'));
|
|
866
1146
|
}
|
|
867
1147
|
|
|
868
|
-
|
|
1148
|
+
/*
|
|
1149
|
+
`skillStack` accompagne l'élément, il ne vit pas sur la session.
|
|
1150
|
+
|
|
1151
|
+
La pile des compétences en cours était posée sur la session pour la durée d'UN
|
|
1152
|
+
run, et restaurée par son `finally`. Or une compétence imbriquée n'est pas
|
|
1153
|
+
exécutée en ligne : elle est MISE EN FILE, et son run démarre après que le
|
|
1154
|
+
parent a fini de se nettoyer. La pile qu'elle lisait était donc déjà vide.
|
|
1155
|
+
|
|
1156
|
+
Conséquence : la garde n'attrapait que le cas pour lequel elle avait été
|
|
1157
|
+
écrite — une compétence qui se relance dans son propre run — et laissait
|
|
1158
|
+
passer A→B→A, qui boucle jusqu'à épuisement du budget en headless.
|
|
1159
|
+
|
|
1160
|
+
Une file est un passage de témoin : ce qui doit survivre au parent voyage avec
|
|
1161
|
+
le message, pas dans l'état de celui qui l'a posté.
|
|
1162
|
+
*/
|
|
1163
|
+
function enqueueControlRequest(context, input, { publicInput = null, capabilityPlan, chainId, chainSequence, skillName, skillExecution, skillStack, selectionKind, optional = false, continueOnFailure = false } = {}) {
|
|
869
1164
|
const now = new Date().toISOString();
|
|
870
1165
|
const item = {
|
|
871
1166
|
id: `control-${randomUUID()}`,
|
|
872
1167
|
workspace: context?.workspace ?? context?.session?.workspace ?? null,
|
|
873
1168
|
type: 'run_request',
|
|
874
|
-
input,
|
|
1169
|
+
input: publicInput ?? input,
|
|
875
1170
|
status: 'queued',
|
|
876
1171
|
createdAt: now,
|
|
877
1172
|
updatedAt: now,
|
|
878
1173
|
...(capabilityPlan !== undefined ? { capabilityPlan } : {}),
|
|
1174
|
+
...(chainId ? { chainId } : {}),
|
|
1175
|
+
...(Number.isInteger(chainSequence) ? { chainSequence } : {}),
|
|
1176
|
+
...(skillName ? { skillName } : {}),
|
|
1177
|
+
...(skillExecution ? { skillExecution } : {}),
|
|
1178
|
+
...(Array.isArray(skillStack) && skillStack.length ? { skillStack: [...skillStack] } : {}),
|
|
1179
|
+
...(selectionKind ? { selectionKind } : {}),
|
|
1180
|
+
optional: optional === true,
|
|
1181
|
+
continueOnFailure: continueOnFailure === true,
|
|
879
1182
|
};
|
|
1183
|
+
privateControlInputsFor(context?.session).set(item.id, input);
|
|
880
1184
|
dispatchAgentEvent(context.session, createAgentEvent('control_enqueued', {
|
|
881
1185
|
origin: 'runtime',
|
|
882
1186
|
workspace: item.workspace,
|
|
@@ -885,6 +1189,23 @@ function enqueueControlRequest(context, input, { capabilityPlan } = {}) {
|
|
|
885
1189
|
return item;
|
|
886
1190
|
}
|
|
887
1191
|
|
|
1192
|
+
function emitControlSkipped(context, item, reason) {
|
|
1193
|
+
dispatchAgentEvent(context.session, createAgentEvent('control_skipped', {
|
|
1194
|
+
origin: 'runtime',
|
|
1195
|
+
workspace: item.workspace ?? context.workspace ?? null,
|
|
1196
|
+
payload: { id: item.id, reason, finishedAt: new Date().toISOString() },
|
|
1197
|
+
}));
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function emitControlCancelled(context, item, reason) {
|
|
1201
|
+
privateControlInputsFor(context?.session).delete(item.id);
|
|
1202
|
+
dispatchAgentEvent(context.session, createAgentEvent('control_cancelled', {
|
|
1203
|
+
origin: 'runtime',
|
|
1204
|
+
workspace: item.workspace ?? context.workspace ?? null,
|
|
1205
|
+
payload: { id: item.id, reason, finishedAt: new Date().toISOString() },
|
|
1206
|
+
}));
|
|
1207
|
+
}
|
|
1208
|
+
|
|
888
1209
|
function storeControlProposal(context, input, classification, status) {
|
|
889
1210
|
const now = new Date().toISOString();
|
|
890
1211
|
const patch = buildPlanPatchFromInput(input, status);
|