@dotdrelle/wiki-manager 0.15.43 → 0.15.49
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 +92 -24
- package/mcp.endpoints.example.json +1 -1
- package/package.json +2 -2
- package/src/agent/graph.js +288 -22
- 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/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/runtime/approvals.js +13 -1
- package/src/runtime/approvals.test.js +44 -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 +333 -15
- package/src/runtime/server.test.js +392 -0
- 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/shell/RightPane.tsx +3 -2
- package/src/shell/repl.js +40 -5
- package/src/shell/repl.test.js +41 -0
- package/src/shell/useSession.ts +43 -9
|
@@ -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 });
|
|
@@ -447,6 +541,13 @@ export function startRuntimeServer({
|
|
|
447
541
|
const runId = url.searchParams.get('runId') ?? body.runId ?? null;
|
|
448
542
|
const purge = body.purge === true || url.searchParams.get('purge') === 'true';
|
|
449
543
|
const context = await resolveContext({ workspace });
|
|
544
|
+
const resolvedWorkspace = context?.workspace ?? workspace ?? null;
|
|
545
|
+
if (purge && !runId && !resolvedWorkspace) {
|
|
546
|
+
// Purge is workspace-wide and destructive: without a scope it would
|
|
547
|
+
// wipe every workspace's history. Mirror /conversation/truncate.
|
|
548
|
+
sendJson(response, 400, { killed: false, reason: 'workspace_required' });
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
450
551
|
const result = await killRuntimeRuns(context, { workspace, runId, purge });
|
|
451
552
|
publishState(context.workspace ?? workspace ?? null, context);
|
|
452
553
|
sendJson(response, 202, result);
|
|
@@ -561,7 +662,7 @@ export function startRuntimeServer({
|
|
|
561
662
|
host,
|
|
562
663
|
port: typeof address === 'object' && address ? address.port : port,
|
|
563
664
|
publish,
|
|
564
|
-
drainControl: (context) =>
|
|
665
|
+
drainControl: (context) => drainControlQueue(context),
|
|
565
666
|
close: () => new Promise((closeResolve, closeReject) => {
|
|
566
667
|
for (const client of clients) client.response.end();
|
|
567
668
|
clients.clear();
|
|
@@ -572,7 +673,70 @@ export function startRuntimeServer({
|
|
|
572
673
|
});
|
|
573
674
|
|
|
574
675
|
async function resolveContext({ workspace = null } = {}) {
|
|
575
|
-
|
|
676
|
+
const context = await resolvedGetContext(workspace);
|
|
677
|
+
if (context?.session) {
|
|
678
|
+
context.session._runSkillWithinRun = async (skillName, args = {}, metadata = {}) => {
|
|
679
|
+
const skill = findSkill(context.session, skillName);
|
|
680
|
+
if (!skill) return {
|
|
681
|
+
ok: false,
|
|
682
|
+
terminal: true,
|
|
683
|
+
code: 'skill_not_found',
|
|
684
|
+
availableSkills: listSkills(context.session).map((item) => item.name),
|
|
685
|
+
};
|
|
686
|
+
try {
|
|
687
|
+
const idempotencyKey = metadata.idempotencyKey
|
|
688
|
+
? String(metadata.idempotencyKey)
|
|
689
|
+
: metadata.turnId
|
|
690
|
+
? skillIdempotencyKey(context.workspace ?? context.session.workspace, metadata.turnId, skill.name, args)
|
|
691
|
+
: null;
|
|
692
|
+
context.skillRuns ??= new Map();
|
|
693
|
+
context.pendingSkillRuns ??= new Map();
|
|
694
|
+
const persistedChainId = idempotencyKey && typeof store.findSkillRun === 'function'
|
|
695
|
+
? store.findSkillRun({ workspace: context.workspace ?? null, idempotencyKey })
|
|
696
|
+
: null;
|
|
697
|
+
const existingChainId = persistedChainId ?? (idempotencyKey ? context.skillRuns.get(idempotencyKey) : null);
|
|
698
|
+
if (existingChainId) return publicSkillRunProjection(context, skill.name, existingChainId, metadata.selectionKind, true);
|
|
699
|
+
if (idempotencyKey && context.pendingSkillRuns.has(idempotencyKey)) {
|
|
700
|
+
const pendingChainId = await context.pendingSkillRuns.get(idempotencyKey);
|
|
701
|
+
return publicSkillRunProjection(context, skill.name, pendingChainId, metadata.selectionKind, true);
|
|
702
|
+
}
|
|
703
|
+
const creation = runSkillChain(context, skill, {
|
|
704
|
+
args,
|
|
705
|
+
enqueueControlRequest,
|
|
706
|
+
drainControlQueue,
|
|
707
|
+
selectionKind: metadata.selectionKind,
|
|
708
|
+
// La pile du run appelant : elle sera empilée sur chaque élément mis
|
|
709
|
+
// en file, et c'est elle seule qui survit au hand-off.
|
|
710
|
+
skillStack: Array.isArray(metadata.skillStack) ? metadata.skillStack : [],
|
|
711
|
+
});
|
|
712
|
+
if (idempotencyKey) context.pendingSkillRuns.set(idempotencyKey, creation.then((item) => item.chainId));
|
|
713
|
+
const result = await creation;
|
|
714
|
+
if (idempotencyKey) {
|
|
715
|
+
context.skillRuns.set(idempotencyKey, result.chainId);
|
|
716
|
+
store.persistSkillRun?.({
|
|
717
|
+
workspace: context.workspace ?? context.session.workspace ?? null,
|
|
718
|
+
idempotencyKey,
|
|
719
|
+
chainId: result.chainId,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
return publicSkillRunProjection(context, skill.name, result.chainId, metadata.selectionKind, false);
|
|
723
|
+
} catch (error) {
|
|
724
|
+
return {
|
|
725
|
+
ok: false,
|
|
726
|
+
terminal: true,
|
|
727
|
+
code: error?.code ?? 'skill_compile_failed',
|
|
728
|
+
};
|
|
729
|
+
} finally {
|
|
730
|
+
const key = metadata.idempotencyKey
|
|
731
|
+
? String(metadata.idempotencyKey)
|
|
732
|
+
: metadata.turnId
|
|
733
|
+
? skillIdempotencyKey(context.workspace ?? context.session.workspace, metadata.turnId, skill.name, args)
|
|
734
|
+
: null;
|
|
735
|
+
if (key) context.pendingSkillRuns?.delete(key);
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
return context;
|
|
576
740
|
}
|
|
577
741
|
|
|
578
742
|
// Shared by POST handlers that take a JSON body carrying an optional
|
|
@@ -649,24 +813,105 @@ export function startRuntimeServer({
|
|
|
649
813
|
context.currentRunId = null;
|
|
650
814
|
context.currentRunWorkspace = null;
|
|
651
815
|
publishState(runWorkspace, context);
|
|
652
|
-
void
|
|
816
|
+
void drainControlQueue(context);
|
|
653
817
|
});
|
|
654
818
|
return { accepted: true, runId, workspace: runWorkspace, ...(ready ? { ready } : {}) };
|
|
655
819
|
}
|
|
656
820
|
|
|
657
821
|
function startNextControlRequest(context) {
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
822
|
+
return drainControlQueue(context);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function drainControlQueue(context) {
|
|
826
|
+
return reconcileControlQueue(context, {
|
|
827
|
+
/*
|
|
828
|
+
La provenance de chaîne suit le run.
|
|
829
|
+
|
|
830
|
+
`chainId` et `skillName` vivaient sur l'item de contrôle et s'arrêtaient
|
|
831
|
+
là : le run ne savait pas qu'il exécutait une intention compilée depuis
|
|
832
|
+
une compétence. L'agent voyait donc un objectif métier — « ingérer les
|
|
833
|
+
fichiers en attente » — qui ressemble par construction à la description
|
|
834
|
+
de la compétence dont il sort, et la resélectionnait. En headless, où
|
|
835
|
+
personne n'interrompt, cela boucle jusqu'à épuisement du budget LLM.
|
|
836
|
+
*/
|
|
837
|
+
startItem: (item) => startRuntimeRun(context, {
|
|
838
|
+
input: takePrivateControlInput(context.session, item),
|
|
839
|
+
publicInput: item.input,
|
|
840
|
+
workspace: item.workspace ?? context.workspace ?? null,
|
|
841
|
+
// Interactive skill runs never approve their own mutations. Headless
|
|
842
|
+
// may still grant the pending run explicitly through --auto-approve.
|
|
843
|
+
...(item.chainId ? { requireApproval: true } : {}),
|
|
844
|
+
...(item.capabilityPlan !== undefined ? { capabilityPlan: item.capabilityPlan } : {}),
|
|
845
|
+
...(item.chainId
|
|
846
|
+
? {
|
|
847
|
+
skillChain: {
|
|
848
|
+
chainId: item.chainId,
|
|
849
|
+
skillName: item.skillName ?? null,
|
|
850
|
+
execution: item.skillExecution === 'direct' ? 'direct' : 'orchestrated',
|
|
851
|
+
// La pile des ancêtres, sans quoi le run ne peut pas savoir qu'il
|
|
852
|
+
// referme un cycle commencé deux hand-offs plus tôt.
|
|
853
|
+
skillStack: Array.isArray(item.skillStack) ? item.skillStack : [],
|
|
854
|
+
},
|
|
855
|
+
}
|
|
856
|
+
: {}),
|
|
857
|
+
}, { controlItemId: item.id }),
|
|
858
|
+
skipItem: (item, reason) => {
|
|
859
|
+
privateControlInputsFor(context.session).delete(item.id);
|
|
860
|
+
emitControlSkipped(context, item, reason);
|
|
861
|
+
},
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function takePrivateControlInput(session, item) {
|
|
866
|
+
const privateControlInputs = privateControlInputsFor(session);
|
|
867
|
+
const input = privateControlInputs.get(item.id) ?? item.input;
|
|
868
|
+
privateControlInputs.delete(item.id);
|
|
869
|
+
return input;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async function enqueueSkillInvocation(context, match) {
|
|
873
|
+
const result = await runSkillChain(context, match.skill, {
|
|
874
|
+
rawArgs: match.rawArgs,
|
|
875
|
+
enqueueControlRequest,
|
|
876
|
+
drainControlQueue,
|
|
877
|
+
selectionKind: 'explicit_name',
|
|
878
|
+
});
|
|
879
|
+
// Publish only after compilation succeeds. The marker prevents an
|
|
880
|
+
// invocation queued during another run from inheriting that run's id.
|
|
881
|
+
dispatchAgentEvent(context.session, createAgentEvent('user_message', {
|
|
882
|
+
origin: 'user',
|
|
883
|
+
workspace: context.workspace ?? null,
|
|
884
|
+
payload: { content: `/${match.skill.name}${match.rawArgs ? ` ${match.rawArgs}` : ''}`, independent: true },
|
|
885
|
+
}));
|
|
886
|
+
return result;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function publishSkillInvocationFailure(context, input, error) {
|
|
890
|
+
const workspace = context.workspace ?? context.session?.workspace ?? null;
|
|
891
|
+
dispatchAgentEvent(context.session, createAgentEvent('user_message', {
|
|
892
|
+
origin: 'user', workspace, payload: { content: input, independent: true },
|
|
893
|
+
}));
|
|
894
|
+
dispatchAgentEvent(context.session, createAgentEvent('assistant_message', {
|
|
895
|
+
origin: 'runtime', workspace, payload: { content: error, independent: true },
|
|
896
|
+
}));
|
|
667
897
|
}
|
|
668
898
|
}
|
|
669
899
|
|
|
900
|
+
function skillInvocationErrorStatus(error) {
|
|
901
|
+
return ['skill_compile_failed', 'skill_arguments_invalid'].includes(error?.code) ? 400 : 422;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function skillResultErrorStatus(result) {
|
|
905
|
+
if (result?.code === 'skill_not_found') return 404;
|
|
906
|
+
if (result?.code === 'skill_arguments_invalid') return 400;
|
|
907
|
+
return 422;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function skillInvocationErrorMessage(error) {
|
|
911
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
912
|
+
return `Skill invocation could not be compiled: ${message}`;
|
|
913
|
+
}
|
|
914
|
+
|
|
670
915
|
function workspaceFromUrl(url) {
|
|
671
916
|
const workspace = url.searchParams.get('workspace');
|
|
672
917
|
return workspace ? workspace.trim() || null : null;
|
|
@@ -748,6 +993,37 @@ function controlQueueFor(session) {
|
|
|
748
993
|
return Array.isArray(session?.controlQueue) ? session.controlQueue : [];
|
|
749
994
|
}
|
|
750
995
|
|
|
996
|
+
function skillIdempotencyKey(workspace, turnId, skillName, args) {
|
|
997
|
+
const canonicalArguments = JSON.stringify(Object.fromEntries(
|
|
998
|
+
Object.entries(args ?? {}).sort(([left], [right]) => left.localeCompare(right)),
|
|
999
|
+
));
|
|
1000
|
+
return createHash('sha256')
|
|
1001
|
+
.update(`${workspace ?? ''}\0${turnId}\0${String(skillName).toLowerCase()}\0${canonicalArguments}`)
|
|
1002
|
+
.digest('hex');
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function publicSkillRunProjection(context, skillName, chainId, selectionKind, deduplicated) {
|
|
1006
|
+
const chainItems = controlQueueFor(context?.session).filter((item) => item.chainId === chainId);
|
|
1007
|
+
const resolvedSelectionKind = selectionKind ?? chainItems.find((item) => item.selectionKind)?.selectionKind ?? null;
|
|
1008
|
+
const items = chainItems
|
|
1009
|
+
.sort((left, right) => Number(left.chainSequence ?? 0) - Number(right.chainSequence ?? 0))
|
|
1010
|
+
.map((item) => ({
|
|
1011
|
+
id: item.id,
|
|
1012
|
+
sequence: Number(item.chainSequence ?? 0),
|
|
1013
|
+
status: item.status,
|
|
1014
|
+
optional: item.optional === true,
|
|
1015
|
+
}));
|
|
1016
|
+
return {
|
|
1017
|
+
accepted: true,
|
|
1018
|
+
skill: skillName,
|
|
1019
|
+
chainId,
|
|
1020
|
+
objectiveCount: items.length,
|
|
1021
|
+
items,
|
|
1022
|
+
...(resolvedSelectionKind ? { selectionKind: resolvedSelectionKind } : {}),
|
|
1023
|
+
...(deduplicated ? { deduplicated: true } : {}),
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
|
|
751
1027
|
function cancelQueuedControlItems(session, workspace = null) {
|
|
752
1028
|
const now = new Date().toISOString();
|
|
753
1029
|
const counted = new Set();
|
|
@@ -760,6 +1036,7 @@ function cancelQueuedControlItems(session, workspace = null) {
|
|
|
760
1036
|
item.finishedAt = now;
|
|
761
1037
|
item.updatedAt = now;
|
|
762
1038
|
const id = item.id ?? item.runId ?? `${item.input}:${item.createdAt}`;
|
|
1039
|
+
privateControlInputsFor(session).delete(item.id);
|
|
763
1040
|
if (!counted.has(id)) {
|
|
764
1041
|
counted.add(id);
|
|
765
1042
|
if (session) {
|
|
@@ -875,18 +1152,42 @@ async function handleControlMessage(context, store, input, { intent = null, star
|
|
|
875
1152
|
: controlMessage(context?.session, 'converse_while_idle'));
|
|
876
1153
|
}
|
|
877
1154
|
|
|
878
|
-
|
|
1155
|
+
/*
|
|
1156
|
+
`skillStack` accompagne l'élément, il ne vit pas sur la session.
|
|
1157
|
+
|
|
1158
|
+
La pile des compétences en cours était posée sur la session pour la durée d'UN
|
|
1159
|
+
run, et restaurée par son `finally`. Or une compétence imbriquée n'est pas
|
|
1160
|
+
exécutée en ligne : elle est MISE EN FILE, et son run démarre après que le
|
|
1161
|
+
parent a fini de se nettoyer. La pile qu'elle lisait était donc déjà vide.
|
|
1162
|
+
|
|
1163
|
+
Conséquence : la garde n'attrapait que le cas pour lequel elle avait été
|
|
1164
|
+
écrite — une compétence qui se relance dans son propre run — et laissait
|
|
1165
|
+
passer A→B→A, qui boucle jusqu'à épuisement du budget en headless.
|
|
1166
|
+
|
|
1167
|
+
Une file est un passage de témoin : ce qui doit survivre au parent voyage avec
|
|
1168
|
+
le message, pas dans l'état de celui qui l'a posté.
|
|
1169
|
+
*/
|
|
1170
|
+
function enqueueControlRequest(context, input, { publicInput = null, capabilityPlan, chainId, chainSequence, skillName, skillExecution, skillStack, selectionKind, optional = false, continueOnFailure = false } = {}) {
|
|
879
1171
|
const now = new Date().toISOString();
|
|
880
1172
|
const item = {
|
|
881
1173
|
id: `control-${randomUUID()}`,
|
|
882
1174
|
workspace: context?.workspace ?? context?.session?.workspace ?? null,
|
|
883
1175
|
type: 'run_request',
|
|
884
|
-
input,
|
|
1176
|
+
input: publicInput ?? input,
|
|
885
1177
|
status: 'queued',
|
|
886
1178
|
createdAt: now,
|
|
887
1179
|
updatedAt: now,
|
|
888
1180
|
...(capabilityPlan !== undefined ? { capabilityPlan } : {}),
|
|
1181
|
+
...(chainId ? { chainId } : {}),
|
|
1182
|
+
...(Number.isInteger(chainSequence) ? { chainSequence } : {}),
|
|
1183
|
+
...(skillName ? { skillName } : {}),
|
|
1184
|
+
...(skillExecution ? { skillExecution } : {}),
|
|
1185
|
+
...(Array.isArray(skillStack) && skillStack.length ? { skillStack: [...skillStack] } : {}),
|
|
1186
|
+
...(selectionKind ? { selectionKind } : {}),
|
|
1187
|
+
optional: optional === true,
|
|
1188
|
+
continueOnFailure: continueOnFailure === true,
|
|
889
1189
|
};
|
|
1190
|
+
privateControlInputsFor(context?.session).set(item.id, input);
|
|
890
1191
|
dispatchAgentEvent(context.session, createAgentEvent('control_enqueued', {
|
|
891
1192
|
origin: 'runtime',
|
|
892
1193
|
workspace: item.workspace,
|
|
@@ -895,6 +1196,23 @@ function enqueueControlRequest(context, input, { capabilityPlan } = {}) {
|
|
|
895
1196
|
return item;
|
|
896
1197
|
}
|
|
897
1198
|
|
|
1199
|
+
function emitControlSkipped(context, item, reason) {
|
|
1200
|
+
dispatchAgentEvent(context.session, createAgentEvent('control_skipped', {
|
|
1201
|
+
origin: 'runtime',
|
|
1202
|
+
workspace: item.workspace ?? context.workspace ?? null,
|
|
1203
|
+
payload: { id: item.id, reason, finishedAt: new Date().toISOString() },
|
|
1204
|
+
}));
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function emitControlCancelled(context, item, reason) {
|
|
1208
|
+
privateControlInputsFor(context?.session).delete(item.id);
|
|
1209
|
+
dispatchAgentEvent(context.session, createAgentEvent('control_cancelled', {
|
|
1210
|
+
origin: 'runtime',
|
|
1211
|
+
workspace: item.workspace ?? context.workspace ?? null,
|
|
1212
|
+
payload: { id: item.id, reason, finishedAt: new Date().toISOString() },
|
|
1213
|
+
}));
|
|
1214
|
+
}
|
|
1215
|
+
|
|
898
1216
|
function storeControlProposal(context, input, classification, status) {
|
|
899
1217
|
const now = new Date().toISOString();
|
|
900
1218
|
const patch = buildPlanPatchFromInput(input, status);
|