@forwardimpact/libeval 0.1.21 → 0.1.23

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libeval",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "Process Claude Code stream-json output into structured traces",
5
5
  "license": "Apache-2.0",
6
6
  "author": "D. Olsson <hi@senzilla.io>",
@@ -25,15 +25,18 @@
25
25
  "node": ">=18.0.0"
26
26
  },
27
27
  "scripts": {
28
- "test": "bun run node --test test/*.test.js"
28
+ "test": "bun test test/*.test.js"
29
29
  },
30
30
  "dependencies": {
31
- "@anthropic-ai/claude-agent-sdk": "^0.2.112",
31
+ "@anthropic-ai/claude-agent-sdk": "0.2.112",
32
32
  "@forwardimpact/libcli": "^0.1.0",
33
33
  "@forwardimpact/libconfig": "^0.1.0",
34
34
  "@forwardimpact/libtelemetry": "^0.1.22",
35
35
  "zod": "^4.3.6"
36
36
  },
37
+ "devDependencies": {
38
+ "@forwardimpact/libharness": "^0.1.14"
39
+ },
37
40
  "publishConfig": {
38
41
  "access": "public"
39
42
  }
@@ -28,6 +28,7 @@ function applyDefaults(deps) {
28
28
  systemPrompt: deps.systemPrompt ?? null,
29
29
  disallowedTools: deps.disallowedTools ?? [],
30
30
  mcpServers: deps.mcpServers ?? null,
31
+ taskAmend: deps.taskAmend ?? null,
31
32
  };
32
33
  }
33
34
 
@@ -67,9 +68,12 @@ export class AgentRunner {
67
68
  async run(task) {
68
69
  const abortController = new AbortController();
69
70
  this.currentAbortController = abortController;
71
+ const effectiveTask = this.taskAmend
72
+ ? `${task}\n\n${this.taskAmend}`
73
+ : task;
70
74
  try {
71
75
  const iterator = this.query({
72
- prompt: task,
76
+ prompt: effectiveTask,
73
77
  options: {
74
78
  cwd: this.cwd,
75
79
  allowedTools: this.allowedTools,
@@ -106,6 +110,7 @@ export class AgentRunner {
106
110
  prompt,
107
111
  options: {
108
112
  resume: this.sessionId,
113
+ model: this.model,
109
114
  permissionMode: PERMISSION_MODE,
110
115
  allowDangerouslySkipPermissions: true,
111
116
  abortController,
@@ -30,8 +30,7 @@ function parseFacilitateOptions(values) {
30
30
  throw new Error("--task-file or --task-text is required");
31
31
 
32
32
  const taskAmend = values["task-amend"] ?? undefined;
33
- let taskContent = taskFile ? readFileSync(taskFile, "utf8") : taskText;
34
- if (taskAmend) taskContent += `\n\n${taskAmend}`;
33
+ const taskContent = taskFile ? readFileSync(taskFile, "utf8") : taskText;
35
34
 
36
35
  const profilesRaw = values["agent-profiles"];
37
36
  if (!profilesRaw) throw new Error("--agent-profiles is required");
@@ -42,6 +41,7 @@ function parseFacilitateOptions(values) {
42
41
 
43
42
  return {
44
43
  taskContent,
44
+ taskAmend,
45
45
  agentConfigs,
46
46
  facilitatorCwd: resolve(values["facilitator-cwd"] ?? "."),
47
47
  model: values.model ?? "opus",
@@ -82,6 +82,7 @@ export async function runFacilitateCommand(values, _args) {
82
82
  model: opts.model,
83
83
  maxTurns: opts.maxTurns,
84
84
  facilitatorProfile: opts.facilitatorProfile,
85
+ taskAmend: opts.taskAmend,
85
86
  });
86
87
 
87
88
  const result = await facilitator.run(opts.taskContent);
@@ -21,11 +21,11 @@ function parseRunOptions(values) {
21
21
 
22
22
  const maxTurnsRaw = values["max-turns"] ?? "50";
23
23
  const taskAmend = values["task-amend"] ?? undefined;
24
- let taskContent = taskFile ? readFileSync(taskFile, "utf8") : taskText;
25
- if (taskAmend) taskContent += `\n\n${taskAmend}`;
24
+ const taskContent = taskFile ? readFileSync(taskFile, "utf8") : taskText;
26
25
 
27
26
  return {
28
27
  taskContent,
28
+ taskAmend,
29
29
  cwd: resolve(values.cwd ?? "."),
30
30
  model: values.model ?? "opus",
31
31
  maxTurns: maxTurnsRaw === "0" ? 0 : parseInt(maxTurnsRaw, 10),
@@ -49,6 +49,7 @@ function parseRunOptions(values) {
49
49
  export async function runRunCommand(values, _args) {
50
50
  const {
51
51
  taskContent,
52
+ taskAmend,
52
53
  cwd,
53
54
  model,
54
55
  maxTurns,
@@ -94,6 +95,7 @@ export async function runRunCommand(values, _args) {
94
95
  onLine,
95
96
  settingSources: ["project"],
96
97
  systemPrompt,
98
+ taskAmend,
97
99
  });
98
100
 
99
101
  const result = await runner.run(taskContent);
@@ -20,11 +20,11 @@ function parseSuperviseOptions(values) {
20
20
  const supervisorAllowedToolsRaw = values["supervisor-allowed-tools"];
21
21
 
22
22
  const taskAmend = values["task-amend"] ?? undefined;
23
- let taskContent = taskFile ? readFileSync(taskFile, "utf8") : taskText;
24
- if (taskAmend) taskContent += `\n\n${taskAmend}`;
23
+ const taskContent = taskFile ? readFileSync(taskFile, "utf8") : taskText;
25
24
 
26
25
  return {
27
26
  taskContent,
27
+ taskAmend,
28
28
  supervisorCwd: resolve(values["supervisor-cwd"] ?? "."),
29
29
  agentCwd: resolve(
30
30
  values["agent-cwd"] ?? mkdtempSync(join(tmpdir(), "fit-eval-agent-")),
@@ -83,6 +83,7 @@ export async function runSuperviseCommand(values, _args) {
83
83
  supervisorAllowedTools: opts.supervisorAllowedTools,
84
84
  supervisorProfile: opts.supervisorProfile,
85
85
  agentProfile: opts.agentProfile,
86
+ taskAmend: opts.taskAmend,
86
87
  });
87
88
 
88
89
  const result = await supervisor.run(opts.taskContent);
@@ -1,9 +1,8 @@
1
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.
2
+ * Facilitator — N agent sessions + one facilitator LLM session. The Ask/Answer
3
+ * contract is enforced at turn boundaries via checkPendingAsk: one synthetic
4
+ * reminder, then a `protocol_violation` event plus a null-answer injection so
5
+ * the session advances instead of deadlocking.
7
6
  */
8
7
 
9
8
  import { Writable } from "node:stream";
@@ -16,62 +15,26 @@ import {
16
15
  createOrchestrationContext,
17
16
  createFacilitatorToolServer,
18
17
  createFacilitatedAgentToolServer,
18
+ checkPendingAsk,
19
19
  } from "./orchestration-toolkit.js";
20
+ import { createAsyncQueue, formatMessages } from "./orchestrator-helpers.js";
20
21
 
21
22
  /** System prompt appended for the facilitator runner. */
22
23
  export const FACILITATOR_SYSTEM_PROMPT =
23
- "You coordinate multiple agents working on a shared task. " +
24
- "Tell sends a direct message to one participant. " +
25
- "Share broadcasts a message to all participants. " +
26
- "Redirect interrupts a participant and replaces their current instructions. " +
27
- "RollCall lists available participants and their roles. " +
28
- "Conclude ends the session with a summary. " +
29
- "Participants communicate with you via Share and may Ask you questions. " +
30
- "IMPORTANT: After sending messages via Tell or Share, stop making tool " +
31
- "calls and produce a text response. The system will resume you with " +
32
- "participant responses. Do not proceed to the next question or call " +
33
- "Conclude until you have received responses from participants.";
24
+ "You coordinate multiple participants. " +
25
+ "Ask sends a question to a participant; omit the addressee to broadcast. " +
26
+ "Announce sends a message with no reply obligation. " +
27
+ "Redirect interrupts a participant with replacement instructions. " +
28
+ "RollCall lists participants. " +
29
+ "Conclude ends the session with a summary.";
34
30
 
35
31
  /** System prompt appended for facilitated agent runners. */
36
32
  export const FACILITATED_AGENT_SYSTEM_PROMPT =
37
- "You are one of several agents working on a shared task under a " +
38
- "facilitator's coordination. " +
39
- "Share broadcasts your message to all participants. " +
40
- "Tell sends a direct message to one participant. " +
41
- "Ask sends a question to the facilitator — you block until answered. " +
42
- "RollCall lists available participants and their roles. " +
43
- "The facilitator may Redirect you with new instructions " +
44
- "— treat redirections as authoritative.";
45
-
46
- function createAsyncQueue() {
47
- const items = [];
48
- let waiter = null;
49
- let closed = false;
50
- return {
51
- enqueue(item) {
52
- items.push(item);
53
- if (waiter) {
54
- waiter();
55
- waiter = null;
56
- }
57
- },
58
- async dequeue() {
59
- if (items.length > 0) return items.shift();
60
- if (closed) return null;
61
- await new Promise((resolve) => {
62
- waiter = resolve;
63
- });
64
- return items.length > 0 ? items.shift() : null;
65
- },
66
- close() {
67
- closed = true;
68
- if (waiter) {
69
- waiter();
70
- waiter = null;
71
- }
72
- },
73
- };
74
- }
33
+ "You participate in a coordinated session. " +
34
+ "Answer replies to an ask addressed to you. " +
35
+ "Ask sends a question to another participant. " +
36
+ "Announce broadcasts a message. " +
37
+ "RollCall lists participants.";
75
38
 
76
39
  export class Facilitator {
77
40
  /**
@@ -83,6 +46,7 @@ export class Facilitator {
83
46
  * @param {number} [deps.maxTurns]
84
47
  * @param {object} [deps.ctx]
85
48
  * @param {object} [deps.eventQueue]
49
+ * @param {string} [deps.taskAmend] - Opaque addendum appended to the task before delivery.
86
50
  */
87
51
  constructor({
88
52
  facilitatorRunner,
@@ -92,6 +56,7 @@ export class Facilitator {
92
56
  maxTurns,
93
57
  ctx,
94
58
  eventQueue,
59
+ taskAmend,
95
60
  }) {
96
61
  this.facilitatorRunner = facilitatorRunner;
97
62
  this.agents = agents;
@@ -102,6 +67,7 @@ export class Facilitator {
102
67
  this.counter = new SequenceCounter();
103
68
  this.eventQueue = eventQueue ?? createAsyncQueue();
104
69
  this.facilitatorTurns = 0;
70
+ this.taskAmend = taskAmend ?? null;
105
71
 
106
72
  let resolve;
107
73
  const promise = new Promise((r) => {
@@ -119,21 +85,23 @@ export class Facilitator {
119
85
  async run(task) {
120
86
  this.emitOrchestratorEvent({ type: "session_start" });
121
87
 
88
+ const initialTask = this.taskAmend ? `${task}\n\n${this.taskAmend}` : task;
89
+
122
90
  // Launch agent loops first — they wait for messages via messageBus.
123
- // This lets agents process Tell/Share messages that arrive during the
124
- // facilitator's initial run, rather than after it completes.
91
+ // This lets agents process Ask/Announce messages that arrive during
92
+ // the facilitator's initial run, rather than after it completes.
125
93
  const agentPromises = this.agents.map((a) => this.#runAgent(a));
126
94
 
127
95
  // Turn 0: facilitator receives the task
128
96
  this.facilitatorTurns++;
129
- await this.facilitatorRunner.run(task);
97
+ await this.facilitatorRunner.run(initialTask);
130
98
 
131
99
  // Handle redirect after turn 0
132
100
  await this.#processRedirect();
133
101
 
134
102
  if (this.ctx.concluded) {
135
103
  // Facilitator concluded during its initial run. Let agents finish any
136
- // in-progress work before returning — they may have received Tell/Share
104
+ // in-progress work before returning — they may have received Ask/Answer
137
105
  // messages and started processing concurrently.
138
106
  this.concludeResolve();
139
107
  await Promise.allSettled(agentPromises);
@@ -177,6 +145,26 @@ export class Facilitator {
177
145
  return result;
178
146
  }
179
147
 
148
+ #checkAsk(name) {
149
+ return checkPendingAsk({
150
+ ctx: this.ctx,
151
+ messageBus: this.messageBus,
152
+ addresseeName: name,
153
+ mode: "facilitated",
154
+ emitViolation: (e) => this.emitOrchestratorEvent(e),
155
+ });
156
+ }
157
+
158
+ async #enforcePendingAsk(agent) {
159
+ if (this.#checkAsk(agent.name) !== "recheck") return;
160
+ if (this.ctx.concluded) return;
161
+ const reminders = this.messageBus.drain(agent.name);
162
+ if (reminders.length === 0) return;
163
+ await agent.runner.resume(formatMessages(reminders));
164
+ if (this.ctx.concluded) return;
165
+ this.#checkAsk(agent.name);
166
+ }
167
+
180
168
  /**
181
169
  * Agent outer loop — waits for messages, runs/resumes the agent.
182
170
  * @param {{name: string, role: string, runner: import("./agent-runner.js").AgentRunner}} agent
@@ -196,7 +184,9 @@ export class Facilitator {
196
184
  type: "agent_start",
197
185
  agent: agent.name,
198
186
  });
199
- await agent.runner.run(this.#formatMessages(messages));
187
+ await agent.runner.run(formatMessages(messages));
188
+ if (this.ctx.concluded) return;
189
+ await this.#enforcePendingAsk(agent);
200
190
  if (this.ctx.concluded) return;
201
191
  this.eventQueue.enqueue({
202
192
  type: "lifecycle",
@@ -216,7 +206,9 @@ export class Facilitator {
216
206
  messages = this.messageBus.drain(agent.name);
217
207
  if (messages.length === 0) break;
218
208
  }
219
- await agent.runner.resume(this.#formatMessages(messages));
209
+ await agent.runner.resume(formatMessages(messages));
210
+ if (this.ctx.concluded) break;
211
+ await this.#enforcePendingAsk(agent);
220
212
  if (this.ctx.concluded) break;
221
213
  this.eventQueue.enqueue({
222
214
  type: "lifecycle",
@@ -239,46 +231,14 @@ export class Facilitator {
239
231
 
240
232
  async #handleEvent(event) {
241
233
  switch (event.type) {
242
- case "ask": {
243
- if (this.ctx.concluded) {
244
- event.resolve("Session has concluded.");
245
- break;
246
- }
247
- this.facilitatorTurns++;
248
- this.emitOrchestratorEvent({
249
- type: "ask_received",
250
- from: event.from,
251
- });
252
- await this.facilitatorRunner.resume(
253
- `Agent "${event.from}" asks: "${event.question}"\nAnswer the question.`,
254
- );
255
- const answer = this.extractLastText(
256
- this.facilitatorRunner,
257
- "No answer.",
258
- );
259
- this.emitOrchestratorEvent({
260
- type: "ask_answered",
261
- from: event.from,
262
- });
263
- event.resolve(answer);
264
- await this.#processRedirect();
265
- break;
266
- }
267
- case "messages": {
268
- const msgs = this.messageBus.drain("facilitator");
269
- if (msgs.length === 0) break;
270
- this.facilitatorTurns++;
271
- await this.facilitatorRunner.resume(this.#formatMessages(msgs));
272
- await this.#processRedirect();
273
- break;
274
- }
234
+ case "messages":
275
235
  case "lifecycle": {
276
- // Check for pending shared messages for the facilitator
277
236
  const msgs = this.messageBus.drain("facilitator");
278
237
  if (msgs.length === 0) break;
279
238
  this.facilitatorTurns++;
280
- await this.facilitatorRunner.resume(this.#formatMessages(msgs));
239
+ await this.facilitatorRunner.resume(formatMessages(msgs));
281
240
  await this.#processRedirect();
241
+ if (!this.ctx.concluded) await this.#enforceFacilitatorPendingAsk();
282
242
  break;
283
243
  }
284
244
  }
@@ -289,6 +249,18 @@ export class Facilitator {
289
249
  }
290
250
  }
291
251
 
252
+ async #enforceFacilitatorPendingAsk() {
253
+ if (this.#checkAsk("facilitator") !== "recheck") return;
254
+ if (this.ctx.concluded) return;
255
+ const reminders = this.messageBus.drain("facilitator");
256
+ if (reminders.length === 0) return;
257
+ this.facilitatorTurns++;
258
+ await this.facilitatorRunner.resume(formatMessages(reminders));
259
+ await this.#processRedirect();
260
+ if (this.ctx.concluded) return;
261
+ this.#checkAsk("facilitator");
262
+ }
263
+
292
264
  /**
293
265
  * Process a pending redirect after a facilitator turn.
294
266
  */
@@ -307,37 +279,17 @@ export class Facilitator {
307
279
  for (const agent of this.agents) {
308
280
  agent.runner.currentAbortController?.abort();
309
281
  }
310
- this.messageBus.share("facilitator", redirect.message);
282
+ this.messageBus.announce("facilitator", redirect.message);
311
283
  } else if (redirect.to) {
312
284
  // Abort specific agent and deliver via direct message
313
285
  const target = this.agents.find((a) => a.name === redirect.to);
314
286
  if (target) {
315
287
  target.runner.currentAbortController?.abort();
316
288
  }
317
- this.messageBus.tell("facilitator", redirect.to, redirect.message);
289
+ this.messageBus.direct("facilitator", redirect.to, redirect.message);
318
290
  }
319
291
  }
320
292
 
321
- /**
322
- * Format messages for an agent prompt.
323
- * @param {Array<{from: string, text: string, direct: boolean}>} messages
324
- * @returns {string}
325
- */
326
- #formatMessages(messages) {
327
- return messages
328
- .map((m) => {
329
- const tag = m.direct ? "[direct]" : "[shared]";
330
- return `${tag} ${m.from}: ${m.text}`;
331
- })
332
- .join("\n");
333
- }
334
-
335
- /**
336
- * Extract the last assistant text block from a runner's buffer.
337
- * @param {import("./agent-runner.js").AgentRunner} runner
338
- * @param {string} fallback
339
- * @returns {string}
340
- */
341
293
  extractLastText(runner, fallback) {
342
294
  const lines = runner.buffer;
343
295
  for (let i = lines.length - 1; i >= 0; i--) {
@@ -412,13 +364,14 @@ const devNull = new Writable({
412
364
  * Factory function — wires all participants with MCP servers.
413
365
  * @param {object} deps
414
366
  * @param {string} deps.facilitatorCwd
415
- * @param {Array<{name: string, role: string, cwd?: string, maxTurns?: number, allowedTools?: string[], agentProfile?: string}>} deps.agentConfigs
367
+ * @param {Array<{name: string, role: string, cwd?: string, maxTurns?: number, allowedTools?: string[], agentProfile?: string, systemPromptAmend?: string}>} deps.agentConfigs
416
368
  * @param {function} deps.query
417
369
  * @param {import("stream").Writable} deps.output
418
370
  * @param {string} [deps.model]
419
371
  * @param {number} [deps.maxTurns]
420
372
  * @param {string} [deps.facilitatorProfile] - Facilitator profile name; resolved into the main-thread system prompt via `composeProfilePrompt`.
421
373
  * @param {string} [deps.profilesDir] - Directory containing `<name>.md` profile files. Defaults to `<facilitatorCwd>/.claude/agents`. Resolved once from the facilitator's cwd so profiles travel with the project, not with per-agent sandboxes.
374
+ * @param {string} [deps.taskAmend] - Opaque addendum appended to the task before delivery.
422
375
  * @returns {Facilitator}
423
376
  */
424
377
  export function createFacilitator({
@@ -430,6 +383,7 @@ export function createFacilitator({
430
383
  maxTurns,
431
384
  facilitatorProfile,
432
385
  profilesDir,
386
+ taskAmend,
433
387
  }) {
434
388
  const resolvedProfilesDir =
435
389
  profilesDir ?? resolve(facilitatorCwd, ".claude/agents");
@@ -461,21 +415,12 @@ export function createFacilitator({
461
415
  const agents = agentConfigs.map((config) => {
462
416
  const agentServer = createFacilitatedAgentToolServer(ctx, {
463
417
  from: config.name,
464
- onAsk: async (question) => {
465
- let resolve;
466
- const promise = new Promise((r) => {
467
- resolve = r;
468
- });
469
- eventQueue.enqueue({
470
- type: "ask",
471
- from: config.name,
472
- question,
473
- resolve,
474
- });
475
- return promise;
476
- },
477
418
  });
478
419
 
420
+ const agentTrailer = config.systemPromptAmend
421
+ ? `${FACILITATED_AGENT_SYSTEM_PROMPT}\n\n${config.systemPromptAmend}`
422
+ : FACILITATED_AGENT_SYSTEM_PROMPT;
423
+
479
424
  const runner = createAgentRunner({
480
425
  cwd: config.cwd ?? facilitatorCwd,
481
426
  query,
@@ -486,10 +431,7 @@ export function createFacilitator({
486
431
  onLine: (line) => facilitator.emitLine(config.name, line),
487
432
  mcpServers: { orchestration: agentServer },
488
433
  settingSources: ["project"],
489
- systemPrompt: systemPromptFor(
490
- config.agentProfile,
491
- FACILITATED_AGENT_SYSTEM_PROMPT,
492
- ),
434
+ systemPrompt: systemPromptFor(config.agentProfile, agentTrailer),
493
435
  });
494
436
 
495
437
  return { name: config.name, role: config.role, runner };
@@ -518,6 +460,7 @@ export function createFacilitator({
518
460
  maxTurns,
519
461
  ctx,
520
462
  eventQueue,
463
+ taskAmend,
521
464
  });
522
465
  return facilitator;
523
466
  }
@@ -1,5 +1,13 @@
1
1
  /**
2
- * MessageBus — in-memory per-participant message queues for facilitate mode.
2
+ * MessageBus — in-memory per-participant message queues for facilitated and
3
+ * supervised modes. The message vocabulary mirrors the orchestration toolkit:
4
+ *
5
+ * - ask(from, to, text, askId) — direct question; registers nothing
6
+ * itself (the handler's caller owns pending-ask state). `to === "@broadcast"`
7
+ * sends an identical entry to every participant except the sender.
8
+ * - answer(from, to, text, askId) — direct reply to the asker.
9
+ * - announce(from, text) — broadcast, no reply expected.
10
+ * - synthetic(to, text) — orchestrator-only reminder injection.
3
11
  *
4
12
  * Follows OO+DI: constructor injection, factory function, tests bypass factory.
5
13
  */
@@ -18,14 +26,55 @@ export class MessageBus {
18
26
  }
19
27
  }
20
28
 
29
+ /**
30
+ * Send a question to a participant (direct), or broadcast when
31
+ * `to === "@broadcast"`.
32
+ * @param {string} from
33
+ * @param {string} to - Recipient name or "@broadcast"
34
+ * @param {string} text
35
+ * @param {number} askId
36
+ */
37
+ ask(from, to, text, askId) {
38
+ this.#assertParticipant(from);
39
+ if (to === "@broadcast") {
40
+ for (const [name, queue] of this.queues) {
41
+ if (name === from) continue;
42
+ queue.push({ from, text, kind: "ask", askId, direct: false });
43
+ this.#resolveWaiter(name);
44
+ }
45
+ return;
46
+ }
47
+ this.#assertParticipant(to);
48
+ this.queues.get(to).push({ from, text, kind: "ask", askId, direct: true });
49
+ this.#resolveWaiter(to);
50
+ }
51
+
52
+ /**
53
+ * Reply to a pending ask.
54
+ * @param {string} from - Answerer (or "@orchestrator" for a synthetic answer)
55
+ * @param {string} to - Original asker
56
+ * @param {string} text
57
+ * @param {number} askId
58
+ */
59
+ answer(from, to, text, askId) {
60
+ this.#assertParticipant(to);
61
+ // Synthetic answers from the orchestrator bypass the participant check
62
+ // on `from` — the orchestrator is not a message-bus participant.
63
+ if (from !== "@orchestrator") this.#assertParticipant(from);
64
+ this.queues
65
+ .get(to)
66
+ .push({ from, text, kind: "answer", askId, direct: true });
67
+ this.#resolveWaiter(to);
68
+ }
69
+
21
70
  /**
22
71
  * Broadcast a message to every participant except the sender.
23
- * @param {string} from - Sender name
24
- * @param {string} message - Message text
72
+ * @param {string} from
73
+ * @param {string} text
25
74
  */
26
- share(from, message) {
75
+ announce(from, text) {
27
76
  this.#assertParticipant(from);
28
- const msg = { from, text: message, direct: false };
77
+ const msg = { from, text, kind: "announce", direct: false };
29
78
  for (const [name, queue] of this.queues) {
30
79
  if (name === from) continue;
31
80
  queue.push(msg);
@@ -34,23 +83,39 @@ export class MessageBus {
34
83
  }
35
84
 
36
85
  /**
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
86
+ * Send a direct message with no reply expected. Used by the Redirect
87
+ * runtime plumbing (facilitator / supervisor) to deliver replacement
88
+ * instructions to a single participant without engaging the ask/answer
89
+ * contract.
90
+ * @param {string} from
91
+ * @param {string} to
92
+ * @param {string} text
41
93
  */
42
- tell(from, to, message) {
94
+ direct(from, to, text) {
43
95
  this.#assertParticipant(from);
44
96
  this.#assertParticipant(to);
45
- const msg = { from, text: message, direct: true };
46
- this.queues.get(to).push(msg);
97
+ this.queues.get(to).push({ from, text, kind: "direct", direct: true });
98
+ this.#resolveWaiter(to);
99
+ }
100
+
101
+ /**
102
+ * Inject an orchestrator-originated reminder onto a single participant's
103
+ * queue. Used by the turn-complete guard.
104
+ * @param {string} to
105
+ * @param {string} text
106
+ */
107
+ synthetic(to, text) {
108
+ this.#assertParticipant(to);
109
+ this.queues
110
+ .get(to)
111
+ .push({ from: "@orchestrator", text, kind: "synthetic", direct: true });
47
112
  this.#resolveWaiter(to);
48
113
  }
49
114
 
50
115
  /**
51
116
  * Return and clear pending messages for a participant.
52
117
  * @param {string} participant - Participant name
53
- * @returns {{ from: string, text: string, direct: boolean }[]}
118
+ * @returns {{from: string, text: string, kind: string, direct: boolean, askId?: number}[]}
54
119
  */
55
120
  drain(participant) {
56
121
  this.#assertParticipant(participant);