@dotdrelle/wiki-manager 0.15.98 → 0.15.99
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/package.json +2 -2
- package/src/agent/graph.js +7 -4
- package/src/contracts/schemas.js +8 -17
- package/src/contracts/schemas.test.js +15 -0
- package/src/core/agentEvents.js +45 -18
- package/src/core/agentEvents.test.js +40 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +1 -1
- package/src/core/runtimeEventAdapter.js +99 -1
- package/src/core/runtimeEventAdapter.test.js +92 -2
- package/src/core/skillCompiler.test.js +1 -1
- package/src/core/toolLoop.js +7 -1
- package/src/core/toolLoop.test.js +28 -0
- package/src/orchestrator/dispatcher.js +19 -0
- package/src/orchestrator/knowledgeSignals.js +260 -0
- package/src/orchestrator/knowledgeSignals.test.js +193 -0
- package/src/orchestrator/proactiveReviewScheduler.js +240 -0
- package/src/orchestrator/proactiveReviewScheduler.test.js +243 -0
- package/src/orchestrator/providers/deepAgentsProvider.js +134 -29
- package/src/orchestrator/providers/deepAgentsProvider.test.js +138 -3
- package/src/orchestrator/resultAggregator.js +115 -1
- package/src/orchestrator/resultAggregator.test.js +138 -0
- package/src/runtime/server.js +239 -1
- package/src/runtime/store.js +1 -1
- package/src/runtime/store.test.js +5 -1
- package/wiki-workspace +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotdrelle/wiki-manager",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.99",
|
|
4
4
|
"description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
21
|
"start": "bun ./bin/wiki-manager.js",
|
|
22
|
-
"test": "node --test src/core/skillInvocation.test.js src/core/skillCompiler.test.js src/runtime/skillRun.test.js src/runtime/controlDrain.test.js src/runtime/controlCancellation.test.js src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/agent/skillRecursion.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/profileServiceStatus.test.js src/core/workspaceProfile.test.js src/core/buildInfo.test.js src/core/testGate.test.js src/core/agentEvents.test.js src/core/skillChainView.test.js src/core/runtimeLog.test.js src/core/runtimeEventAdapter.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/mcpEndpoints.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/otherWorkspacesRunning.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikiWorkspaceStart.test.js src/core/wikirc.test.js src/core/workspaceInherit.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/currentArtifact.test.js src/core/googleGrants.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/taskStatuses.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/orchestrator/providers/fakeRuntimeProvider.test.js src/orchestrator/providers/runtimeProviders.test.js src/orchestrator/providers/dispatcherExternalRuntime.test.js src/orchestrator/providers/deepAgentsProvider.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardModality.test.js src/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/shell/setupWizardDiscovery.test.js src/shell/wrapText.test.js src/runtime/lifecycle.test.js src/runtime/store.test.js src/runtime/workspaceIsolation.test.js src/runtime/controlMessages.test.js src/runtime/controlClassify.test.js src/runtime/deltaCoalescer.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/delegation.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/skillChain.e2e.test.js src/runtime/donna-contract.test.js src/runtime/approvals.test.js src/runtime/auth.test.js src/runtime/totp.test.js src/runtime/loginSession.test.js src/runtime/loginRoutes.test.js",
|
|
22
|
+
"test": "node --test src/core/skillInvocation.test.js src/core/skillCompiler.test.js src/runtime/skillRun.test.js src/runtime/controlDrain.test.js src/runtime/controlCancellation.test.js src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/agent/skillRecursion.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/profileServiceStatus.test.js src/core/workspaceProfile.test.js src/core/buildInfo.test.js src/core/testGate.test.js src/core/agentEvents.test.js src/core/skillChainView.test.js src/core/runtimeLog.test.js src/core/runtimeEventAdapter.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/mcpEndpoints.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/otherWorkspacesRunning.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikiWorkspaceStart.test.js src/core/wikirc.test.js src/core/workspaceInherit.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/currentArtifact.test.js src/core/googleGrants.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/taskStatuses.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/proactiveReviewScheduler.test.js src/orchestrator/knowledgeSignals.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/orchestrator/providers/fakeRuntimeProvider.test.js src/orchestrator/providers/runtimeProviders.test.js src/orchestrator/providers/dispatcherExternalRuntime.test.js src/orchestrator/providers/deepAgentsProvider.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardModality.test.js src/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/shell/setupWizardDiscovery.test.js src/shell/wrapText.test.js src/runtime/lifecycle.test.js src/runtime/store.test.js src/runtime/workspaceIsolation.test.js src/runtime/controlMessages.test.js src/runtime/controlClassify.test.js src/runtime/deltaCoalescer.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/delegation.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/skillChain.e2e.test.js src/runtime/donna-contract.test.js src/runtime/approvals.test.js src/runtime/auth.test.js src/runtime/totp.test.js src/runtime/loginSession.test.js src/runtime/loginRoutes.test.js",
|
|
23
23
|
"check-versions": "node scripts/check-versions.js",
|
|
24
24
|
"prepack": "node scripts/check-versions.js",
|
|
25
25
|
"prepublishOnly": "node scripts/check-versions.js",
|
package/src/agent/graph.js
CHANGED
|
@@ -1905,7 +1905,11 @@ export function createAgentGraph(options = {}) {
|
|
|
1905
1905
|
let terminalFailure = null;
|
|
1906
1906
|
let skillLaunch = null;
|
|
1907
1907
|
|
|
1908
|
-
|
|
1908
|
+
// The index is kept so a break can name the calls that never ran: every
|
|
1909
|
+
// iteration pushes a tool result before it continues or completes, so
|
|
1910
|
+
// everything AFTER the break is exactly the unexecuted set.
|
|
1911
|
+
let stoppedAt = toolCalls.length;
|
|
1912
|
+
for (const [callIndex, call] of toolCalls.entries()) {
|
|
1909
1913
|
const resolved = resolveToolCallName(state.session.mcp, call.function.name, INTERNAL_TOOL_SERVERS);
|
|
1910
1914
|
const { server, tool } = resolved;
|
|
1911
1915
|
const argsSummary = summarizeToolArguments(call.function.arguments);
|
|
@@ -2193,7 +2197,7 @@ export function createAgentGraph(options = {}) {
|
|
|
2193
2197
|
tool_call_id: call.id,
|
|
2194
2198
|
content: boundedResult,
|
|
2195
2199
|
});
|
|
2196
|
-
if (terminalFailure || skillLaunch) break;
|
|
2200
|
+
if (terminalFailure || skillLaunch) { stoppedAt = callIndex + 1; break; }
|
|
2197
2201
|
}
|
|
2198
2202
|
|
|
2199
2203
|
// A skill launch owns execution and ends the turn, so the rest of the
|
|
@@ -2203,8 +2207,7 @@ export function createAgentGraph(options = {}) {
|
|
|
2203
2207
|
// that sees tool_calls without matching results on a replayed history
|
|
2204
2208
|
// rejects the conversation.
|
|
2205
2209
|
if (skillLaunch) {
|
|
2206
|
-
const
|
|
2207
|
-
const dropped = toolCalls.filter((call) => !executed.has(call.id));
|
|
2210
|
+
const dropped = toolCalls.slice(stoppedAt);
|
|
2208
2211
|
const notRun = `Not executed: ${skillLaunch.publicInput} was launched earlier in this `
|
|
2209
2212
|
+ 'turn and owns execution from here. Do not start a second run for the same objective.';
|
|
2210
2213
|
for (const call of dropped) {
|
package/src/contracts/schemas.js
CHANGED
|
@@ -369,23 +369,14 @@ const runtimeEventSchema = {
|
|
|
369
369
|
required: ['type'],
|
|
370
370
|
additionalProperties: true,
|
|
371
371
|
properties: {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
'subagent_started',
|
|
381
|
-
'subagent_finished',
|
|
382
|
-
'message',
|
|
383
|
-
'approval_required',
|
|
384
|
-
'run_completed',
|
|
385
|
-
'run_failed',
|
|
386
|
-
'run_cancelled',
|
|
387
|
-
],
|
|
388
|
-
},
|
|
372
|
+
// Deliberately OPEN: a gateway newer than the manager emits event types
|
|
373
|
+
// this version has never heard of, and a closed enum here threw inside
|
|
374
|
+
// `normalizeRuntimeEvent` before `runtimeEventAdapter` could decide what to
|
|
375
|
+
// do with them — the provider's catch swallowed the frame, so the whole
|
|
376
|
+
// activity contract was invisible in production while its unit tests
|
|
377
|
+
// passed. The adapter owns the vocabulary and journals an unknown type;
|
|
378
|
+
// the schema must let it see one.
|
|
379
|
+
type: { type: 'string' },
|
|
389
380
|
runId: { type: 'string' },
|
|
390
381
|
tool: { type: 'string' },
|
|
391
382
|
durationMs: { type: 'number' },
|
|
@@ -250,3 +250,18 @@ test('capability status contract carries dynamic pending inputs without prescrib
|
|
|
250
250
|
assert.equal(validateContract('capabilityStatus', status).ok, true);
|
|
251
251
|
assert.equal(validateContract('capabilityStatus', { ...status, pendingInputs: [{ type: 'file' }] }).ok, false);
|
|
252
252
|
});
|
|
253
|
+
|
|
254
|
+
test('the runtime event contract tolerates a type this version does not know', () => {
|
|
255
|
+
// A newer gateway emits types this manager has never heard of. A closed enum
|
|
256
|
+
// made `normalizeRuntimeEvent` throw before the adapter could journal it, so
|
|
257
|
+
// the whole activity contract was silently invisible. The schema must let an
|
|
258
|
+
// unknown type through; deciding what to do with it belongs to the adapter.
|
|
259
|
+
for (const type of ['phase_started', 'progress', 'heartbeat', 'finding', 'degraded', 'notice', 'stream_epoch', 'a_future_type']) {
|
|
260
|
+
assert.equal(
|
|
261
|
+
validateContract('runtimeEvent', { type, runId: 'r1' }).ok,
|
|
262
|
+
true,
|
|
263
|
+
`${type} must be accepted`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
assert.equal(validateContract('runtimeEvent', { runId: 'r1' }).ok, false, 'a missing type is still refused');
|
|
267
|
+
});
|
package/src/core/agentEvents.js
CHANGED
|
@@ -231,6 +231,10 @@ function createProjectionState() {
|
|
|
231
231
|
agents: {},
|
|
232
232
|
summary: null,
|
|
233
233
|
status: 'idle',
|
|
234
|
+
// Liveness from the external runtime's heartbeat (lot 2). Display-only:
|
|
235
|
+
// never persisted, never in the conversation.
|
|
236
|
+
lastHeartbeatAt: null,
|
|
237
|
+
lastHeartbeatElapsedMs: 0,
|
|
234
238
|
};
|
|
235
239
|
}
|
|
236
240
|
|
|
@@ -263,6 +267,8 @@ function publicProjection(state) {
|
|
|
263
267
|
.sort((a, b) => a.agentInstanceId.localeCompare(b.agentInstanceId)),
|
|
264
268
|
summary: state.summary,
|
|
265
269
|
status: state.status,
|
|
270
|
+
lastHeartbeatAt: state.lastHeartbeatAt ?? null,
|
|
271
|
+
lastHeartbeatElapsedMs: state.lastHeartbeatElapsedMs ?? 0,
|
|
266
272
|
};
|
|
267
273
|
return {
|
|
268
274
|
...projection,
|
|
@@ -292,6 +298,21 @@ export function applyAgentProjectionToSession(session, projection) {
|
|
|
292
298
|
} : session.productionActivity ?? null;
|
|
293
299
|
}
|
|
294
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Clears the `pending_approval` latch once no approval is outstanding.
|
|
303
|
+
*
|
|
304
|
+
* Called after EVERY approval decision, granted or rejected. Only `granted`
|
|
305
|
+
* used to clear it, so a refusal left the projection reporting
|
|
306
|
+
* `pending_approval` for the rest of the run: both UIs kept asking for a
|
|
307
|
+
* decision already made, while `explainControlState` found nothing pending and
|
|
308
|
+
* answered "run is active". One function so the two verdicts cannot drift.
|
|
309
|
+
*/
|
|
310
|
+
function releaseApprovalLatch(state) {
|
|
311
|
+
if (state.status !== 'pending_approval') return;
|
|
312
|
+
if ((state.approvals ?? []).some((approval) => approval.status === 'pending_approval')) return;
|
|
313
|
+
state.status = 'running';
|
|
314
|
+
}
|
|
315
|
+
|
|
295
316
|
function hasRunningPlanStep(state) {
|
|
296
317
|
return (Array.isArray(state.plan) ? state.plan : [])
|
|
297
318
|
.some((step) => isActive(step?.status));
|
|
@@ -318,8 +339,17 @@ function applyEvent(state, event) {
|
|
|
318
339
|
state.planPatches = [];
|
|
319
340
|
state.summary = null;
|
|
320
341
|
state.subagents = [];
|
|
342
|
+
state.lastHeartbeatAt = null;
|
|
343
|
+
state.lastHeartbeatElapsedMs = 0;
|
|
321
344
|
pruneTerminalControlItems(state.controlQueue);
|
|
322
345
|
return;
|
|
346
|
+
case 'runtime_heartbeat':
|
|
347
|
+
// Liveness only: the external runtime saying "still working" during a
|
|
348
|
+
// long, tool-less phase. A timestamp the run strip reads; never a
|
|
349
|
+
// conversation entry, never persisted (store.js NON_PERSISTED_EVENT_TYPES).
|
|
350
|
+
state.lastHeartbeatAt = event.ts ?? new Date().toISOString();
|
|
351
|
+
state.lastHeartbeatElapsedMs = Number(event.payload?.elapsedMs) || 0;
|
|
352
|
+
return;
|
|
323
353
|
case 'user_message':
|
|
324
354
|
state.conversation.push({ role: 'user', content: String(event.payload?.content ?? '') });
|
|
325
355
|
return;
|
|
@@ -450,6 +480,15 @@ function applyEvent(state, event) {
|
|
|
450
480
|
case 'task.failed':
|
|
451
481
|
appendLog(state, taskLogLine(state, event, 'failed'));
|
|
452
482
|
return;
|
|
483
|
+
// Stable business facts: a workspace's knowledge changed. They are
|
|
484
|
+
// published for the proactive scheduler and shown as one journal line.
|
|
485
|
+
case 'knowledge.ingested':
|
|
486
|
+
case 'knowledge.rebuilt': {
|
|
487
|
+
const workspace = String(event.payload?.workspace ?? 'workspace');
|
|
488
|
+
const version = String(event.payload?.sourceVersion ?? 'unknown version');
|
|
489
|
+
appendLog(state, `${logTime(event.ts)} ${event.type} — ${workspace} (${version})`.trim());
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
453
492
|
case 'plan.revision_changed':
|
|
454
493
|
if (Array.isArray(event.payload?.tasks)) {
|
|
455
494
|
state.plan = normalizePlan(event.payload.tasks, { owner: 'orchestrator', planRevision: state.planRevision });
|
|
@@ -629,13 +668,7 @@ function applyEvent(state, event) {
|
|
|
629
668
|
};
|
|
630
669
|
upsertApproval(state, grant);
|
|
631
670
|
markCoveredApprovalsApproved(state.approvals, grant, event.ts);
|
|
632
|
-
|
|
633
|
-
// approval is still outstanding (a run-scoped grant clears its covered
|
|
634
|
-
// ones, markCoveredApprovalsApproved above).
|
|
635
|
-
if (state.status === 'pending_approval'
|
|
636
|
-
&& !(state.approvals ?? []).some((approval) => approval.status === 'pending_approval')) {
|
|
637
|
-
state.status = 'running';
|
|
638
|
-
}
|
|
671
|
+
releaseApprovalLatch(state);
|
|
639
672
|
return;
|
|
640
673
|
}
|
|
641
674
|
case 'approval.rejected':
|
|
@@ -654,17 +687,7 @@ function applyEvent(state, event) {
|
|
|
654
687
|
reason: event.payload?.reason ?? null,
|
|
655
688
|
rejectedAt: event.ts,
|
|
656
689
|
});
|
|
657
|
-
|
|
658
|
-
// `granted` cleared the status, so a rejected approval left the
|
|
659
|
-
// projection reporting `pending_approval` for the rest of the run —
|
|
660
|
-
// both UIs kept asking for a decision the user had already made, while
|
|
661
|
-
// `explainControlState` found no pending approval and answered "run is
|
|
662
|
-
// active". The two surfaces disagreed until some later run_done
|
|
663
|
-
// overwrote it.
|
|
664
|
-
if (state.status === 'pending_approval'
|
|
665
|
-
&& !(state.approvals ?? []).some((approval) => approval.status === 'pending_approval')) {
|
|
666
|
-
state.status = 'running';
|
|
667
|
-
}
|
|
690
|
+
releaseApprovalLatch(state);
|
|
668
691
|
return;
|
|
669
692
|
case 'run_done':
|
|
670
693
|
state.status = 'done';
|
|
@@ -731,6 +754,10 @@ function applyEvent(state, event) {
|
|
|
731
754
|
: {}),
|
|
732
755
|
...(event.payload?.selectionKind ? { selectionKind: event.payload.selectionKind } : {}),
|
|
733
756
|
...(Number.isInteger(event.payload?.chainSequence) ? { chainSequence: event.payload.chainSequence } : {}),
|
|
757
|
+
// A proactive review's identity must survive projection and replay: the
|
|
758
|
+
// drain hands it back to the run it starts, which is what lets the
|
|
759
|
+
// result be filed as a review rather than lost as an anonymous audit.
|
|
760
|
+
...(event.payload?.proactiveReview ? { proactiveReview: event.payload.proactiveReview } : {}),
|
|
734
761
|
optional: event.payload?.optional === true,
|
|
735
762
|
continueOnFailure: event.payload?.continueOnFailure === true,
|
|
736
763
|
});
|
|
@@ -899,3 +899,43 @@ test('subagent_started/finished track the collective timeline, reset per run', (
|
|
|
899
899
|
dispatchAgentEvent(session, createAgentEvent('run_started', { origin: 'runtime', runId: 'r2', payload: {} }));
|
|
900
900
|
assert.equal(session.agentProjection.subagents.length, 0, 'a new run starts a fresh timeline');
|
|
901
901
|
});
|
|
902
|
+
|
|
903
|
+
test('a runtime heartbeat sets liveness only, and a new run clears it', () => {
|
|
904
|
+
const session = {};
|
|
905
|
+
dispatchAgentEvent(session, createAgentEvent('run_started', { origin: 'runtime', runId: 'r1', payload: {} }));
|
|
906
|
+
dispatchAgentEvent(session, createAgentEvent('runtime_heartbeat', {
|
|
907
|
+
origin: 'runtime_provider', runId: 'r1', payload: { elapsedMs: 30_000 },
|
|
908
|
+
}));
|
|
909
|
+
|
|
910
|
+
assert.ok(session.agentProjection.lastHeartbeatAt, 'the beat is visible to the strip');
|
|
911
|
+
assert.equal(session.agentProjection.lastHeartbeatElapsedMs, 30_000);
|
|
912
|
+
// A heartbeat is not an event the conversation projection can seed from.
|
|
913
|
+
assert.equal(session.agentProjection.conversation.length, 0);
|
|
914
|
+
|
|
915
|
+
dispatchAgentEvent(session, createAgentEvent('run_started', { origin: 'runtime', runId: 'r2', payload: {} }));
|
|
916
|
+
assert.equal(session.agentProjection.lastHeartbeatAt, null, 'a new run starts with no stale beat');
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
test('a queued control item keeps its proactive-review marker across projection', () => {
|
|
920
|
+
const session = {};
|
|
921
|
+
const marker = { id: 'review-1', workspace: 'docs', trigger: 'knowledge.ingested', sourceVersion: 'v1' };
|
|
922
|
+
dispatchAgentEvent(session, createAgentEvent('control_enqueued', {
|
|
923
|
+
origin: 'runtime',
|
|
924
|
+
workspace: 'docs',
|
|
925
|
+
payload: { id: 'control-1', workspace: 'docs', input: 'audit the workspace', proactiveReview: marker },
|
|
926
|
+
}));
|
|
927
|
+
|
|
928
|
+
assert.deepEqual(session.agentProjection.controlQueue[0].proactiveReview, marker);
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
test('streamed deltas are replaced by the final message, never duplicated', () => {
|
|
932
|
+
const session = {};
|
|
933
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_delta', { origin: 'runtime', payload: { delta: 'Hello ' } }));
|
|
934
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_delta', { origin: 'runtime', payload: { delta: 'world' } }));
|
|
935
|
+
assert.equal(session.agentProjection.conversation.at(-1).content, 'Hello world');
|
|
936
|
+
|
|
937
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_message', { origin: 'runtime', payload: { content: 'Hello world' } }));
|
|
938
|
+
assert.equal(session.agentProjection.conversation.length, 1, 'the final message replaces the streamed one');
|
|
939
|
+
assert.equal(session.agentProjection.conversation[0].content, 'Hello world');
|
|
940
|
+
assert.ok(!session.agentProjection.conversation[0].streaming);
|
|
941
|
+
});
|
package/src/core/buildInfo.json
CHANGED
package/src/core/mcp.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
|
|
3
3
|
|
|
4
|
-
const WIKI_MANAGER_VERSION = '0.15.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.15.99';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
|
@@ -23,6 +23,19 @@ export function mapRuntimeEvent(event) {
|
|
|
23
23
|
const content = String(event?.content ?? event?.message ?? '').trim();
|
|
24
24
|
return content ? [{ type: 'assistant_message', payload: { content } }] : [];
|
|
25
25
|
}
|
|
26
|
+
// Progressive final stream (lot 7). The gateway only streams the MAIN
|
|
27
|
+
// assembly, never a role, so a delta can only belong to the answer. The
|
|
28
|
+
// reducer replaces the streamed text with the final `assistant_message`
|
|
29
|
+
// (finalizeAssistantMessage), so streaming cannot duplicate it.
|
|
30
|
+
case 'assistant_delta': {
|
|
31
|
+
const delta = String(event?.delta ?? '');
|
|
32
|
+
return delta ? [{ type: 'assistant_delta', payload: { delta } }] : [];
|
|
33
|
+
}
|
|
34
|
+
// A tool call interrupted the streamed answer: the text so far was
|
|
35
|
+
// reasoning, not the answer — discard it (the reducer empties, never pops,
|
|
36
|
+
// the streaming entry).
|
|
37
|
+
case 'assistant_delta_reset':
|
|
38
|
+
return [{ type: 'assistant_delta_reset', payload: {} }];
|
|
26
39
|
case 'tool_started':
|
|
27
40
|
return log(`tool ${toolLabel(event)} started`);
|
|
28
41
|
case 'tool_finished': {
|
|
@@ -60,17 +73,102 @@ export function mapRuntimeEvent(event) {
|
|
|
60
73
|
},
|
|
61
74
|
}];
|
|
62
75
|
}
|
|
76
|
+
// ── Activity (lot 2) ────────────────────────────────────────────────────
|
|
77
|
+
//
|
|
78
|
+
// The runtime's phases enrich the EXISTING business activity line; they do
|
|
79
|
+
// not open a second axis of "phases" beside `projectWorkflow`. That is why
|
|
80
|
+
// they travel as runtime_log here and are aggregated downstream, rather
|
|
81
|
+
// than minting a new event type the reducer would have to reconcile.
|
|
82
|
+
case 'phase_started':
|
|
83
|
+
return log(`phase ${phaseLabel(event)} started`);
|
|
84
|
+
case 'phase_finished': {
|
|
85
|
+
const counters = phaseCounters(event);
|
|
86
|
+
const outcome = event?.ok === false ? 'interrupted' : 'done';
|
|
87
|
+
return log(`phase ${phaseLabel(event)} ${outcome}${counters}`);
|
|
88
|
+
}
|
|
89
|
+
case 'progress': {
|
|
90
|
+
const label = String(event?.label ?? event?.phase ?? '').trim();
|
|
91
|
+
return label ? log(`progress ${label}${phaseCounters(event)}`) : [];
|
|
92
|
+
}
|
|
93
|
+
// A heartbeat is liveness, not history: it proves the run is alive to
|
|
94
|
+
// whoever is watching right now. It travels as its own NON-persisted event
|
|
95
|
+
// so the run strip can read it, but it never reaches the journal — one
|
|
96
|
+
// line per beat would bury what actually happened. Persisting is the
|
|
97
|
+
// store's decision (NON_PERSISTED_EVENT_TYPES); dropping it here would
|
|
98
|
+
// leave the strip with no liveness signal at all.
|
|
99
|
+
case 'heartbeat':
|
|
100
|
+
return [{
|
|
101
|
+
type: 'runtime_heartbeat',
|
|
102
|
+
payload: { elapsedMs: Number(event?.elapsedMs) || 0 },
|
|
103
|
+
}];
|
|
104
|
+
case 'finding': {
|
|
105
|
+
const severity = String(event?.severity ?? '').trim();
|
|
106
|
+
const summary = String(event?.summary ?? '').trim();
|
|
107
|
+
if (!summary) return [];
|
|
108
|
+
const path = String(event?.path ?? '').trim();
|
|
109
|
+
const where = path ? ` at ${path}` : '';
|
|
110
|
+
return log(`finding${severity ? ` [${severity}]` : ''} from ${String(event?.role ?? 'runtime')}${where}: ${summary}`);
|
|
111
|
+
}
|
|
112
|
+
// Maintenance the gateway performed on its own memory (eviction of an
|
|
113
|
+
// inactive workspace, compaction of a thread). Not a failure — a notice,
|
|
114
|
+
// so a reader can tell "the agent forgot an old workspace" from "the
|
|
115
|
+
// agent broke".
|
|
116
|
+
case 'notice': {
|
|
117
|
+
const topic = String(event?.topic ?? 'notice').trim();
|
|
118
|
+
const detail = String(event?.detail ?? '').trim();
|
|
119
|
+
return log(`notice ${topic}${detail ? `: ${detail}` : ''}`);
|
|
120
|
+
}
|
|
121
|
+
// A degradation must announce itself — that is the whole contract. It is
|
|
122
|
+
// never filtered, whatever else this adapter decides to keep quiet.
|
|
123
|
+
case 'degraded': {
|
|
124
|
+
const capability = String(event?.capability ?? 'capability').trim();
|
|
125
|
+
const cause = String(event?.cause ?? 'unknown cause').trim();
|
|
126
|
+
const fallback = String(event?.fallback ?? '').trim();
|
|
127
|
+
return log(`degraded ${capability}: ${cause}${fallback ? ` — ${fallback}` : ''}`);
|
|
128
|
+
}
|
|
63
129
|
case 'run_started':
|
|
64
130
|
case 'run_created':
|
|
65
131
|
case 'agent_thinking':
|
|
66
132
|
case 'run_completed':
|
|
67
133
|
case 'run_failed':
|
|
68
134
|
case 'run_cancelled':
|
|
69
|
-
|
|
135
|
+
// Deliberately silent, and listed BY NAME so the silence is a decision
|
|
136
|
+
// rather than a default: `agent_thinking` is private reasoning the chat
|
|
137
|
+
// never shows, and the terminal events are already carried by the
|
|
138
|
+
// dispatcher's own `status()` poll.
|
|
70
139
|
return [];
|
|
140
|
+
default:
|
|
141
|
+
// Everything else is a type this manager does not know — most likely a
|
|
142
|
+
// newer gateway talking to an older manager. Dropping it made that
|
|
143
|
+
// version skew invisible: the events simply never arrived, and nothing
|
|
144
|
+
// said so. One bounded line is the cost of knowing.
|
|
145
|
+
return log(`unrecognized runtime event "${type || 'unnamed'}"${unknownDetail(event)}`);
|
|
71
146
|
}
|
|
72
147
|
}
|
|
73
148
|
|
|
149
|
+
function phaseLabel(event) {
|
|
150
|
+
return String(event?.phase ?? event?.label ?? 'unnamed');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function phaseCounters(event) {
|
|
154
|
+
const parts = [];
|
|
155
|
+
const tools = Number(event?.tools);
|
|
156
|
+
const pages = Number(event?.pages);
|
|
157
|
+
if (Number.isFinite(tools) && tools > 0) parts.push(`${tools} tool(s)`);
|
|
158
|
+
if (Number.isFinite(pages) && pages > 0) parts.push(`${pages} page(s) read`);
|
|
159
|
+
return parts.length > 0 ? ` — ${parts.join(', ')}` : '';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Bounded on purpose: this is a diagnostic breadcrumb for a version skew, not
|
|
163
|
+
// a channel for an unknown payload to reach the journal whole.
|
|
164
|
+
const UNKNOWN_EVENT_DETAIL_MAX = 200;
|
|
165
|
+
function unknownDetail(event) {
|
|
166
|
+
const keys = Object.keys(event ?? {})
|
|
167
|
+
.filter((key) => !['type', 'runId', 'ts', 'sequence'].includes(key));
|
|
168
|
+
if (keys.length === 0) return '';
|
|
169
|
+
return ` (fields: ${keys.join(', ')})`.slice(0, UNKNOWN_EVENT_DETAIL_MAX);
|
|
170
|
+
}
|
|
171
|
+
|
|
74
172
|
function log(message) {
|
|
75
173
|
return [{ type: 'runtime_log', payload: { message } }];
|
|
76
174
|
}
|
|
@@ -61,6 +61,96 @@ test('private reasoning and terminal events are never re-emitted', () => {
|
|
|
61
61
|
assert.deepEqual(mapRuntimeEvent({ type: 'run_cancelled' }), []);
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
// (An unknown type used to produce nothing. It now produces one journal line —
|
|
65
|
+
// see "an unknown event type is journalled instead of vanishing" below. The
|
|
66
|
+
// old assertion pinned the silence that hid a version skew.)
|
|
67
|
+
|
|
68
|
+
// ── Activity events (lot 2) ──────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
test('phase and progress events enrich the journal with bounded counters', () => {
|
|
71
|
+
assert.deepEqual(
|
|
72
|
+
mapRuntimeEvent({ type: 'phase_started', phase: 'discover' }),
|
|
73
|
+
[{ type: 'runtime_log', payload: { message: 'phase discover started' } }],
|
|
74
|
+
);
|
|
75
|
+
const [finished] = mapRuntimeEvent({
|
|
76
|
+
type: 'phase_finished', phase: 'discover', ok: true, tools: 7, pages: 4,
|
|
77
|
+
});
|
|
78
|
+
assert.match(finished.payload.message, /phase discover done — 7 tool\(s\), 4 page\(s\) read/);
|
|
79
|
+
|
|
80
|
+
const [interrupted] = mapRuntimeEvent({ type: 'phase_finished', phase: 'critique', ok: false });
|
|
81
|
+
assert.match(interrupted.payload.message, /phase critique interrupted/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// A beat proves the run is alive to whoever watches NOW. It still travels — as
|
|
85
|
+
// its own non-persisted event, so the run strip can read it — but one journal
|
|
86
|
+
// line per beat would bury what actually happened under "still alive".
|
|
87
|
+
test('a heartbeat becomes a non-persisted liveness event, not a journal line', () => {
|
|
88
|
+
assert.deepEqual(
|
|
89
|
+
mapRuntimeEvent({ type: 'heartbeat', elapsedMs: 30_000 }),
|
|
90
|
+
[{ type: 'runtime_heartbeat', payload: { elapsedMs: 30_000 } }],
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('a finding carries its severity, its author and its path', () => {
|
|
95
|
+
const [entry] = mapRuntimeEvent({
|
|
96
|
+
type: 'finding',
|
|
97
|
+
role: 'critique',
|
|
98
|
+
severity: 'blocking',
|
|
99
|
+
path: 'wiki/concepts/demo/a.md',
|
|
100
|
+
summary: 'cites no source',
|
|
101
|
+
});
|
|
102
|
+
assert.match(
|
|
103
|
+
entry.payload.message,
|
|
104
|
+
/finding \[blocking\] from critique at wiki\/concepts\/demo\/a\.md: cites no source/,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('a degradation is never filtered', () => {
|
|
109
|
+
const [entry] = mapRuntimeEvent({
|
|
110
|
+
type: 'degraded',
|
|
111
|
+
capability: 'role:critique',
|
|
112
|
+
cause: 'model timeout',
|
|
113
|
+
fallback: 'the run continues without this role',
|
|
114
|
+
});
|
|
115
|
+
assert.match(entry.payload.message, /degraded role:critique: model timeout — the run continues/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/*
|
|
119
|
+
The version-skew guard. A newer gateway talking to an older manager used to
|
|
120
|
+
lose EVERY new event here, silently — the adapter ended on `default: return []`.
|
|
121
|
+
The deliberate silences stay silent, but they are now listed by name, so the
|
|
122
|
+
difference between "we chose not to show this" and "we did not recognise it"
|
|
123
|
+
is visible in the journal instead of being the same thing.
|
|
124
|
+
*/
|
|
125
|
+
test('an unknown event type is journalled instead of vanishing', () => {
|
|
126
|
+
const [entry] = mapRuntimeEvent({ type: 'sub_phase_started', detail: 'x', weight: 2 });
|
|
127
|
+
assert.equal(entry.type, 'runtime_log');
|
|
128
|
+
assert.match(entry.payload.message, /unrecognized runtime event "sub_phase_started"/);
|
|
129
|
+
assert.match(entry.payload.message, /fields: detail, weight/);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('the deliberate silences stay silent', () => {
|
|
133
|
+
for (const type of ['agent_thinking', 'run_started', 'run_completed', 'run_failed', 'run_cancelled']) {
|
|
134
|
+
assert.deepEqual(mapRuntimeEvent({ type }), [], `${type} must stay silent`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('a memory notice is journalled as maintenance, not as a failure', () => {
|
|
139
|
+
const [entry] = mapRuntimeEvent({
|
|
140
|
+
type: 'notice', topic: 'memory.evicted', detail: 'old-workspace',
|
|
141
|
+
});
|
|
142
|
+
assert.equal(entry.type, 'runtime_log');
|
|
143
|
+
assert.match(entry.payload.message, /^notice memory\.evicted: old-workspace$/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('the final stream maps as deltas, and a reset clears them', () => {
|
|
147
|
+
assert.deepEqual(
|
|
148
|
+
mapRuntimeEvent({ type: 'assistant_delta', delta: 'Hi' }),
|
|
149
|
+
[{ type: 'assistant_delta', payload: { delta: 'Hi' } }],
|
|
150
|
+
);
|
|
151
|
+
assert.deepEqual(
|
|
152
|
+
mapRuntimeEvent({ type: 'assistant_delta_reset' }),
|
|
153
|
+
[{ type: 'assistant_delta_reset', payload: {} }],
|
|
154
|
+
);
|
|
155
|
+
assert.deepEqual(mapRuntimeEvent({ type: 'assistant_delta', delta: '' }), []);
|
|
66
156
|
});
|
|
@@ -31,7 +31,7 @@ test('validation rejects technical routing details', () => {
|
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
test('every shipped scaffold skill compiles to a single intention, deterministically', async () => {
|
|
34
|
-
const expected = { pipeline: 1, 'wiki-sync': 1, 'wiki-ingest': 1, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1, 'wiki-rebuild': 1 };
|
|
34
|
+
const expected = { pipeline: 1, 'wiki-sync': 1, 'wiki-ingest': 1, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1, 'wiki-rebuild': 1, curate: 1 };
|
|
35
35
|
// Passing no llmFallback used to make this test assert the one path
|
|
36
36
|
// production never takes: an ambiguous body silently returns the safe
|
|
37
37
|
// mono-intention fallback, so the count was 1 and the test was green while
|
package/src/core/toolLoop.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { truncateToolResult } from './mcp.js';
|
|
2
|
+
|
|
1
3
|
// Minimal, side-effect-free bounded tool-use loop.
|
|
2
4
|
//
|
|
3
5
|
// This is the shared mechanic of "ask the LLM with a tool set, run the tool
|
|
@@ -79,9 +81,13 @@ export async function runBoundedToolLoop({
|
|
|
79
81
|
convo.push(result.message ?? { role: 'assistant', content: result.content ?? '', tool_calls: calls });
|
|
80
82
|
// Tool calls within one turn are independent: dispatch concurrently, then
|
|
81
83
|
// replay results in the model's call order so the transcript stays stable.
|
|
84
|
+
// Bound what enters the LLM context, exactly like the /agent loop
|
|
85
|
+
// (graph.js). Without it a wide read — a CME Confluence search at limit 50
|
|
86
|
+
// can weigh ~35 kB — is re-sent on every iteration (up to the cap), and the
|
|
87
|
+
// chat answer pays for tokens the model never needed.
|
|
82
88
|
const outcomes = await Promise.all(calls.map(async (call) => ({
|
|
83
89
|
tool_call_id: call.id,
|
|
84
|
-
content: await executeCall(call),
|
|
90
|
+
content: truncateToolResult(await executeCall(call)),
|
|
85
91
|
})));
|
|
86
92
|
for (const outcome of outcomes) {
|
|
87
93
|
convo.push({ role: 'tool', tool_call_id: outcome.tool_call_id, content: outcome.content });
|
|
@@ -104,6 +104,34 @@ test('answers from the gathered results when the cap is reached', async () => {
|
|
|
104
104
|
assert.equal(out.content, "Voici ce que j'ai trouvé.");
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
+
test('bounds a wide tool result before it enters the LLM context', async () => {
|
|
108
|
+
// A CME Confluence search at limit 50 can weigh ~35 kB and would otherwise be
|
|
109
|
+
// re-sent on every iteration. The /agent loop already truncates at 16 kB
|
|
110
|
+
// (graph.js); the chat loop must not be the one unbounded path.
|
|
111
|
+
let round = 0;
|
|
112
|
+
let toolContent = '';
|
|
113
|
+
const llm = {
|
|
114
|
+
async completeWithTools({ messages }) {
|
|
115
|
+
round += 1;
|
|
116
|
+
if (round === 1) {
|
|
117
|
+
const calls = [toolCall('c1', 'cme__cme_confluence_search')];
|
|
118
|
+
return { message: { role: 'assistant', content: '', tool_calls: calls }, tool_calls: calls };
|
|
119
|
+
}
|
|
120
|
+
toolContent = messages.find((m) => m.role === 'tool')?.content ?? '';
|
|
121
|
+
return { content: 'ok', tool_calls: [] };
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
const wide = 'x'.repeat(50000);
|
|
125
|
+
await runBoundedToolLoop({
|
|
126
|
+
llm,
|
|
127
|
+
tools: [{ function: { name: 'cme__cme_confluence_search' } }],
|
|
128
|
+
executeCall: async () => wide,
|
|
129
|
+
});
|
|
130
|
+
assert.ok(toolContent.length < wide.length, 'the result must be bounded');
|
|
131
|
+
assert.ok(toolContent.length <= 16200, `bounded length was ${toolContent.length}`);
|
|
132
|
+
assert.match(toolContent, /tronqu/);
|
|
133
|
+
});
|
|
134
|
+
|
|
107
135
|
test('propagates an abort thrown by executeCall', async () => {
|
|
108
136
|
const llm = {
|
|
109
137
|
async completeWithTools() {
|
|
@@ -255,6 +255,7 @@ async function executeExternalRuntime(task, assignment, {
|
|
|
255
255
|
capability: task.requiredCapability ?? null,
|
|
256
256
|
arguments: task.arguments && typeof task.arguments === 'object' ? task.arguments : {},
|
|
257
257
|
workspace: workspaceRequest(session),
|
|
258
|
+
memoryScope: memoryScopeRequest(session),
|
|
258
259
|
model: activeProfileModel(session),
|
|
259
260
|
language: session?.language ?? session?.wikircConfig?.language ?? null,
|
|
260
261
|
mcp: mcpPool,
|
|
@@ -471,6 +472,24 @@ function executeRequest(task, session, runId, assignment) {
|
|
|
471
472
|
};
|
|
472
473
|
}
|
|
473
474
|
|
|
475
|
+
/**
|
|
476
|
+
* Which past conversation this run resumes, on the external runtime.
|
|
477
|
+
*
|
|
478
|
+
* The workspace alone today. The multi-user lot turns this into
|
|
479
|
+
* `<workspace>:<actorId>` — the shape is already the one the gateway accepts,
|
|
480
|
+
* so identity lands here and nowhere else. Returning null is legitimate and
|
|
481
|
+
* silent: the gateway then scopes to the workspace it resolved itself.
|
|
482
|
+
*
|
|
483
|
+
* Never a value a caller supplied: the runtime treats the scope as a read
|
|
484
|
+
* capability and refuses one that leaves its own workspace.
|
|
485
|
+
*/
|
|
486
|
+
function memoryScopeRequest(session) {
|
|
487
|
+
const actorId = session?._currentRunIdentity?.actorId ?? session?.actorId ?? null;
|
|
488
|
+
if (!actorId) return null;
|
|
489
|
+
const workspace = workspaceRequest(session)?.name;
|
|
490
|
+
return workspace ? `${workspace}:${String(actorId)}` : null;
|
|
491
|
+
}
|
|
492
|
+
|
|
474
493
|
function workspaceRequest(session) {
|
|
475
494
|
const workspace = session.workspace ?? session._currentRunIdentity?.workspace;
|
|
476
495
|
if (workspace && typeof workspace === 'object' && !Array.isArray(workspace)) return { ...workspace };
|