@forwardimpact/libeval 0.1.14 → 0.1.16

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.
@@ -0,0 +1,512 @@
1
+ /**
2
+ * Facilitator — orchestrates multi-agent concurrent sessions with tool-based
3
+ * communication. Manages N agent sessions and one facilitator LLM session,
4
+ * communicating through OrchestrationToolkit primitives and the MessageBus.
5
+ *
6
+ * Follows OO+DI: constructor injection, factory function, tests bypass factory.
7
+ */
8
+
9
+ import { Writable } from "node:stream";
10
+ import { createAgentRunner } from "./agent-runner.js";
11
+ import { SequenceCounter } from "./sequence-counter.js";
12
+ import { createMessageBus } from "./message-bus.js";
13
+ import {
14
+ createOrchestrationContext,
15
+ createFacilitatorToolServer,
16
+ createFacilitatedAgentToolServer,
17
+ } from "./orchestration-toolkit.js";
18
+
19
+ /** System prompt appended for the facilitator runner. */
20
+ export const FACILITATOR_SYSTEM_PROMPT =
21
+ "You coordinate multiple agents working on a shared task. " +
22
+ "Tell sends a direct message to one participant. " +
23
+ "Share broadcasts a message to all participants. " +
24
+ "Redirect interrupts a participant and replaces their current instructions. " +
25
+ "RollCall lists available participants and their roles. " +
26
+ "Conclude ends the session with a summary. " +
27
+ "Participants communicate with you via Share and may Ask you questions. " +
28
+ "IMPORTANT: After sending messages via Tell or Share, stop making tool " +
29
+ "calls and produce a text response. The system will resume you with " +
30
+ "participant responses. Do not proceed to the next question or call " +
31
+ "Conclude until you have received responses from participants.";
32
+
33
+ /** System prompt appended for facilitated agent runners. */
34
+ export const FACILITATED_AGENT_SYSTEM_PROMPT =
35
+ "You are one of several agents working on a shared task under a " +
36
+ "facilitator's coordination. " +
37
+ "Share broadcasts your message to all participants. " +
38
+ "Tell sends a direct message to one participant. " +
39
+ "Ask sends a question to the facilitator — you block until answered. " +
40
+ "RollCall lists available participants and their roles. " +
41
+ "The facilitator may Redirect you with new instructions " +
42
+ "— treat redirections as authoritative.";
43
+
44
+ function createAsyncQueue() {
45
+ const items = [];
46
+ let waiter = null;
47
+ let closed = false;
48
+ return {
49
+ enqueue(item) {
50
+ items.push(item);
51
+ if (waiter) {
52
+ waiter();
53
+ waiter = null;
54
+ }
55
+ },
56
+ async dequeue() {
57
+ if (items.length > 0) return items.shift();
58
+ if (closed) return null;
59
+ await new Promise((resolve) => {
60
+ waiter = resolve;
61
+ });
62
+ return items.length > 0 ? items.shift() : null;
63
+ },
64
+ close() {
65
+ closed = true;
66
+ if (waiter) {
67
+ waiter();
68
+ waiter = null;
69
+ }
70
+ },
71
+ };
72
+ }
73
+
74
+ export class Facilitator {
75
+ /**
76
+ * @param {object} deps
77
+ * @param {import("./agent-runner.js").AgentRunner} deps.facilitatorRunner
78
+ * @param {Array<{name: string, role: string, runner: import("./agent-runner.js").AgentRunner}>} deps.agents
79
+ * @param {import("./message-bus.js").MessageBus} deps.messageBus
80
+ * @param {import("stream").Writable} deps.output
81
+ * @param {number} [deps.maxTurns]
82
+ * @param {object} [deps.ctx]
83
+ * @param {object} [deps.eventQueue]
84
+ */
85
+ constructor({
86
+ facilitatorRunner,
87
+ agents,
88
+ messageBus,
89
+ output,
90
+ maxTurns,
91
+ ctx,
92
+ eventQueue,
93
+ }) {
94
+ this.facilitatorRunner = facilitatorRunner;
95
+ this.agents = agents;
96
+ this.messageBus = messageBus;
97
+ this.output = output;
98
+ this.maxTurns = maxTurns ?? 20;
99
+ this.ctx = ctx ?? createOrchestrationContext();
100
+ this.counter = new SequenceCounter();
101
+ this.eventQueue = eventQueue ?? createAsyncQueue();
102
+ this.facilitatorTurns = 0;
103
+
104
+ let resolve;
105
+ const promise = new Promise((r) => {
106
+ resolve = r;
107
+ });
108
+ this.concludePromise = promise;
109
+ this.concludeResolve = resolve;
110
+ }
111
+
112
+ /**
113
+ * Run the full facilitated session.
114
+ * @param {string} task
115
+ * @returns {Promise<{success: boolean, turns: number}>}
116
+ */
117
+ async run(task) {
118
+ this.emitOrchestratorEvent({ type: "session_start" });
119
+
120
+ // Launch agent loops first — they wait for messages via messageBus.
121
+ // This lets agents process Tell/Share messages that arrive during the
122
+ // facilitator's initial run, rather than after it completes.
123
+ const agentPromises = this.agents.map((a) => this.#runAgent(a));
124
+
125
+ // Turn 0: facilitator receives the task
126
+ this.facilitatorTurns++;
127
+ await this.facilitatorRunner.run(task);
128
+
129
+ // Handle redirect after turn 0
130
+ await this.#processRedirect();
131
+
132
+ if (this.ctx.concluded) {
133
+ // Facilitator concluded during its initial run. Let agents finish any
134
+ // in-progress work before returning — they may have received Tell/Share
135
+ // messages and started processing concurrently.
136
+ this.concludeResolve();
137
+ await Promise.allSettled(agentPromises);
138
+ this.emitSummary({
139
+ success: true,
140
+ turns: this.facilitatorTurns,
141
+ summary: this.ctx.summary,
142
+ });
143
+ return { success: true, turns: this.facilitatorTurns };
144
+ }
145
+
146
+ // Abort agents promptly when Conclude is called during the event loop
147
+ this.concludePromise.then(() => {
148
+ for (const agent of this.agents) {
149
+ agent.runner.currentAbortController?.abort();
150
+ }
151
+ });
152
+
153
+ // Concurrent phase: facilitator event loop + already-running agent loops
154
+ const facilitatorPromise = this.#facilitatorLoop();
155
+
156
+ try {
157
+ await Promise.all([...agentPromises, facilitatorPromise]);
158
+ } catch (err) {
159
+ for (const agent of this.agents) {
160
+ agent.runner.currentAbortController?.abort();
161
+ }
162
+ this.facilitatorRunner.currentAbortController?.abort();
163
+ throw err;
164
+ }
165
+
166
+ const result = {
167
+ success: this.ctx.concluded,
168
+ turns: this.facilitatorTurns,
169
+ };
170
+ this.emitSummary({
171
+ success: result.success,
172
+ turns: result.turns,
173
+ summary: this.ctx.summary,
174
+ });
175
+ return result;
176
+ }
177
+
178
+ /**
179
+ * Agent outer loop — waits for messages, runs/resumes the agent.
180
+ * @param {{name: string, role: string, runner: import("./agent-runner.js").AgentRunner}} agent
181
+ */
182
+ async #runAgent(agent) {
183
+ // Wait for first message (lazy start)
184
+ await Promise.race([
185
+ this.messageBus.waitForMessages(agent.name),
186
+ this.concludePromise,
187
+ ]);
188
+ if (this.ctx.concluded) return;
189
+
190
+ let messages = this.messageBus.drain(agent.name);
191
+ if (messages.length === 0) return;
192
+
193
+ this.emitOrchestratorEvent({
194
+ type: "agent_start",
195
+ agent: agent.name,
196
+ });
197
+ await agent.runner.run(this.#formatMessages(messages));
198
+ if (this.ctx.concluded) return;
199
+ this.eventQueue.enqueue({
200
+ type: "lifecycle",
201
+ agent: agent.name,
202
+ status: "turn_complete",
203
+ });
204
+
205
+ // Loop: check for new messages, resume if any
206
+ while (!this.ctx.concluded) {
207
+ messages = this.messageBus.drain(agent.name);
208
+ if (messages.length === 0) {
209
+ await Promise.race([
210
+ this.messageBus.waitForMessages(agent.name),
211
+ this.concludePromise,
212
+ ]);
213
+ if (this.ctx.concluded) break;
214
+ messages = this.messageBus.drain(agent.name);
215
+ if (messages.length === 0) break;
216
+ }
217
+ await agent.runner.resume(this.#formatMessages(messages));
218
+ if (this.ctx.concluded) break;
219
+ this.eventQueue.enqueue({
220
+ type: "lifecycle",
221
+ agent: agent.name,
222
+ status: "turn_complete",
223
+ });
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Facilitator event loop — only runs when input arrives.
229
+ */
230
+ async #facilitatorLoop() {
231
+ while (!this.ctx.concluded) {
232
+ const event = await this.eventQueue.dequeue();
233
+ if (this.ctx.concluded || event === null) break;
234
+ await this.#handleEvent(event);
235
+ }
236
+ }
237
+
238
+ async #handleEvent(event) {
239
+ switch (event.type) {
240
+ case "ask": {
241
+ if (this.ctx.concluded) {
242
+ event.resolve("Session has concluded.");
243
+ break;
244
+ }
245
+ this.facilitatorTurns++;
246
+ this.emitOrchestratorEvent({
247
+ type: "ask_received",
248
+ from: event.from,
249
+ });
250
+ await this.facilitatorRunner.resume(
251
+ `Agent "${event.from}" asks: "${event.question}"\nAnswer the question.`,
252
+ );
253
+ const answer = this.extractLastText(
254
+ this.facilitatorRunner,
255
+ "No answer.",
256
+ );
257
+ this.emitOrchestratorEvent({
258
+ type: "ask_answered",
259
+ from: event.from,
260
+ });
261
+ event.resolve(answer);
262
+ await this.#processRedirect();
263
+ break;
264
+ }
265
+ case "messages": {
266
+ const msgs = this.messageBus.drain("facilitator");
267
+ if (msgs.length === 0) break;
268
+ this.facilitatorTurns++;
269
+ await this.facilitatorRunner.resume(this.#formatMessages(msgs));
270
+ await this.#processRedirect();
271
+ break;
272
+ }
273
+ case "lifecycle": {
274
+ // Check for pending shared messages for the facilitator
275
+ const msgs = this.messageBus.drain("facilitator");
276
+ if (msgs.length === 0) break;
277
+ this.facilitatorTurns++;
278
+ await this.facilitatorRunner.resume(this.#formatMessages(msgs));
279
+ await this.#processRedirect();
280
+ break;
281
+ }
282
+ }
283
+
284
+ if (this.ctx.concluded) {
285
+ this.concludeResolve();
286
+ this.eventQueue.close();
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Process a pending redirect after a facilitator turn.
292
+ */
293
+ async #processRedirect() {
294
+ if (!this.ctx.redirect) return;
295
+ const redirect = this.ctx.redirect;
296
+ this.ctx.redirect = null;
297
+
298
+ this.emitOrchestratorEvent({
299
+ type: "redirect",
300
+ to: redirect.to,
301
+ });
302
+
303
+ if (redirect.to === "all") {
304
+ // Abort all agents and deliver redirect via broadcast
305
+ for (const agent of this.agents) {
306
+ agent.runner.currentAbortController?.abort();
307
+ }
308
+ this.messageBus.share("facilitator", redirect.message);
309
+ } else if (redirect.to) {
310
+ // Abort specific agent and deliver via direct message
311
+ const target = this.agents.find((a) => a.name === redirect.to);
312
+ if (target) {
313
+ target.runner.currentAbortController?.abort();
314
+ }
315
+ this.messageBus.tell("facilitator", redirect.to, redirect.message);
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Format messages for an agent prompt.
321
+ * @param {Array<{from: string, text: string, direct: boolean}>} messages
322
+ * @returns {string}
323
+ */
324
+ #formatMessages(messages) {
325
+ return messages
326
+ .map((m) => {
327
+ const tag = m.direct ? "[direct]" : "[shared]";
328
+ return `${tag} ${m.from}: ${m.text}`;
329
+ })
330
+ .join("\n");
331
+ }
332
+
333
+ /**
334
+ * Extract the last assistant text block from a runner's buffer.
335
+ * @param {import("./agent-runner.js").AgentRunner} runner
336
+ * @param {string} fallback
337
+ * @returns {string}
338
+ */
339
+ extractLastText(runner, fallback) {
340
+ const lines = runner.buffer;
341
+ for (let i = lines.length - 1; i >= 0; i--) {
342
+ const event = JSON.parse(lines[i]);
343
+ if (event.type !== "assistant") continue;
344
+ const content = event.message?.content ?? event.content;
345
+ if (!Array.isArray(content)) continue;
346
+ for (let j = content.length - 1; j >= 0; j--) {
347
+ if (content[j].type === "text" && content[j].text) {
348
+ return content[j].text;
349
+ }
350
+ }
351
+ }
352
+ return fallback;
353
+ }
354
+
355
+ /**
356
+ * Emit a single NDJSON line tagged with source and seq.
357
+ * @param {string} source - Participant name
358
+ * @param {string} line - Raw NDJSON line
359
+ */
360
+ emitLine(source, line) {
361
+ const event = JSON.parse(line);
362
+ this.output.write(
363
+ JSON.stringify({
364
+ source,
365
+ seq: this.counter.next(),
366
+ event,
367
+ }) + "\n",
368
+ );
369
+ }
370
+
371
+ /**
372
+ * @param {{type: string}} event
373
+ */
374
+ emitOrchestratorEvent(event) {
375
+ this.output.write(
376
+ JSON.stringify({
377
+ source: "orchestrator",
378
+ seq: this.counter.next(),
379
+ event,
380
+ }) + "\n",
381
+ );
382
+ }
383
+
384
+ /**
385
+ * @param {{success: boolean, turns: number, summary?: string}} result
386
+ */
387
+ emitSummary(result) {
388
+ this.output.write(
389
+ JSON.stringify({
390
+ source: "orchestrator",
391
+ seq: this.counter.next(),
392
+ event: {
393
+ type: "summary",
394
+ success: result.success,
395
+ turns: result.turns,
396
+ ...(result.summary && { summary: result.summary }),
397
+ },
398
+ }) + "\n",
399
+ );
400
+ }
401
+ }
402
+
403
+ const devNull = new Writable({
404
+ write(_chunk, _enc, cb) {
405
+ cb();
406
+ },
407
+ });
408
+
409
+ /**
410
+ * Factory function — wires all participants with MCP servers.
411
+ * @param {object} deps
412
+ * @param {string} deps.facilitatorCwd
413
+ * @param {Array<{name: string, role: string, cwd?: string, maxTurns?: number, allowedTools?: string[], agentProfile?: string}>} deps.agentConfigs
414
+ * @param {function} deps.query
415
+ * @param {import("stream").Writable} deps.output
416
+ * @param {string} [deps.model]
417
+ * @param {number} [deps.maxTurns]
418
+ * @param {string} [deps.facilitatorProfile]
419
+ * @returns {Facilitator}
420
+ */
421
+ export function createFacilitator({
422
+ facilitatorCwd,
423
+ agentConfigs,
424
+ query,
425
+ output,
426
+ model,
427
+ maxTurns,
428
+ facilitatorProfile,
429
+ }) {
430
+ const ctx = createOrchestrationContext();
431
+ const messageBus = createMessageBus({
432
+ participants: ["facilitator", ...agentConfigs.map((a) => a.name)],
433
+ });
434
+ ctx.messageBus = messageBus;
435
+ ctx.participants = [
436
+ { name: "facilitator", role: "facilitator" },
437
+ ...agentConfigs.map((a) => ({ name: a.name, role: a.role })),
438
+ ];
439
+
440
+ let facilitator;
441
+
442
+ const eventQueue = createAsyncQueue();
443
+
444
+ const facilitatorServer = createFacilitatorToolServer(ctx);
445
+
446
+ const agents = agentConfigs.map((config) => {
447
+ const agentServer = createFacilitatedAgentToolServer(ctx, {
448
+ from: config.name,
449
+ onAsk: async (question) => {
450
+ let resolve;
451
+ const promise = new Promise((r) => {
452
+ resolve = r;
453
+ });
454
+ eventQueue.enqueue({
455
+ type: "ask",
456
+ from: config.name,
457
+ question,
458
+ resolve,
459
+ });
460
+ return promise;
461
+ },
462
+ });
463
+
464
+ const runner = createAgentRunner({
465
+ cwd: config.cwd ?? facilitatorCwd,
466
+ query,
467
+ output: devNull,
468
+ model,
469
+ maxTurns: config.maxTurns ?? 50,
470
+ allowedTools: config.allowedTools,
471
+ onLine: (line) => facilitator.emitLine(config.name, line),
472
+ mcpServers: { orchestration: agentServer },
473
+ settingSources: ["project"],
474
+ agentProfile: config.agentProfile,
475
+ systemPrompt: {
476
+ type: "preset",
477
+ preset: "claude_code",
478
+ append: FACILITATED_AGENT_SYSTEM_PROMPT,
479
+ },
480
+ });
481
+
482
+ return { name: config.name, role: config.role, runner };
483
+ });
484
+
485
+ const facilitatorRunner = createAgentRunner({
486
+ cwd: facilitatorCwd,
487
+ query,
488
+ output: devNull,
489
+ model,
490
+ maxTurns: maxTurns ?? 20,
491
+ onLine: (line) => facilitator.emitLine("facilitator", line),
492
+ mcpServers: { orchestration: facilitatorServer },
493
+ settingSources: ["project"],
494
+ agentProfile: facilitatorProfile,
495
+ systemPrompt: {
496
+ type: "preset",
497
+ preset: "claude_code",
498
+ append: FACILITATOR_SYSTEM_PROMPT,
499
+ },
500
+ });
501
+
502
+ facilitator = new Facilitator({
503
+ facilitatorRunner,
504
+ agents,
505
+ messageBus,
506
+ output,
507
+ maxTurns,
508
+ ctx,
509
+ eventQueue,
510
+ });
511
+ return facilitator;
512
+ }
package/src/index.js CHANGED
@@ -1,11 +1,30 @@
1
1
  export { TraceCollector, createTraceCollector } from "./trace-collector.js";
2
+ export { TraceQuery, createTraceQuery } from "./trace-query.js";
3
+ export {
4
+ TraceGitHub,
5
+ createTraceGitHub,
6
+ parseGitRemote,
7
+ } from "./trace-github.js";
2
8
  export { AgentRunner, createAgentRunner } from "./agent-runner.js";
3
9
  export {
4
10
  Supervisor,
5
11
  createSupervisor,
6
12
  SUPERVISOR_SYSTEM_PROMPT,
7
13
  AGENT_SYSTEM_PROMPT,
8
- isComplete,
9
- isIntervention,
10
14
  } from "./supervisor.js";
11
15
  export { TeeWriter, createTeeWriter } from "./tee-writer.js";
16
+ export { SequenceCounter, createSequenceCounter } from "./sequence-counter.js";
17
+ export {
18
+ createOrchestrationContext,
19
+ createSupervisorToolServer,
20
+ createSupervisedAgentToolServer,
21
+ createFacilitatorToolServer,
22
+ createFacilitatedAgentToolServer,
23
+ } from "./orchestration-toolkit.js";
24
+ export { MessageBus, createMessageBus } from "./message-bus.js";
25
+ export {
26
+ Facilitator,
27
+ createFacilitator,
28
+ FACILITATOR_SYSTEM_PROMPT,
29
+ FACILITATED_AGENT_SYSTEM_PROMPT,
30
+ } from "./facilitator.js";
@@ -0,0 +1,100 @@
1
+ /**
2
+ * MessageBus — in-memory per-participant message queues for facilitate mode.
3
+ *
4
+ * Follows OO+DI: constructor injection, factory function, tests bypass factory.
5
+ */
6
+
7
+ export class MessageBus {
8
+ /**
9
+ * @param {object} deps
10
+ * @param {string[]} deps.participants - Participant names
11
+ */
12
+ constructor({ participants }) {
13
+ this.queues = new Map();
14
+ this.waiters = new Map();
15
+ for (const name of participants) {
16
+ this.queues.set(name, []);
17
+ this.waiters.set(name, null);
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Broadcast a message to every participant except the sender.
23
+ * @param {string} from - Sender name
24
+ * @param {string} message - Message text
25
+ */
26
+ share(from, message) {
27
+ this.#assertParticipant(from);
28
+ const msg = { from, text: message, direct: false };
29
+ for (const [name, queue] of this.queues) {
30
+ if (name === from) continue;
31
+ queue.push(msg);
32
+ this.#resolveWaiter(name);
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Send a direct message to one participant.
38
+ * @param {string} from - Sender name
39
+ * @param {string} to - Recipient name
40
+ * @param {string} message - Message text
41
+ */
42
+ tell(from, to, message) {
43
+ this.#assertParticipant(from);
44
+ this.#assertParticipant(to);
45
+ const msg = { from, text: message, direct: true };
46
+ this.queues.get(to).push(msg);
47
+ this.#resolveWaiter(to);
48
+ }
49
+
50
+ /**
51
+ * Return and clear pending messages for a participant.
52
+ * @param {string} participant - Participant name
53
+ * @returns {{ from: string, text: string, direct: boolean }[]}
54
+ */
55
+ drain(participant) {
56
+ this.#assertParticipant(participant);
57
+ const queue = this.queues.get(participant);
58
+ const messages = queue.splice(0);
59
+ return messages;
60
+ }
61
+
62
+ /**
63
+ * Return a Promise that resolves when at least one message is pending.
64
+ * Resolves immediately if messages are already queued.
65
+ * @param {string} participant - Participant name
66
+ * @returns {Promise<void>}
67
+ */
68
+ waitForMessages(participant) {
69
+ this.#assertParticipant(participant);
70
+ if (this.queues.get(participant).length > 0) {
71
+ return Promise.resolve();
72
+ }
73
+ return new Promise((resolve) => {
74
+ this.waiters.set(participant, resolve);
75
+ });
76
+ }
77
+
78
+ #assertParticipant(name) {
79
+ if (!this.queues.has(name)) {
80
+ throw new Error(`Unknown participant: ${name}`);
81
+ }
82
+ }
83
+
84
+ #resolveWaiter(name) {
85
+ const waiter = this.waiters.get(name);
86
+ if (waiter) {
87
+ this.waiters.set(name, null);
88
+ waiter();
89
+ }
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Factory function.
95
+ * @param {object} deps - Same as MessageBus constructor
96
+ * @returns {MessageBus}
97
+ */
98
+ export function createMessageBus(deps) {
99
+ return new MessageBus(deps);
100
+ }