@dotdrelle/wiki-manager 0.6.47 → 0.8.2
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/.env.example +20 -0
- package/README.md +35 -1
- package/bin/wiki-manager.js +2 -2
- package/docker-compose.yml +2 -0
- package/mcp.endpoints.example.json +13 -0
- package/package.json +2 -2
- package/src/agent/graph.js +101 -15
- package/src/agent/graph.test.js +145 -0
- package/src/cli/wiki-manager.js +459 -131
- package/src/commands/slash.js +1 -1
- package/src/core/activity.js +14 -0
- package/src/core/agentEvents.js +148 -6
- package/src/core/agentEvents.test.js +145 -4
- package/src/core/agentLoop.js +167 -0
- package/src/core/agentLoop.test.js +85 -0
- package/src/core/cacert.js +4 -3
- package/src/core/dockerCompose.test.js +14 -0
- package/src/core/documentIntake.test.js +7 -7
- package/src/core/env.js +6 -0
- package/src/core/jobQueue.js +47 -16
- package/src/core/mcp.js +120 -10
- package/src/core/mcp.test.js +121 -1
- package/src/core/plan.js +33 -0
- package/src/core/queueStore.js +27 -0
- package/src/core/queueStore.test.js +32 -0
- package/src/core/wikiWorkspace.test.js +24 -0
- package/src/core/workspaces.js +8 -1
- package/src/runtime/approvals.js +113 -0
- package/src/runtime/auth.js +35 -0
- package/src/runtime/auth.test.js +29 -0
- package/src/runtime/client.js +154 -0
- package/src/runtime/lifecycle.js +84 -0
- package/src/runtime/queueStore.js +6 -0
- package/src/runtime/runner.js +413 -0
- package/src/runtime/runner.test.js +270 -0
- package/src/runtime/server.js +216 -0
- package/src/runtime/server.test.js +308 -0
- package/src/runtime/store.js +431 -0
- package/src/runtime/store.test.js +437 -0
- package/src/runtime/supervisor.js +104 -0
- package/src/runtime/supervisor.test.js +240 -0
- package/src/shell/RightPane.tsx +1 -1
- package/src/shell/repl.js +148 -18
- package/src/shell/repl.test.js +38 -0
- package/src/shell/tui.tsx +4 -1
- package/src/shell/useAgent.ts +20 -2
- package/src/shell/useSession.ts +153 -13
- package/wiki-workspace +221 -22
package/src/core/agentEvents.js
CHANGED
|
@@ -1,15 +1,41 @@
|
|
|
1
1
|
import { normalizeActivity } from './activity.js';
|
|
2
|
-
import { syncActivitiesToPlan } from './plan.js';
|
|
2
|
+
import { attachActivityToExistingPlan, syncActivitiesToPlan } from './plan.js';
|
|
3
3
|
|
|
4
4
|
const SESSION_PROJECTION_EVENTS = new Set([
|
|
5
5
|
'run_started',
|
|
6
6
|
'plan_set',
|
|
7
7
|
'plan_step_updated',
|
|
8
8
|
'activity_upserted',
|
|
9
|
+
'run_evaluated',
|
|
10
|
+
'run_replanned',
|
|
11
|
+
'run_pending_approval',
|
|
12
|
+
'run_approved',
|
|
13
|
+
'tool_pending_approval',
|
|
14
|
+
'tool_approved',
|
|
15
|
+
'run_done',
|
|
9
16
|
'run_error',
|
|
17
|
+
'run_cancelled',
|
|
18
|
+
'runtime_log',
|
|
10
19
|
]);
|
|
11
20
|
|
|
12
|
-
|
|
21
|
+
// Events that can mutate state.plan in applyEvent() — only these warrant the
|
|
22
|
+
// before/after plan comparison below (runtime_log fires far more often and
|
|
23
|
+
// never touches the plan).
|
|
24
|
+
const PLAN_MUTATING_EVENTS = new Set([
|
|
25
|
+
'run_started',
|
|
26
|
+
'plan_set',
|
|
27
|
+
'plan_step_updated',
|
|
28
|
+
'activity_upserted',
|
|
29
|
+
'run_done',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
export function createAgentEvent(type, {
|
|
33
|
+
origin = 'system',
|
|
34
|
+
payload = {},
|
|
35
|
+
runId = null,
|
|
36
|
+
turnId = null,
|
|
37
|
+
workspace = null,
|
|
38
|
+
} = {}) {
|
|
13
39
|
return {
|
|
14
40
|
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
|
|
15
41
|
ts: new Date().toISOString(),
|
|
@@ -17,13 +43,18 @@ export function createAgentEvent(type, { origin = 'system', payload = {}, runId
|
|
|
17
43
|
origin,
|
|
18
44
|
runId,
|
|
19
45
|
turnId,
|
|
46
|
+
workspace,
|
|
20
47
|
payload,
|
|
21
48
|
};
|
|
22
49
|
}
|
|
23
50
|
|
|
24
51
|
export function dispatchAgentEvent(session, event) {
|
|
25
|
-
const normalized =
|
|
26
|
-
|
|
52
|
+
const normalized = withSessionRunIdentity(
|
|
53
|
+
event.id && event.ts ? event : createAgentEvent(event.type, event),
|
|
54
|
+
session,
|
|
55
|
+
);
|
|
56
|
+
const tracksPlan = PLAN_MUTATING_EVENTS.has(normalized.type);
|
|
57
|
+
const previousPlan = tracksPlan ? JSON.stringify(session.headlessPlan ?? null) : null;
|
|
27
58
|
session.agentEvents ??= [];
|
|
28
59
|
session.agentEvents.push(normalized);
|
|
29
60
|
session._agentProjectionState ??= createProjectionState();
|
|
@@ -31,13 +62,25 @@ export function dispatchAgentEvent(session, event) {
|
|
|
31
62
|
session.agentProjection = publicProjection(session._agentProjectionState);
|
|
32
63
|
if (SESSION_PROJECTION_EVENTS.has(normalized.type)) {
|
|
33
64
|
applyAgentProjectionToSession(session, session.agentProjection);
|
|
34
|
-
if (JSON.stringify(session.headlessPlan ?? null) !== previousPlan) {
|
|
65
|
+
if (tracksPlan && JSON.stringify(session.headlessPlan ?? null) !== previousPlan) {
|
|
35
66
|
session._onPlanUpdate?.();
|
|
36
67
|
}
|
|
37
68
|
}
|
|
69
|
+
session._onAgentEvent?.(normalized, session.agentProjection);
|
|
38
70
|
return normalized;
|
|
39
71
|
}
|
|
40
72
|
|
|
73
|
+
function withSessionRunIdentity(event, session) {
|
|
74
|
+
const identity = session?._currentRunIdentity;
|
|
75
|
+
if (!identity) return event;
|
|
76
|
+
return {
|
|
77
|
+
...event,
|
|
78
|
+
runId: event.runId ?? identity.runId ?? null,
|
|
79
|
+
turnId: event.turnId ?? identity.turnId ?? null,
|
|
80
|
+
workspace: event.workspace ?? identity.workspace ?? null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
41
84
|
export function reduceAgentEvents(events = []) {
|
|
42
85
|
const state = createProjectionState();
|
|
43
86
|
|
|
@@ -55,6 +98,9 @@ function createProjectionState() {
|
|
|
55
98
|
plan: null,
|
|
56
99
|
activities: {},
|
|
57
100
|
logs: [],
|
|
101
|
+
evaluation: null,
|
|
102
|
+
replans: [],
|
|
103
|
+
approvals: [],
|
|
58
104
|
summary: null,
|
|
59
105
|
status: 'idle',
|
|
60
106
|
};
|
|
@@ -67,6 +113,9 @@ function publicProjection(state) {
|
|
|
67
113
|
plan: state.plan ? state.plan.map((step) => ({ ...step })) : null,
|
|
68
114
|
activities: sortedActivities(state.activities).map((activity) => ({ ...activity })),
|
|
69
115
|
logs: [...state.logs],
|
|
116
|
+
evaluation: state.evaluation ? { ...state.evaluation } : null,
|
|
117
|
+
replans: state.replans.map((replan) => ({ ...replan, plan: [...(replan.plan ?? [])] })),
|
|
118
|
+
approvals: state.approvals.map((approval) => ({ ...approval })),
|
|
70
119
|
summary: state.summary,
|
|
71
120
|
status: state.status,
|
|
72
121
|
};
|
|
@@ -89,10 +138,13 @@ function applyEvent(state, event) {
|
|
|
89
138
|
switch (event.type) {
|
|
90
139
|
case 'run_started':
|
|
91
140
|
state.status = 'running';
|
|
92
|
-
state.plan =
|
|
141
|
+
state.plan = defaultRunPlan();
|
|
93
142
|
state.chain = [];
|
|
94
143
|
state.activities = {};
|
|
95
144
|
state.logs = [];
|
|
145
|
+
state.evaluation = null;
|
|
146
|
+
state.replans = [];
|
|
147
|
+
state.approvals = [];
|
|
96
148
|
state.summary = null;
|
|
97
149
|
return;
|
|
98
150
|
case 'user_message':
|
|
@@ -130,13 +182,63 @@ function applyEvent(state, event) {
|
|
|
130
182
|
state.summary = String(event.payload?.content ?? '');
|
|
131
183
|
if (state.summary) state.conversation.push({ role: 'assistant', content: state.summary });
|
|
132
184
|
return;
|
|
185
|
+
case 'run_evaluated':
|
|
186
|
+
state.evaluation = {
|
|
187
|
+
ok: event.payload?.ok === true,
|
|
188
|
+
reason: String(event.payload?.reason ?? ''),
|
|
189
|
+
suggestedAction: event.payload?.suggestedAction ?? null,
|
|
190
|
+
runId: event.runId ?? event.payload?.runId ?? null,
|
|
191
|
+
};
|
|
192
|
+
return;
|
|
193
|
+
case 'run_replanned':
|
|
194
|
+
state.replans.push({
|
|
195
|
+
reason: String(event.payload?.reason ?? ''),
|
|
196
|
+
plan: Array.isArray(event.payload?.plan) ? event.payload.plan.map(String) : [],
|
|
197
|
+
replansLeft: Number(event.payload?.replansLeft ?? 0),
|
|
198
|
+
runId: event.runId ?? event.payload?.runId ?? null,
|
|
199
|
+
});
|
|
200
|
+
return;
|
|
201
|
+
case 'run_pending_approval':
|
|
202
|
+
case 'tool_pending_approval':
|
|
203
|
+
upsertApproval(state, {
|
|
204
|
+
id: event.payload?.approvalId ?? event.payload?.runId ?? event.payload?.itemId ?? event.id,
|
|
205
|
+
scope: event.type === 'run_pending_approval' ? 'run' : 'tool',
|
|
206
|
+
status: 'pending_approval',
|
|
207
|
+
runId: event.runId ?? event.payload?.runId ?? null,
|
|
208
|
+
itemId: event.payload?.itemId ?? null,
|
|
209
|
+
reason: event.payload?.reason ?? null,
|
|
210
|
+
tool: event.payload?.tool ?? null,
|
|
211
|
+
plan: event.payload?.plan ?? null,
|
|
212
|
+
createdAt: event.ts,
|
|
213
|
+
});
|
|
214
|
+
return;
|
|
215
|
+
case 'run_approved':
|
|
216
|
+
case 'tool_approved':
|
|
217
|
+
upsertApproval(state, {
|
|
218
|
+
id: event.payload?.approvalId ?? event.payload?.runId ?? event.payload?.itemId ?? event.id,
|
|
219
|
+
scope: event.type === 'run_approved' ? 'run' : 'tool',
|
|
220
|
+
status: 'approved',
|
|
221
|
+
runId: event.runId ?? event.payload?.runId ?? null,
|
|
222
|
+
itemId: event.payload?.itemId ?? null,
|
|
223
|
+
approvedAt: event.ts,
|
|
224
|
+
});
|
|
225
|
+
return;
|
|
133
226
|
case 'run_done':
|
|
134
227
|
state.status = 'done';
|
|
228
|
+
finishPendingPlanSteps(state.plan);
|
|
229
|
+
return;
|
|
230
|
+
case 'run_cancelled':
|
|
231
|
+
state.status = 'cancelled';
|
|
232
|
+
state.logs.push(String(event.payload?.message ?? 'Agent run cancelled.'));
|
|
135
233
|
return;
|
|
136
234
|
case 'run_error':
|
|
137
235
|
state.status = 'error';
|
|
138
236
|
state.logs.push(String(event.payload?.message ?? 'Agent run failed.'));
|
|
139
237
|
return;
|
|
238
|
+
case 'runtime_log':
|
|
239
|
+
state.logs.push(String(event.payload?.message ?? ''));
|
|
240
|
+
state.logs = state.logs.slice(-200);
|
|
241
|
+
return;
|
|
140
242
|
default:
|
|
141
243
|
return;
|
|
142
244
|
}
|
|
@@ -176,6 +278,26 @@ function finishToolCall(state, payload = {}) {
|
|
|
176
278
|
if (!existing) state.chain.push(step);
|
|
177
279
|
}
|
|
178
280
|
|
|
281
|
+
function upsertApproval(state, next) {
|
|
282
|
+
const index = state.approvals.findIndex((approval) => approval.id === next.id);
|
|
283
|
+
if (index === -1) {
|
|
284
|
+
state.approvals.push(next);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
state.approvals[index] = {
|
|
288
|
+
...state.approvals[index],
|
|
289
|
+
...next,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function finishPendingPlanSteps(plan) {
|
|
294
|
+
for (const step of plan ?? []) {
|
|
295
|
+
if (step.status === 'running' || step.status === 'pending') {
|
|
296
|
+
step.status = 'done';
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
179
301
|
function upsertActivity(state, rawActivity) {
|
|
180
302
|
const activity = normalizeActivity(rawActivity);
|
|
181
303
|
if (!activity) return;
|
|
@@ -189,6 +311,10 @@ function upsertActivity(state, rawActivity) {
|
|
|
189
311
|
|
|
190
312
|
function ensurePlanFromActivityProjection(state, activity) {
|
|
191
313
|
const actKey = activity.key ?? null;
|
|
314
|
+
if (state.plan?.some((step) => step.owner === 'orchestrator')) {
|
|
315
|
+
attachActivityToExistingPlan(state.plan, activity);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
192
318
|
if (state.plan && actKey !== null && state.plan[0]?._activityKey === actKey) return;
|
|
193
319
|
const steps = activity.plan?.steps;
|
|
194
320
|
if (Array.isArray(steps) && steps.length > 0) {
|
|
@@ -197,6 +323,8 @@ function ensurePlanFromActivityProjection(state, activity) {
|
|
|
197
323
|
id: step.id ?? null,
|
|
198
324
|
description: step.label,
|
|
199
325
|
status: 'pending',
|
|
326
|
+
owner: 'activity',
|
|
327
|
+
ownerActivityKey: activity.key,
|
|
200
328
|
_activityKey: activity.key,
|
|
201
329
|
}));
|
|
202
330
|
return;
|
|
@@ -206,12 +334,16 @@ function ensurePlanFromActivityProjection(state, activity) {
|
|
|
206
334
|
id: null,
|
|
207
335
|
description: activity.label,
|
|
208
336
|
status: 'pending',
|
|
337
|
+
owner: 'activity',
|
|
338
|
+
ownerActivityKey: activity.key,
|
|
209
339
|
_activityKey: activity.key,
|
|
210
340
|
}];
|
|
211
341
|
}
|
|
212
342
|
|
|
213
343
|
function normalizePlan(steps, payload = {}) {
|
|
214
344
|
if (!Array.isArray(steps)) return null;
|
|
345
|
+
const owner = payload.owner ?? 'orchestrator';
|
|
346
|
+
const ownerActivityKey = payload.ownerActivityKey ?? payload.activityKey ?? null;
|
|
215
347
|
return steps.map((raw, i) => {
|
|
216
348
|
const item = typeof raw === 'string' ? { description: raw } : (raw ?? {});
|
|
217
349
|
return {
|
|
@@ -219,11 +351,21 @@ function normalizePlan(steps, payload = {}) {
|
|
|
219
351
|
id: item.id ?? null,
|
|
220
352
|
description: String(item.description ?? item.label ?? item.name ?? `Step ${i + 1}`),
|
|
221
353
|
status: item.status ?? 'pending',
|
|
354
|
+
owner: item.owner ?? owner,
|
|
355
|
+
ownerActivityKey: item.ownerActivityKey ?? ownerActivityKey,
|
|
222
356
|
_activityKey: item._activityKey ?? payload.activityKey ?? null,
|
|
223
357
|
};
|
|
224
358
|
});
|
|
225
359
|
}
|
|
226
360
|
|
|
361
|
+
function defaultRunPlan() {
|
|
362
|
+
return [
|
|
363
|
+
{ step: 1, id: 'analyze', description: 'Analyze the request', status: 'running', owner: 'orchestrator', ownerActivityKey: null, _activityKey: null },
|
|
364
|
+
{ step: 2, id: 'execute', description: 'Execute the required actions', status: 'pending', owner: 'orchestrator', ownerActivityKey: null, _activityKey: null },
|
|
365
|
+
{ step: 3, id: 'verify', description: 'Verify the result', status: 'pending', owner: 'orchestrator', ownerActivityKey: null, _activityKey: null },
|
|
366
|
+
];
|
|
367
|
+
}
|
|
368
|
+
|
|
227
369
|
function updatePlanStep(plan, payload) {
|
|
228
370
|
if (!plan) return;
|
|
229
371
|
const step = plan.find((item) => item.step === Number(payload.step));
|
|
@@ -22,7 +22,9 @@ test('reduceAgentEvents: run_started clears stale plan', () => {
|
|
|
22
22
|
}),
|
|
23
23
|
createAgentEvent('run_started', { origin: 'user' }),
|
|
24
24
|
]);
|
|
25
|
-
assert.equal(projection.plan,
|
|
25
|
+
assert.equal(projection.plan.length, 3);
|
|
26
|
+
assert.equal(projection.plan[0].description, 'Analyze the request');
|
|
27
|
+
assert.equal(projection.plan[0].owner, 'orchestrator');
|
|
26
28
|
assert.equal(projection.activities.length, 0);
|
|
27
29
|
assert.equal(projection.status, 'running');
|
|
28
30
|
});
|
|
@@ -67,7 +69,7 @@ test('reduceAgentEvents: activity with plan creates visible plan and progress',
|
|
|
67
69
|
assert.equal(projection.plan[1].status, 'pending');
|
|
68
70
|
});
|
|
69
71
|
|
|
70
|
-
test('reduceAgentEvents:
|
|
72
|
+
test('reduceAgentEvents: activity attaches to orchestrator plan without replacing it', () => {
|
|
71
73
|
const projection = reduceAgentEvents([
|
|
72
74
|
createAgentEvent('plan_set', {
|
|
73
75
|
origin: 'tool',
|
|
@@ -87,8 +89,147 @@ test('reduceAgentEvents: real activity replaces minimal MCP plan', () => {
|
|
|
87
89
|
}),
|
|
88
90
|
]);
|
|
89
91
|
assert.equal(projection.plan.length, 1);
|
|
90
|
-
assert.equal(projection.plan[0].description, '
|
|
91
|
-
assert.equal(projection.plan[0].
|
|
92
|
+
assert.equal(projection.plan[0].description, 'cme.cme_export_run');
|
|
93
|
+
assert.equal(projection.plan[0].owner, 'orchestrator');
|
|
94
|
+
assert.equal(projection.plan[0].activityKey, 'cme:export-1');
|
|
95
|
+
assert.equal(projection.plan[0].ownerActivityKey, 'cme:export-1');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('reduceAgentEvents: run activity attaches to execution step of default plan', () => {
|
|
99
|
+
const projection = reduceAgentEvents([
|
|
100
|
+
createAgentEvent('run_started', { origin: 'runtime' }),
|
|
101
|
+
createAgentEvent('activity_upserted', {
|
|
102
|
+
origin: 'tool',
|
|
103
|
+
payload: {
|
|
104
|
+
activity: {
|
|
105
|
+
key: 'production:build-1',
|
|
106
|
+
id: 'build-1',
|
|
107
|
+
source: 'production',
|
|
108
|
+
label: 'Production build',
|
|
109
|
+
status: 'running',
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
assert.equal(projection.plan[0].status, 'done');
|
|
116
|
+
assert.equal(projection.plan[1].status, 'running');
|
|
117
|
+
assert.equal(projection.plan[1].activityKey, 'production:build-1');
|
|
118
|
+
assert.equal(projection.plan[2].status, 'pending');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('reduceAgentEvents: run_done finalizes running and pending plan steps', () => {
|
|
122
|
+
const projection = reduceAgentEvents([
|
|
123
|
+
createAgentEvent('run_started', { origin: 'runtime' }),
|
|
124
|
+
createAgentEvent('run_done', { origin: 'runtime' }),
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
assert.deepEqual(projection.plan.map((step) => step.status), ['done', 'done', 'done']);
|
|
128
|
+
assert.equal(projection.status, 'done');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('dispatchAgentEvent: run_done finalizes session plan', () => {
|
|
132
|
+
const session = {};
|
|
133
|
+
dispatchAgentEvent(session, createAgentEvent('run_started', { origin: 'runtime' }));
|
|
134
|
+
dispatchAgentEvent(session, createAgentEvent('run_done', { origin: 'runtime' }));
|
|
135
|
+
|
|
136
|
+
assert.deepEqual(session.headlessPlan.map((step) => step.status), ['done', 'done', 'done']);
|
|
137
|
+
assert.equal(session.agentProjection.status, 'done');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('reduceAgentEvents: run_evaluated exposes evaluator verdict', () => {
|
|
141
|
+
const projection = reduceAgentEvents([
|
|
142
|
+
createAgentEvent('run_started', { origin: 'runtime', runId: 'run-1' }),
|
|
143
|
+
createAgentEvent('run_evaluated', {
|
|
144
|
+
origin: 'runtime',
|
|
145
|
+
runId: 'run-1',
|
|
146
|
+
payload: {
|
|
147
|
+
ok: false,
|
|
148
|
+
reason: 'Missing export.',
|
|
149
|
+
suggestedAction: 'Run export step.',
|
|
150
|
+
},
|
|
151
|
+
}),
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
assert.deepEqual(projection.evaluation, {
|
|
155
|
+
ok: false,
|
|
156
|
+
reason: 'Missing export.',
|
|
157
|
+
suggestedAction: 'Run export step.',
|
|
158
|
+
runId: 'run-1',
|
|
159
|
+
});
|
|
160
|
+
assert.equal(projection.status, 'running');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('reduceAgentEvents: run_replanned records replan trace', () => {
|
|
164
|
+
const projection = reduceAgentEvents([
|
|
165
|
+
createAgentEvent('run_started', { origin: 'runtime', runId: 'run-1' }),
|
|
166
|
+
createAgentEvent('run_replanned', {
|
|
167
|
+
origin: 'runtime',
|
|
168
|
+
runId: 'run-1',
|
|
169
|
+
payload: {
|
|
170
|
+
reason: 'Export file missing.',
|
|
171
|
+
plan: ['Run export again'],
|
|
172
|
+
replansLeft: 1,
|
|
173
|
+
},
|
|
174
|
+
}),
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
assert.deepEqual(projection.replans, [{
|
|
178
|
+
reason: 'Export file missing.',
|
|
179
|
+
plan: ['Run export again'],
|
|
180
|
+
replansLeft: 1,
|
|
181
|
+
runId: 'run-1',
|
|
182
|
+
}]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('reduceAgentEvents: approvals move from pending to approved', () => {
|
|
186
|
+
const projection = reduceAgentEvents([
|
|
187
|
+
createAgentEvent('run_pending_approval', {
|
|
188
|
+
origin: 'runtime',
|
|
189
|
+
runId: 'run-1',
|
|
190
|
+
payload: {
|
|
191
|
+
approvalId: 'approval-1',
|
|
192
|
+
runId: 'run-1',
|
|
193
|
+
reason: 'Approve plan.',
|
|
194
|
+
plan: ['Build'],
|
|
195
|
+
},
|
|
196
|
+
}),
|
|
197
|
+
createAgentEvent('run_approved', {
|
|
198
|
+
origin: 'runtime',
|
|
199
|
+
runId: 'run-1',
|
|
200
|
+
payload: {
|
|
201
|
+
approvalId: 'approval-1',
|
|
202
|
+
runId: 'run-1',
|
|
203
|
+
},
|
|
204
|
+
}),
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
assert.equal(projection.approvals.length, 1);
|
|
208
|
+
assert.equal(projection.approvals[0].status, 'approved');
|
|
209
|
+
assert.equal(projection.approvals[0].scope, 'run');
|
|
210
|
+
assert.deepEqual(projection.approvals[0].plan, ['Build']);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('reduceAgentEvents: activity-owned plan is used when no orchestrator plan exists', () => {
|
|
214
|
+
const projection = reduceAgentEvents([
|
|
215
|
+
createAgentEvent('activity_upserted', {
|
|
216
|
+
origin: 'tool',
|
|
217
|
+
payload: {
|
|
218
|
+
activity: {
|
|
219
|
+
key: 'production:job-1',
|
|
220
|
+
id: 'job-1',
|
|
221
|
+
source: 'production',
|
|
222
|
+
label: 'Production build',
|
|
223
|
+
status: 'running',
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
}),
|
|
227
|
+
]);
|
|
228
|
+
|
|
229
|
+
assert.equal(projection.plan.length, 1);
|
|
230
|
+
assert.equal(projection.plan[0].description, 'Production build');
|
|
231
|
+
assert.equal(projection.plan[0].owner, 'activity');
|
|
232
|
+
assert.equal(projection.plan[0].ownerActivityKey, 'production:job-1');
|
|
92
233
|
});
|
|
93
234
|
|
|
94
235
|
test('dispatchAgentEvent: writes compatibility projections to session', () => {
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { buildAgentSystemPrompt, buildLimitedAgentResponse } from '../agent/graph.js';
|
|
2
|
+
import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
|
|
3
|
+
import { activitySnapshot, newNonTerminalActivities } from './activity.js';
|
|
4
|
+
import { extractHeadlessPlan, formatCompletedActivities, formatPlanStatus } from './plan.js';
|
|
5
|
+
|
|
6
|
+
export function abortError(message = 'Agent run cancelled.') {
|
|
7
|
+
const err = new Error(message);
|
|
8
|
+
err.name = 'AbortError';
|
|
9
|
+
return err;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function throwIfAborted(signal, message) {
|
|
13
|
+
if (signal?.aborted) throw abortError(message);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function runAgentTurn(agent, session, input, {
|
|
17
|
+
messages = [],
|
|
18
|
+
signal = null,
|
|
19
|
+
} = {}) {
|
|
20
|
+
let streamedContent = '';
|
|
21
|
+
session._onStream = (delta) => { streamedContent += delta; };
|
|
22
|
+
session._onStreamReset = () => { streamedContent = ''; };
|
|
23
|
+
let result;
|
|
24
|
+
try {
|
|
25
|
+
result = await agent.invoke({ input, session, messages });
|
|
26
|
+
} finally {
|
|
27
|
+
delete session._onStream;
|
|
28
|
+
delete session._onStreamReset;
|
|
29
|
+
}
|
|
30
|
+
if (result.streamedInline) {
|
|
31
|
+
return streamedContent.trim() || buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content');
|
|
32
|
+
}
|
|
33
|
+
if (result.response != null) return result.response;
|
|
34
|
+
if (result.readyToStream && session.llm?.stream) {
|
|
35
|
+
const { system, messages: streamMessages = [] } = result.streamContext ?? {};
|
|
36
|
+
let content = '';
|
|
37
|
+
for await (const delta of session.llm.stream({
|
|
38
|
+
system: system ?? buildAgentSystemPrompt({ input, session }),
|
|
39
|
+
messages: streamMessages,
|
|
40
|
+
signal,
|
|
41
|
+
})) {
|
|
42
|
+
content += delta;
|
|
43
|
+
}
|
|
44
|
+
return content.trim() || buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content');
|
|
45
|
+
}
|
|
46
|
+
return buildLimitedAgentResponse({ input, session });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function runAgenticLoop(agent, session, initialInput, {
|
|
50
|
+
maxTurns,
|
|
51
|
+
timeoutMs,
|
|
52
|
+
signal = null,
|
|
53
|
+
runId = null,
|
|
54
|
+
runTurn = runAgentTurn,
|
|
55
|
+
waitForActivities,
|
|
56
|
+
planOrigin = 'llm',
|
|
57
|
+
onTurnStart = null,
|
|
58
|
+
onTurnResponse = null,
|
|
59
|
+
onAssistantMessage = null,
|
|
60
|
+
onPlanExtracted = null,
|
|
61
|
+
onPlanAlreadySet = null,
|
|
62
|
+
onComplete = null,
|
|
63
|
+
onPendingSteps = null,
|
|
64
|
+
onActivitiesStarted = null,
|
|
65
|
+
onActivitiesCompleted = null,
|
|
66
|
+
onMaxTurns = null,
|
|
67
|
+
abortMessage = 'Agent run cancelled.',
|
|
68
|
+
} = {}) {
|
|
69
|
+
if (!waitForActivities) throw new Error('runAgenticLoop requires waitForActivities.');
|
|
70
|
+
const conversationHistory = [];
|
|
71
|
+
let currentInput = initialInput;
|
|
72
|
+
|
|
73
|
+
for (let turn = 1; turn <= maxTurns; turn += 1) {
|
|
74
|
+
throwIfAborted(signal, abortMessage);
|
|
75
|
+
if (session._currentRunIdentity && runId) {
|
|
76
|
+
session._currentRunIdentity.turnId = `${runId}:turn-${turn}`;
|
|
77
|
+
}
|
|
78
|
+
onTurnStart?.({ turn, maxTurns });
|
|
79
|
+
|
|
80
|
+
const snapshot = activitySnapshot(session);
|
|
81
|
+
const response = await runTurn(agent, session, currentInput, {
|
|
82
|
+
messages: conversationHistory,
|
|
83
|
+
signal,
|
|
84
|
+
});
|
|
85
|
+
onTurnResponse?.({ turn, response });
|
|
86
|
+
onAssistantMessage?.({ response, runId });
|
|
87
|
+
|
|
88
|
+
conversationHistory.push(
|
|
89
|
+
{ role: 'user', content: currentInput },
|
|
90
|
+
{ role: 'assistant', content: response },
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (turn === 1) {
|
|
94
|
+
if (session.headlessPlan === null) {
|
|
95
|
+
const extractedPlan = extractHeadlessPlan(response);
|
|
96
|
+
if (extractedPlan) {
|
|
97
|
+
dispatchAgentEvent(session, createAgentEvent('plan_set', {
|
|
98
|
+
origin: planOrigin,
|
|
99
|
+
runId,
|
|
100
|
+
payload: { steps: extractedPlan },
|
|
101
|
+
}));
|
|
102
|
+
onPlanExtracted?.({ steps: session.headlessPlan ?? extractedPlan, fallback: true });
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
onPlanAlreadySet?.({ steps: session.headlessPlan });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const newPending = newNonTerminalActivities(snapshot, session);
|
|
110
|
+
if (newPending.length === 0) {
|
|
111
|
+
const pendingSteps = (session.headlessPlan ?? []).filter((step) => step.status === 'pending');
|
|
112
|
+
if (pendingSteps.length === 0) {
|
|
113
|
+
onComplete?.();
|
|
114
|
+
return { ok: true };
|
|
115
|
+
}
|
|
116
|
+
onPendingSteps?.({ pendingSteps });
|
|
117
|
+
currentInput = pendingStepsPrompt(initialInput, session.headlessPlan);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
onActivitiesStarted?.({ activities: newPending });
|
|
122
|
+
const waitResult = await waitForActivities(session, newPending, { timeoutMs, signal });
|
|
123
|
+
if (!waitResult.ok) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
timedOut: Boolean(waitResult.timedOut),
|
|
127
|
+
completed: waitResult.completed ?? [],
|
|
128
|
+
waitResult,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const completed = waitResult.completed ?? [];
|
|
133
|
+
const summary = formatCompletedActivities(completed);
|
|
134
|
+
onActivitiesCompleted?.({ completed, summary });
|
|
135
|
+
currentInput = completedActivitiesPrompt(initialInput, session.headlessPlan, summary);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
onMaxTurns?.({ maxTurns });
|
|
139
|
+
return { ok: false, maxTurns: true };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function pendingStepsPrompt(initialInput, plan) {
|
|
143
|
+
return [
|
|
144
|
+
'Original task:',
|
|
145
|
+
initialInput,
|
|
146
|
+
'',
|
|
147
|
+
`Plan status:\n${formatPlanStatus(plan)}`,
|
|
148
|
+
'',
|
|
149
|
+
'No new background activity was started in the previous turn.',
|
|
150
|
+
'Continue the original plan. Start the next pending step only.',
|
|
151
|
+
'If required information is missing and cannot be inferred, stop with a clear blocker.',
|
|
152
|
+
].join('\n');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function completedActivitiesPrompt(initialInput, plan, summary) {
|
|
156
|
+
return [
|
|
157
|
+
'Original task:',
|
|
158
|
+
initialInput,
|
|
159
|
+
'',
|
|
160
|
+
plan ? `Plan status:\n${formatPlanStatus(plan)}\n` : null,
|
|
161
|
+
'Completed activities:',
|
|
162
|
+
summary,
|
|
163
|
+
'',
|
|
164
|
+
'Continue the original plan. Start the next required step only.',
|
|
165
|
+
'If all steps are complete, provide the final summary.',
|
|
166
|
+
].filter(Boolean).join('\n');
|
|
167
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
|
|
4
|
+
import { runAgenticLoop } from './agentLoop.js';
|
|
5
|
+
|
|
6
|
+
test('runAgenticLoop waits for new activities and continues with a completion summary', async () => {
|
|
7
|
+
const session = {
|
|
8
|
+
activities: {},
|
|
9
|
+
headlessPlan: null,
|
|
10
|
+
};
|
|
11
|
+
const inputs = [];
|
|
12
|
+
const callbacks = [];
|
|
13
|
+
const agent = {
|
|
14
|
+
async invoke({ input, session: turnSession }) {
|
|
15
|
+
inputs.push(input);
|
|
16
|
+
if (inputs.length === 1) {
|
|
17
|
+
dispatchAgentEvent(turnSession, createAgentEvent('activity_upserted', {
|
|
18
|
+
payload: {
|
|
19
|
+
activity: {
|
|
20
|
+
id: 'job-1',
|
|
21
|
+
source: 'production',
|
|
22
|
+
kind: 'job',
|
|
23
|
+
label: 'production job',
|
|
24
|
+
status: 'running',
|
|
25
|
+
terminal: false,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
return { response: 'Started production job.' };
|
|
30
|
+
}
|
|
31
|
+
return { response: 'All done.' };
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const result = await runAgenticLoop(agent, session, 'Build workspace', {
|
|
36
|
+
maxTurns: 3,
|
|
37
|
+
timeoutMs: 1000,
|
|
38
|
+
waitForActivities: async (turnSession, startedActivities) => {
|
|
39
|
+
assert.equal(startedActivities.length, 1);
|
|
40
|
+
dispatchAgentEvent(turnSession, createAgentEvent('activity_upserted', {
|
|
41
|
+
payload: {
|
|
42
|
+
activity: {
|
|
43
|
+
id: 'job-1',
|
|
44
|
+
source: 'production',
|
|
45
|
+
kind: 'job',
|
|
46
|
+
label: 'production job',
|
|
47
|
+
status: 'done',
|
|
48
|
+
terminal: true,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
}));
|
|
52
|
+
if (turnSession.headlessPlan?.[0]) turnSession.headlessPlan[0].status = 'done';
|
|
53
|
+
return { ok: true, completed: Object.values(turnSession.activities) };
|
|
54
|
+
},
|
|
55
|
+
onActivitiesStarted: ({ activities }) => callbacks.push(`started:${activities.length}`),
|
|
56
|
+
onActivitiesCompleted: ({ summary }) => callbacks.push(summary),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
assert.equal(result.ok, true);
|
|
60
|
+
assert.equal(inputs.length, 2);
|
|
61
|
+
assert.match(inputs[1], /Completed activities:/);
|
|
62
|
+
assert.match(inputs[1], /production job-1: done/);
|
|
63
|
+
assert.deepEqual(callbacks, ['started:1', '- production job-1: done']);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('runAgenticLoop extracts a fallback numbered plan from first response', async () => {
|
|
67
|
+
const session = {
|
|
68
|
+
activities: {},
|
|
69
|
+
headlessPlan: null,
|
|
70
|
+
};
|
|
71
|
+
const result = await runAgenticLoop({
|
|
72
|
+
async invoke() {
|
|
73
|
+
return { response: '1. Collect sources\n2. Build page' };
|
|
74
|
+
},
|
|
75
|
+
}, session, 'Plan task', {
|
|
76
|
+
maxTurns: 1,
|
|
77
|
+
timeoutMs: 1000,
|
|
78
|
+
waitForActivities: async () => assert.fail('No activities should be waited for.'),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
assert.equal(result.ok, false);
|
|
82
|
+
assert.equal(result.maxTurns, true);
|
|
83
|
+
assert.equal(session.headlessPlan.length, 2);
|
|
84
|
+
assert.equal(session.headlessPlan[0].description, 'Collect sources');
|
|
85
|
+
});
|