@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.
Files changed (48) hide show
  1. package/.env.example +20 -0
  2. package/README.md +35 -1
  3. package/bin/wiki-manager.js +2 -2
  4. package/docker-compose.yml +2 -0
  5. package/mcp.endpoints.example.json +13 -0
  6. package/package.json +2 -2
  7. package/src/agent/graph.js +101 -15
  8. package/src/agent/graph.test.js +145 -0
  9. package/src/cli/wiki-manager.js +459 -131
  10. package/src/commands/slash.js +1 -1
  11. package/src/core/activity.js +14 -0
  12. package/src/core/agentEvents.js +148 -6
  13. package/src/core/agentEvents.test.js +145 -4
  14. package/src/core/agentLoop.js +167 -0
  15. package/src/core/agentLoop.test.js +85 -0
  16. package/src/core/cacert.js +4 -3
  17. package/src/core/dockerCompose.test.js +14 -0
  18. package/src/core/documentIntake.test.js +7 -7
  19. package/src/core/env.js +6 -0
  20. package/src/core/jobQueue.js +47 -16
  21. package/src/core/mcp.js +120 -10
  22. package/src/core/mcp.test.js +121 -1
  23. package/src/core/plan.js +33 -0
  24. package/src/core/queueStore.js +27 -0
  25. package/src/core/queueStore.test.js +32 -0
  26. package/src/core/wikiWorkspace.test.js +24 -0
  27. package/src/core/workspaces.js +8 -1
  28. package/src/runtime/approvals.js +113 -0
  29. package/src/runtime/auth.js +35 -0
  30. package/src/runtime/auth.test.js +29 -0
  31. package/src/runtime/client.js +154 -0
  32. package/src/runtime/lifecycle.js +84 -0
  33. package/src/runtime/queueStore.js +6 -0
  34. package/src/runtime/runner.js +413 -0
  35. package/src/runtime/runner.test.js +270 -0
  36. package/src/runtime/server.js +216 -0
  37. package/src/runtime/server.test.js +308 -0
  38. package/src/runtime/store.js +431 -0
  39. package/src/runtime/store.test.js +437 -0
  40. package/src/runtime/supervisor.js +104 -0
  41. package/src/runtime/supervisor.test.js +240 -0
  42. package/src/shell/RightPane.tsx +1 -1
  43. package/src/shell/repl.js +148 -18
  44. package/src/shell/repl.test.js +38 -0
  45. package/src/shell/tui.tsx +4 -1
  46. package/src/shell/useAgent.ts +20 -2
  47. package/src/shell/useSession.ts +153 -13
  48. package/wiki-workspace +221 -22
@@ -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
- export function createAgentEvent(type, { origin = 'system', payload = {}, runId = null, turnId = null } = {}) {
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 = event.id && event.ts ? event : createAgentEvent(event.type, event);
26
- const previousPlan = JSON.stringify(session.headlessPlan ?? null);
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 = null;
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, null);
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: real activity replaces minimal MCP plan', () => {
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, 'CME export');
91
- assert.equal(projection.plan[0]._activityKey, 'cme:export-1');
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
+ });