@forwardimpact/libeval 0.1.11 → 0.1.13

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.
@@ -1,113 +0,0 @@
1
- /**
2
- * Test-only mock factory for AgentRunner. Yields pre-scripted responses,
3
- * and (when an `onBatch` callback is set) fires it at the same boundaries
4
- * the real AgentRunner would: every `runner.batchSize` assistant messages
5
- * with a text block, and the terminal `result` message. Tool-only
6
- * assistant messages accumulate into the pending batch without counting
7
- * toward the threshold. If the callback calls `abort()`, the mock stops
8
- * iterating that response's messages and reports `aborted: true` — any
9
- * lines that never made it through a flush boundary then ship in a
10
- * terminal batch, mirroring the real runner's finally-flush.
11
- *
12
- * Intentionally a regular module (not a test file) so describe/test blocks
13
- * here would not run. Lives under test/ to make its scope explicit.
14
- */
15
-
16
- import { PassThrough } from "node:stream";
17
- import { AgentRunner } from "@forwardimpact/libeval";
18
- import { hasTextBlock } from "../src/agent-runner.js";
19
-
20
- /**
21
- * Create a mock AgentRunner that yields pre-scripted responses. Each call
22
- * to `run()` or `resume()` pops the next response from the array.
23
- * @param {object[]} responses - Array of {text, success} objects
24
- * @param {object[]} [messages] - Messages to buffer per response
25
- * @returns {AgentRunner}
26
- */
27
- export function createMockRunner(responses, messages) {
28
- const output = new PassThrough();
29
- let callIndex = 0;
30
-
31
- const runner = new AgentRunner({
32
- cwd: "/tmp",
33
- query: async function* () {},
34
- output,
35
- });
36
-
37
- const consume = async (msgs) => {
38
- let aborted = false;
39
- const pendingBatch = [];
40
- let assistantTextCount = 0;
41
- for (const m of msgs) {
42
- const line = JSON.stringify(m);
43
- runner.buffer.push(line);
44
- if (runner.onLine) runner.onLine(line);
45
- if (runner.onBatch) pendingBatch.push(line);
46
-
47
- if (hasTextBlock(m)) {
48
- assistantTextCount++;
49
- }
50
-
51
- const shouldFlush =
52
- runner.onBatch &&
53
- (m.type === "result" || assistantTextCount >= runner.batchSize);
54
- if (shouldFlush) {
55
- assistantTextCount = 0;
56
- const batchLines = pendingBatch.splice(0);
57
- await runner.onBatch(batchLines, {
58
- abort: () => {
59
- aborted = true;
60
- },
61
- });
62
- if (aborted) break;
63
- }
64
- }
65
- // Terminal flush: mirror the real AgentRunner's abnormal-end path —
66
- // an aborted scripted run delivers any pending tail so the supervisor
67
- // sees the partial state. Natural-end without a `result` marker is
68
- // treated as a simplified stub (no phantom flush), matching the real
69
- // runner's rule that terminal flush only fires on error/abort.
70
- if (aborted && runner.onBatch && pendingBatch.length > 0) {
71
- const batchLines = pendingBatch.splice(0);
72
- await runner.onBatch(batchLines, {
73
- abort: () => {
74
- aborted = true;
75
- },
76
- });
77
- }
78
- return aborted;
79
- };
80
-
81
- runner.run = async (_task) => {
82
- const resp = responses[callIndex++];
83
- const msgs = messages?.[callIndex - 1] ?? [
84
- { type: "assistant", content: resp.text },
85
- ];
86
- const aborted = await consume(msgs);
87
- runner.sessionId = "mock-session";
88
- return {
89
- success: resp.success ?? true,
90
- text: resp.text,
91
- sessionId: "mock-session",
92
- aborted,
93
- error: null,
94
- };
95
- };
96
-
97
- runner.resume = async (_prompt) => {
98
- const resp = responses[callIndex++];
99
- const msgs = messages?.[callIndex - 1] ?? [
100
- { type: "assistant", content: resp.text },
101
- ];
102
- const aborted = await consume(msgs);
103
- return {
104
- success: resp.success ?? true,
105
- text: resp.text,
106
- sessionId: runner.sessionId,
107
- aborted,
108
- error: null,
109
- };
110
- };
111
-
112
- return runner;
113
- }
@@ -1,175 +0,0 @@
1
- import { describe, test } from "node:test";
2
- import assert from "node:assert";
3
- import { PassThrough } from "node:stream";
4
-
5
- import { Supervisor } from "@forwardimpact/libeval";
6
- import { createMockRunner } from "./mock-runner.js";
7
-
8
- const textBlock = (t) => ({
9
- type: "assistant",
10
- message: { content: [{ type: "text", text: t }] },
11
- });
12
-
13
- describe("Supervisor - batching at the default batchSize", () => {
14
- test("mid-turn review fires once per 3 agent text messages", async () => {
15
- // Agent emits 7 text-block assistant messages in one turn. With the
16
- // default batchSize of 3 the supervisor's mid-turn review should fire
17
- // twice (after messages 3 and 6) plus once more from the terminal
18
- // result flush carrying the remaining message — not seven times, as
19
- // the old per-message flushing would have done.
20
- const agentMessages = [
21
- [
22
- textBlock("step 1"),
23
- textBlock("step 2"),
24
- textBlock("step 3"),
25
- textBlock("step 4"),
26
- textBlock("step 5"),
27
- textBlock("step 6"),
28
- textBlock("step 7"),
29
- { type: "result", subtype: "success", result: "Done." },
30
- ],
31
- ];
32
-
33
- const agentRunner = createMockRunner(
34
- [{ text: "Finished." }],
35
- agentMessages,
36
- );
37
- // Leave batchSize at the default (3) — this is the behaviour we're
38
- // verifying end-to-end through the supervisor loop.
39
- assert.strictEqual(agentRunner.batchSize, 3);
40
-
41
- const supervisorRunner = createMockRunner([
42
- { text: "Welcome. Begin." },
43
- { text: "Keep going." }, // mid-turn batch 1 (messages 1-3)
44
- { text: "Keep going." }, // mid-turn batch 2 (messages 4-6)
45
- { text: "Keep going." }, // terminal result flush (message 7 + result)
46
- { text: "Good work.\n\nEVALUATION_COMPLETE" }, // end-of-turn review
47
- ]);
48
-
49
- const output = new PassThrough();
50
- const supervisor = new Supervisor({
51
- agentRunner,
52
- supervisorRunner,
53
- output,
54
- maxTurns: 10,
55
- });
56
- agentRunner.onLine = (line) => supervisor.emitLine(line);
57
- supervisorRunner.onLine = (line) => supervisor.emitLine(line);
58
-
59
- const result = await supervisor.run("Do the task");
60
- assert.strictEqual(result.success, true);
61
-
62
- const midTurnReviews = (output.read()?.toString() ?? "")
63
- .trim()
64
- .split("\n")
65
- .filter((l) => l.length > 0)
66
- .map((l) => JSON.parse(l))
67
- .filter(
68
- (l) =>
69
- l.source === "orchestrator" && l.event?.type === "mid_turn_review",
70
- );
71
-
72
- // 3 flushes total: two at the batchSize threshold (messages 3 and 6),
73
- // one at the terminal result (trailing message + result marker).
74
- assert.strictEqual(
75
- midTurnReviews.length,
76
- 3,
77
- "Supervisor should review 3 times per turn, not 7",
78
- );
79
- });
80
-
81
- test("EVALUATION_INTERVENTION at the default batchSize still aborts and relays", async () => {
82
- // Companion to the observation test above: the 3-message batching and
83
- // the intervention path exercised together.
84
- //
85
- // Agent call 1 emits 3 text-block messages (triggering a flush at the
86
- // 3rd). The supervisor intervenes; the agent SDK session aborts and
87
- // the supervisor's intervention text is relayed into resume(). Agent
88
- // call 2 has 1 text block — below the batchSize threshold — so no
89
- // extra mid-turn flush fires, and the supervisor jumps straight to
90
- // the end-of-turn review.
91
- const agentMessages = [
92
- [
93
- textBlock("reading docs"),
94
- textBlock("running Bash"),
95
- textBlock("found the wrong path"),
96
- ],
97
- [textBlock("corrected, using the documented path")],
98
- ];
99
-
100
- const agentRunner = createMockRunner(
101
- [{ text: "wrong path" }, { text: "corrected" }],
102
- agentMessages,
103
- );
104
- assert.strictEqual(agentRunner.batchSize, 3);
105
-
106
- const supervisorMessages = [
107
- undefined,
108
- [
109
- {
110
- type: "assistant",
111
- message: {
112
- content: [
113
- {
114
- type: "text",
115
- text: "EVALUATION_INTERVENTION Use the documented path.",
116
- },
117
- ],
118
- },
119
- },
120
- ],
121
- undefined,
122
- ];
123
-
124
- const supervisorRunner = createMockRunner(
125
- [
126
- { text: "Welcome. Begin." },
127
- { text: "EVALUATION_INTERVENTION Use the documented path." },
128
- { text: "Good.\n\nEVALUATION_COMPLETE" },
129
- ],
130
- supervisorMessages,
131
- );
132
-
133
- const output = new PassThrough();
134
- const supervisor = new Supervisor({
135
- agentRunner,
136
- supervisorRunner,
137
- output,
138
- maxTurns: 10,
139
- });
140
- agentRunner.onLine = (line) => supervisor.emitLine(line);
141
- supervisorRunner.onLine = (line) => supervisor.emitLine(line);
142
-
143
- let resumePrompt = null;
144
- const origResume = agentRunner.resume;
145
- agentRunner.resume = async (prompt) => {
146
- resumePrompt = prompt;
147
- return origResume.call(agentRunner, prompt);
148
- };
149
-
150
- const result = await supervisor.run("Install");
151
- assert.strictEqual(result.success, true);
152
- assert.strictEqual(result.turns, 1);
153
- assert.ok(
154
- resumePrompt && resumePrompt.includes("documented path"),
155
- "Resume prompt should carry the supervisor's intervention text",
156
- );
157
-
158
- const orchestratorEvents = (output.read()?.toString() ?? "")
159
- .trim()
160
- .split("\n")
161
- .filter((l) => l.length > 0)
162
- .map((l) => JSON.parse(l))
163
- .filter((e) => e.source === "orchestrator");
164
- assert.ok(
165
- orchestratorEvents.some(
166
- (e) => e.event?.type === "intervention_requested",
167
- ),
168
- "Trace should contain intervention_requested",
169
- );
170
- assert.ok(
171
- orchestratorEvents.some((e) => e.event?.type === "intervention_relayed"),
172
- "Trace should contain intervention_relayed",
173
- );
174
- });
175
- });
@@ -1,365 +0,0 @@
1
- import { describe, test } from "node:test";
2
- import assert from "node:assert";
3
- import { PassThrough } from "node:stream";
4
-
5
- import { Supervisor } from "@forwardimpact/libeval";
6
- import { isIntervention } from "../src/supervisor.js";
7
- import { createMockRunner } from "./mock-runner.js";
8
-
9
- describe("isIntervention", () => {
10
- test("detects EVALUATION_INTERVENTION on its own line", () => {
11
- assert.strictEqual(isIntervention("EVALUATION_INTERVENTION"), true);
12
- assert.strictEqual(
13
- isIntervention("Some text\nEVALUATION_INTERVENTION\nMore text"),
14
- true,
15
- );
16
- assert.strictEqual(
17
- isIntervention("Stop.\n\nEVALUATION_INTERVENTION"),
18
- true,
19
- );
20
- });
21
-
22
- test("tolerates markdown formatting around the signal", () => {
23
- assert.strictEqual(isIntervention("**EVALUATION_INTERVENTION**"), true);
24
- assert.strictEqual(isIntervention("*EVALUATION_INTERVENTION*"), true);
25
- assert.strictEqual(isIntervention("__EVALUATION_INTERVENTION__"), true);
26
- assert.strictEqual(isIntervention("_EVALUATION_INTERVENTION_"), true);
27
- assert.strictEqual(isIntervention("`EVALUATION_INTERVENTION`"), true);
28
- assert.strictEqual(
29
- isIntervention(
30
- "Wrong path.\n\n**EVALUATION_INTERVENTION**\n\nTry the documented one.",
31
- ),
32
- true,
33
- );
34
- });
35
-
36
- test("matches EVALUATION_INTERVENTION inline", () => {
37
- assert.strictEqual(
38
- isIntervention("Stopping you with EVALUATION_INTERVENTION now."),
39
- true,
40
- );
41
- assert.strictEqual(
42
- isIntervention("Note: EVALUATION_INTERVENTION. Switch to Y."),
43
- true,
44
- );
45
- });
46
-
47
- test("does not match empty or unrelated text", () => {
48
- assert.strictEqual(isIntervention(""), false);
49
- assert.strictEqual(isIntervention("Stop and think."), false);
50
- assert.strictEqual(isIntervention("INTERVENTION"), false);
51
- });
52
-
53
- test("does not match EVALUATION_COMPLETE alone", () => {
54
- assert.strictEqual(isIntervention("EVALUATION_COMPLETE"), false);
55
- assert.strictEqual(
56
- isIntervention("Good work.\n\nEVALUATION_COMPLETE"),
57
- false,
58
- );
59
- });
60
- });
61
-
62
- describe("Supervisor - mid-turn intervention", () => {
63
- test("observation without intervention does not interrupt the agent", async () => {
64
- // Agent emits one structured assistant text block — fires onBatch once.
65
- // Supervisor responds with "Keep going." — neither signal flag is set,
66
- // so the agent's SDK session completes naturally and the end-of-turn
67
- // review then emits EVALUATION_COMPLETE.
68
- //
69
- // batchSize = 1 keeps this test focused on intervention semantics, not
70
- // on the coarser default batching (3) exercised by agent-runner.test.js.
71
- const agentMessages = [
72
- [
73
- {
74
- type: "assistant",
75
- message: {
76
- content: [{ type: "text", text: "I'm working on it." }],
77
- },
78
- },
79
- ],
80
- ];
81
-
82
- const agentRunner = createMockRunner(
83
- [{ text: "I'm working on it." }],
84
- agentMessages,
85
- );
86
- agentRunner.batchSize = 1;
87
-
88
- const supervisorRunner = createMockRunner([
89
- { text: "Welcome! Please install." },
90
- { text: "Keep going." },
91
- { text: "Good work.\n\nEVALUATION_COMPLETE" },
92
- ]);
93
-
94
- const output = new PassThrough();
95
- const supervisor = new Supervisor({
96
- agentRunner,
97
- supervisorRunner,
98
- output,
99
- maxTurns: 10,
100
- });
101
- agentRunner.onLine = (line) => supervisor.emitLine(line);
102
- supervisorRunner.onLine = (line) => supervisor.emitLine(line);
103
-
104
- let agentResumeCalls = 0;
105
- const origAgentResume = agentRunner.resume;
106
- agentRunner.resume = async (prompt) => {
107
- agentResumeCalls++;
108
- return origAgentResume.call(agentRunner, prompt);
109
- };
110
-
111
- const result = await supervisor.run("Install");
112
-
113
- assert.strictEqual(result.success, true);
114
- assert.strictEqual(result.turns, 1);
115
- assert.strictEqual(
116
- agentResumeCalls,
117
- 0,
118
- "Agent should not be resumed when supervisor never intervenes",
119
- );
120
-
121
- // Trace must contain a mid_turn_review marker but no intervention markers.
122
- const data = output.read()?.toString() ?? "";
123
- const orchestratorEvents = data
124
- .trim()
125
- .split("\n")
126
- .filter((l) => l.length > 0)
127
- .map((l) => JSON.parse(l))
128
- .filter((e) => e.source === "orchestrator");
129
- assert.ok(
130
- orchestratorEvents.some((e) => e.event?.type === "mid_turn_review"),
131
- "Trace should contain mid_turn_review when onBatch fires",
132
- );
133
- assert.ok(
134
- !orchestratorEvents.some(
135
- (e) => e.event?.type === "intervention_requested",
136
- ),
137
- "Trace should not contain intervention_requested when supervisor only observes",
138
- );
139
- });
140
-
141
- test("EVALUATION_INTERVENTION from mid-turn batch interrupts and relays", async () => {
142
- // Agent's first call fires onBatch on a structured assistant text block;
143
- // supervisor responds with EVALUATION_INTERVENTION → abort + relay.
144
- // Agent's second call (resume) finishes naturally; end-of-turn review
145
- // then emits EVALUATION_COMPLETE.
146
- const agentMessages = [
147
- [
148
- {
149
- type: "assistant",
150
- message: {
151
- content: [{ type: "text", text: "I'll try the wrong path." }],
152
- },
153
- },
154
- ],
155
- [
156
- {
157
- type: "assistant",
158
- message: {
159
- content: [
160
- { type: "text", text: "OK, switching to the documented path." },
161
- ],
162
- },
163
- },
164
- ],
165
- ];
166
-
167
- const agentRunner = createMockRunner(
168
- [
169
- { text: "I'll try the wrong path." },
170
- { text: "OK, switching to the documented path." },
171
- ],
172
- agentMessages,
173
- );
174
- agentRunner.batchSize = 1;
175
-
176
- // Supervisor responses (in order):
177
- // 0: turn 0 introduction
178
- // 1: mid-turn 1 batch 1 — intervene
179
- // 2: mid-turn 1 batch 1 (post-resume) — keep going
180
- // 3: end-of-turn 1 — EVALUATION_COMPLETE
181
- const supervisorMessages = [
182
- undefined,
183
- [
184
- {
185
- type: "assistant",
186
- message: {
187
- content: [
188
- {
189
- type: "text",
190
- text: "EVALUATION_INTERVENTION Stop and use the documented path.",
191
- },
192
- ],
193
- },
194
- },
195
- ],
196
- undefined,
197
- undefined,
198
- ];
199
-
200
- const supervisorRunner = createMockRunner(
201
- [
202
- { text: "Welcome." },
203
- { text: "EVALUATION_INTERVENTION Stop and use the documented path." },
204
- { text: "Keep going." },
205
- { text: "Good.\n\nEVALUATION_COMPLETE" },
206
- ],
207
- supervisorMessages,
208
- );
209
-
210
- const output = new PassThrough();
211
- const supervisor = new Supervisor({
212
- agentRunner,
213
- supervisorRunner,
214
- output,
215
- maxTurns: 10,
216
- });
217
- agentRunner.onLine = (line) => supervisor.emitLine(line);
218
- supervisorRunner.onLine = (line) => supervisor.emitLine(line);
219
-
220
- let agentResumeCalls = 0;
221
- let firstResumePrompt = null;
222
- const origAgentResume = agentRunner.resume;
223
- agentRunner.resume = async (prompt) => {
224
- agentResumeCalls++;
225
- if (agentResumeCalls === 1) firstResumePrompt = prompt;
226
- return origAgentResume.call(agentRunner, prompt);
227
- };
228
-
229
- const result = await supervisor.run("Install");
230
-
231
- assert.strictEqual(result.success, true);
232
- assert.strictEqual(result.turns, 1);
233
- assert.strictEqual(
234
- agentResumeCalls,
235
- 1,
236
- "Agent should be resumed exactly once after intervention",
237
- );
238
- assert.ok(
239
- firstResumePrompt && firstResumePrompt.includes("documented path"),
240
- "Resume prompt should carry the supervisor's intervention text",
241
- );
242
-
243
- const orchestratorEvents = (output.read()?.toString() ?? "")
244
- .trim()
245
- .split("\n")
246
- .filter((l) => l.length > 0)
247
- .map((l) => JSON.parse(l))
248
- .filter((e) => e.source === "orchestrator");
249
- assert.ok(
250
- orchestratorEvents.some(
251
- (e) => e.event?.type === "intervention_requested",
252
- ),
253
- "Trace should contain intervention_requested orchestrator event",
254
- );
255
- assert.ok(
256
- orchestratorEvents.some((e) => e.event?.type === "intervention_relayed"),
257
- "Trace should contain intervention_relayed orchestrator event",
258
- );
259
- });
260
-
261
- test("EVALUATION_INTERVENTION and EVALUATION_COMPLETE in the same turn", async () => {
262
- // Batch 1: supervisor intervenes (abort + relay).
263
- // After resume, batch 1 of resume: supervisor writes EVALUATION_COMPLETE
264
- // (mid-turn) — the loop must exit success without running an end-of-turn
265
- // review.
266
- const agentMessages = [
267
- [
268
- {
269
- type: "assistant",
270
- message: { content: [{ type: "text", text: "Trying X." }] },
271
- },
272
- ],
273
- [
274
- {
275
- type: "assistant",
276
- message: { content: [{ type: "text", text: "OK trying Y." }] },
277
- },
278
- ],
279
- ];
280
-
281
- const agentRunner = createMockRunner(
282
- [{ text: "Trying X." }, { text: "Trying Y." }],
283
- agentMessages,
284
- );
285
- agentRunner.batchSize = 1;
286
-
287
- const supervisorMessages = [
288
- undefined,
289
- [
290
- {
291
- type: "assistant",
292
- message: {
293
- content: [
294
- {
295
- type: "text",
296
- text: "EVALUATION_INTERVENTION Try Y instead.",
297
- },
298
- ],
299
- },
300
- },
301
- ],
302
- [
303
- {
304
- type: "assistant",
305
- message: {
306
- content: [{ type: "text", text: "Excellent. EVALUATION_COMPLETE" }],
307
- },
308
- },
309
- ],
310
- ];
311
-
312
- const supervisorRunner = createMockRunner(
313
- [
314
- { text: "Welcome." },
315
- { text: "EVALUATION_INTERVENTION Try Y instead." },
316
- { text: "Excellent. EVALUATION_COMPLETE" },
317
- ],
318
- supervisorMessages,
319
- );
320
-
321
- const output = new PassThrough();
322
- const supervisor = new Supervisor({
323
- agentRunner,
324
- supervisorRunner,
325
- output,
326
- maxTurns: 10,
327
- });
328
- agentRunner.onLine = (line) => supervisor.emitLine(line);
329
- supervisorRunner.onLine = (line) => supervisor.emitLine(line);
330
-
331
- let agentResumeCalls = 0;
332
- const origAgentResume = agentRunner.resume;
333
- agentRunner.resume = async (prompt) => {
334
- agentResumeCalls++;
335
- return origAgentResume.call(agentRunner, prompt);
336
- };
337
-
338
- const result = await supervisor.run("Install");
339
-
340
- assert.strictEqual(result.success, true);
341
- assert.strictEqual(result.turns, 1);
342
- assert.strictEqual(
343
- agentResumeCalls,
344
- 1,
345
- "Agent.resume runs once (after intervention); EVALUATION_COMPLETE then ends the turn",
346
- );
347
-
348
- const orchestratorEvents = (output.read()?.toString() ?? "")
349
- .trim()
350
- .split("\n")
351
- .filter((l) => l.length > 0)
352
- .map((l) => JSON.parse(l))
353
- .filter((e) => e.source === "orchestrator");
354
- assert.ok(
355
- orchestratorEvents.some(
356
- (e) => e.event?.type === "intervention_requested",
357
- ),
358
- "Trace should contain intervention_requested",
359
- );
360
- assert.ok(
361
- orchestratorEvents.some((e) => e.event?.type === "complete_requested"),
362
- "Trace should contain complete_requested for mid-turn EVALUATION_COMPLETE",
363
- );
364
- });
365
- });