@forwardimpact/libeval 0.1.13 → 0.1.15

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