@forwardimpact/libharness 0.1.22 → 1.0.0
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/LICENSE +21 -201
- package/README.md +196 -80
- package/bin/fit-benchmark.js +44 -0
- package/bin/fit-harness.js +358 -0
- package/bin/fit-selfedit.js +165 -0
- package/bin/fit-trace.js +510 -0
- package/package.json +41 -11
- package/src/agent-runner.js +256 -0
- package/src/benchmark/apm-installer.js +207 -0
- package/src/benchmark/env-loader.js +158 -0
- package/src/benchmark/hook-env.js +40 -0
- package/src/benchmark/invariants.js +141 -0
- package/src/benchmark/judge.js +187 -0
- package/src/benchmark/npm-installer.js +87 -0
- package/src/benchmark/report.js +522 -0
- package/src/benchmark/result.js +127 -0
- package/src/benchmark/runner.js +583 -0
- package/src/benchmark/task-family.js +260 -0
- package/src/benchmark/workdir.js +298 -0
- package/src/commands/assert.js +153 -0
- package/src/commands/benchmark-definition.js +165 -0
- package/src/commands/benchmark-invariants.js +73 -0
- package/src/commands/benchmark-report.js +51 -0
- package/src/commands/benchmark-run.js +111 -0
- package/src/commands/by-discussion.js +94 -0
- package/src/commands/callback.js +119 -0
- package/src/commands/discuss.js +132 -0
- package/src/commands/facilitate.js +123 -0
- package/src/commands/output.js +36 -0
- package/src/commands/run.js +152 -0
- package/src/commands/supervise.js +136 -0
- package/src/commands/task-input.js +54 -0
- package/src/commands/tee.js +53 -0
- package/src/commands/trace.js +630 -0
- package/src/commands/work-tracker.js +35 -0
- package/src/cost.js +79 -0
- package/src/discuss-tools.js +173 -0
- package/src/discusser.js +394 -0
- package/src/events/github.js +161 -0
- package/src/facilitator.js +205 -0
- package/src/inbox-poller.js +81 -0
- package/src/index.js +72 -2
- package/src/judge.js +210 -0
- package/src/message-bus.js +118 -0
- package/src/orchestration-loop.js +330 -0
- package/src/orchestration-toolkit.js +441 -0
- package/src/orchestrator-helpers.js +23 -0
- package/src/profile-prompt.js +266 -0
- package/src/redaction.js +253 -0
- package/src/render/line-renderer.js +54 -0
- package/src/render/orchestrator-filter.js +19 -0
- package/src/render/palette.js +63 -0
- package/src/render/tool-hints.js +154 -0
- package/src/render/turn-renderer.js +96 -0
- package/src/reply-emitter.js +47 -0
- package/src/sequence-counter.js +21 -0
- package/src/signature-filter.js +27 -0
- package/src/supervisor.js +236 -0
- package/src/tee-writer.js +150 -0
- package/src/trace-collector.js +444 -0
- package/src/trace-github.js +473 -0
- package/src/trace-multi.js +101 -0
- package/src/trace-query.js +748 -0
- package/src/trace-render.js +211 -0
- package/src/trace-usage.js +249 -0
- package/src/fixture/assertions.js +0 -42
- package/src/fixture/cache.js +0 -50
- package/src/fixture/eval.js +0 -146
- package/src/fixture/index.js +0 -9
- package/src/fixture/pathway.js +0 -451
- package/src/fixture/services.js +0 -56
- package/src/mock/clients.js +0 -135
- package/src/mock/config.js +0 -45
- package/src/mock/data.js +0 -46
- package/src/mock/fs.js +0 -111
- package/src/mock/grpc.js +0 -94
- package/src/mock/http.js +0 -60
- package/src/mock/index.js +0 -36
- package/src/mock/infra.js +0 -219
- package/src/mock/logger.js +0 -42
- package/src/mock/observer.js +0 -74
- package/src/mock/resource-index.js +0 -95
- package/src/mock/service-callbacks.js +0 -39
- package/src/mock/services.js +0 -79
- package/src/mock/spy.js +0 -44
- package/src/mock/storage.js +0 -118
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OrchestrationToolkit — tool schemas, per-role tool sets, and handler
|
|
3
|
+
* factories for orchestration between leads (facilitator, supervisor,
|
|
4
|
+
* discuss-lead) and their participating agents.
|
|
5
|
+
*
|
|
6
|
+
* **Tool surface, by role:**
|
|
7
|
+
*
|
|
8
|
+
* | | Ask | Answer | Announce | RollCall | Conclude | …extras |
|
|
9
|
+
* |-------------|-----|--------|----------|----------|----------|-----------------------|
|
|
10
|
+
* | Facilitator | ✓ | ✓ | ✓ | ✓ | ✓ | |
|
|
11
|
+
* | Fac. agent | ✓ | ✓ | ✓ | ✓ | | RFC |
|
|
12
|
+
* | Supervisor | ✓ | ✓ | ✓ | ✓ | ✓ | |
|
|
13
|
+
* | Sup. agent | ✓ | ✓ | ✓ | ✓ | | |
|
|
14
|
+
* | Discuss lead| ✓ | ✓ | ✓ | ✓ | | Recess / Adjourn |
|
|
15
|
+
* | Discuss agt | ✓ | ✓ | ✓ | ✓ | | RFC |
|
|
16
|
+
* | Judge | | | | | ✓ | |
|
|
17
|
+
*
|
|
18
|
+
* **Ask is async.** Ask returns `{askIds:[…]}` immediately and posts the
|
|
19
|
+
* question to the addressee's bus queue. The reply arrives on the asker's
|
|
20
|
+
* next turn as `[answer#N] <participant>: <text>`. Pending state keys by
|
|
21
|
+
* `askId` (visible in `[ask#N]` tags), so duplicate Asks to the same
|
|
22
|
+
* addressee coexist without overwriting.
|
|
23
|
+
*
|
|
24
|
+
* **Answer's `askId` is optional.** With a matching askId, the reply
|
|
25
|
+
* routes to that specific asker. Without, the handler auto-picks if
|
|
26
|
+
* exactly one ask is owed to the caller, otherwise routes the message
|
|
27
|
+
* as an Announce so it still reaches everyone.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
|
|
33
|
+
/** Create a fresh orchestration context object. */
|
|
34
|
+
export function createOrchestrationContext() {
|
|
35
|
+
return {
|
|
36
|
+
concluded: false,
|
|
37
|
+
verdict: null,
|
|
38
|
+
summary: null,
|
|
39
|
+
participants: [],
|
|
40
|
+
messageBus: null,
|
|
41
|
+
// Map<askId, {askId, askerName, addresseeName, reminded}>.
|
|
42
|
+
pendingAsks: new Map(),
|
|
43
|
+
askIdCounter: 0,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --- Handler factories ---
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Guard for terminal tools (`Conclude`, `Adjourn`, `Recess`). Returns an
|
|
51
|
+
* error result when the caller still has Asks in flight, telling them to
|
|
52
|
+
* end the turn and wait for the auto-resume. Returns `null` when no Asks
|
|
53
|
+
* are pending and the terminal tool is free to run.
|
|
54
|
+
*/
|
|
55
|
+
export function requireNoPendingAsks(ctx) {
|
|
56
|
+
if (ctx.pendingAsks.size === 0) return null;
|
|
57
|
+
return errorResult(
|
|
58
|
+
"Asks are still pending. End your turn. You will be resumed when answers arrive.",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Guard for terminal tools in discuss mode (`Adjourn`, `Recess`). Returns
|
|
64
|
+
* an error result when the lead's inbox has unprocessed messages from the
|
|
65
|
+
* human, telling them to end the turn and wait for the auto-resume.
|
|
66
|
+
* Returns `null` when no inbox messages are pending and the terminal tool
|
|
67
|
+
* is free to run.
|
|
68
|
+
*/
|
|
69
|
+
export function requireNoUnprocessedInbox(ctx) {
|
|
70
|
+
if (!ctx.messageBus?.hasPending?.("lead")) return null;
|
|
71
|
+
return errorResult(
|
|
72
|
+
"New messages from the human are waiting. End your turn. You will be resumed to process them.",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Mark the session as concluded; cancel any open Asks so askers see the synthetic null on their next turn. */
|
|
77
|
+
export function createConcludeHandler(ctx) {
|
|
78
|
+
return async ({ verdict, summary }) => {
|
|
79
|
+
const guard = requireNoPendingAsks(ctx);
|
|
80
|
+
if (guard) return guard;
|
|
81
|
+
concludeSession(ctx, { verdict, summary, reason: "session concluded" });
|
|
82
|
+
return { content: [{ type: "text", text: "Session concluded." }] };
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Shared terminal-tool helper. Conclude / Adjourn / Recess all set the
|
|
88
|
+
* same three context fields (`concluded`, `verdict`, `summary`) and
|
|
89
|
+
* cancel any in-flight Asks for the same reason: nobody will ever
|
|
90
|
+
* answer them now. Mode-specific handlers (Adjourn, Recess) layer
|
|
91
|
+
* extra state on top before calling this.
|
|
92
|
+
*/
|
|
93
|
+
export function concludeSession(ctx, { verdict, summary, reason }) {
|
|
94
|
+
ctx.concluded = true;
|
|
95
|
+
ctx.verdict = verdict;
|
|
96
|
+
ctx.summary = summary;
|
|
97
|
+
cancelPendingAsks(ctx, reason);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Return the list of participants and their roles. */
|
|
101
|
+
export function createRollCallHandler(ctx) {
|
|
102
|
+
return async () => ({
|
|
103
|
+
content: [{ type: "text", text: JSON.stringify(ctx.participants) }],
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function resolveAddressees(ctx, { from, to, defaultTo }) {
|
|
108
|
+
const explicitTo = typeof to === "string" && to.length > 0 ? to : null;
|
|
109
|
+
const effectiveTo = explicitTo ?? defaultTo ?? null;
|
|
110
|
+
if (effectiveTo) return [effectiveTo];
|
|
111
|
+
return ctx.participants.map((p) => p.name).filter((n) => n !== from);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function registerPendingAsk(ctx, { from, addressee, question }) {
|
|
115
|
+
const askId = ++ctx.askIdCounter;
|
|
116
|
+
ctx.pendingAsks.set(askId, {
|
|
117
|
+
askId,
|
|
118
|
+
askerName: from,
|
|
119
|
+
addresseeName: addressee,
|
|
120
|
+
reminded: false,
|
|
121
|
+
});
|
|
122
|
+
ctx.messageBus.ask(from, addressee, question, askId);
|
|
123
|
+
return askId;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Create an Ask handler. Registers a pending entry per addressee, posts
|
|
128
|
+
* the ask on the bus, returns `{askIds:[…]}` immediately. The LLM uses
|
|
129
|
+
* those ids to match the `[answer#N]` it sees on a later turn.
|
|
130
|
+
*
|
|
131
|
+
* @param {object} ctx
|
|
132
|
+
* @param {object} opts
|
|
133
|
+
* @param {string} opts.from
|
|
134
|
+
* @param {string|undefined} opts.defaultTo - `undefined` means "broadcast
|
|
135
|
+
* to everyone else"; a participant name means "target that one when
|
|
136
|
+
* `to` is omitted."
|
|
137
|
+
*/
|
|
138
|
+
export function createAskHandler(ctx, { from, defaultTo }) {
|
|
139
|
+
return async ({ question, to }) => {
|
|
140
|
+
if (ctx.concluded) {
|
|
141
|
+
return errorResult("Session is concluded; Ask was not delivered.");
|
|
142
|
+
}
|
|
143
|
+
const addressees = resolveAddressees(ctx, { from, to, defaultTo });
|
|
144
|
+
if (addressees.length === 0) {
|
|
145
|
+
return errorResult("No addressee for Ask.");
|
|
146
|
+
}
|
|
147
|
+
const askIds = addressees.map((addressee) =>
|
|
148
|
+
registerPendingAsk(ctx, { from, addressee, question }),
|
|
149
|
+
);
|
|
150
|
+
return jsonResult({ askIds });
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Create an Answer handler with optional askId.
|
|
156
|
+
*
|
|
157
|
+
* - askId provided + matches a pending entry whose addressee is the caller →
|
|
158
|
+
* route the reply to the asker's queue and clear the pending entry.
|
|
159
|
+
* - askId provided but unknown or wrong addressee → `isError`. The caller
|
|
160
|
+
* tried to specify; we tell them why it didn't match.
|
|
161
|
+
* - askId omitted + exactly one ask owed by the caller → auto-pick it.
|
|
162
|
+
* - askId omitted + 0 or many pending → broadcast as Announce so the
|
|
163
|
+
* message still reaches every other participant.
|
|
164
|
+
*/
|
|
165
|
+
export function createAnswerHandler(ctx, { from }) {
|
|
166
|
+
return async ({ askId, message }) => {
|
|
167
|
+
if (typeof askId === "number") {
|
|
168
|
+
return routeAnswerByAskId(ctx, { from, askId, message });
|
|
169
|
+
}
|
|
170
|
+
const owed = [...ctx.pendingAsks.values()].filter(
|
|
171
|
+
(e) => e.addresseeName === from,
|
|
172
|
+
);
|
|
173
|
+
if (owed.length === 1) {
|
|
174
|
+
return routeAnswerByAskId(ctx, {
|
|
175
|
+
from,
|
|
176
|
+
askId: owed[0].askId,
|
|
177
|
+
message,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
ctx.messageBus.announce(from, message);
|
|
181
|
+
const reason =
|
|
182
|
+
owed.length === 0
|
|
183
|
+
? "no pending ask for you"
|
|
184
|
+
: `${owed.length} pending asks (askId omitted is ambiguous)`;
|
|
185
|
+
return textResult(`Answer routed as Announce — ${reason}.`);
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function routeAnswerByAskId(ctx, { from, askId, message }) {
|
|
190
|
+
const entry = ctx.pendingAsks.get(askId);
|
|
191
|
+
if (!entry) return errorResult(`No pending ask with askId=${askId}.`);
|
|
192
|
+
if (entry.addresseeName !== from) {
|
|
193
|
+
return errorResult(
|
|
194
|
+
`Ask #${askId} is addressed to ${entry.addresseeName}, not ${from}.`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
ctx.pendingAsks.delete(askId);
|
|
198
|
+
ctx.messageBus.answer(from, entry.askerName, message, askId);
|
|
199
|
+
return textResult("Answer delivered.");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Broadcast a message to every participant except the sender. */
|
|
203
|
+
export function createAnnounceHandler(ctx, { from }) {
|
|
204
|
+
return async ({ message }) => {
|
|
205
|
+
ctx.messageBus.announce(from, message);
|
|
206
|
+
return textResult("Announcement delivered.");
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Cancel pending Asks and route a synthetic `[no answer: <reason>]` to
|
|
212
|
+
* each asker's queue so callers never deadlock on a participant ignoring
|
|
213
|
+
* its inbox.
|
|
214
|
+
*
|
|
215
|
+
* @param {object} ctx
|
|
216
|
+
* @param {string} reason - Surfaced inside `[no answer: <reason>]`.
|
|
217
|
+
* @param {string} [addressee] - When set, only cancel asks owed by this
|
|
218
|
+
* addressee. Omit to cancel every pending ask.
|
|
219
|
+
*/
|
|
220
|
+
export function cancelPendingAsks(ctx, reason, addressee) {
|
|
221
|
+
const text = `[no answer: ${reason}]`;
|
|
222
|
+
for (const [askId, entry] of [...ctx.pendingAsks]) {
|
|
223
|
+
if (addressee && entry.addresseeName !== addressee) continue;
|
|
224
|
+
ctx.pendingAsks.delete(askId);
|
|
225
|
+
ctx.messageBus.answer("@orchestrator", entry.askerName, text, askId);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Return the list of pending Asks the named participant owes an Answer to. */
|
|
230
|
+
export function pendingAsksOwedBy(ctx, addressee) {
|
|
231
|
+
return [...ctx.pendingAsks.values()].filter(
|
|
232
|
+
(e) => e.addresseeName === addressee,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Inject a synthetic reminder onto the addressee's bus queue and mark
|
|
238
|
+
* each owed ask as reminded. Returns true when a reminder fired.
|
|
239
|
+
*/
|
|
240
|
+
export function remindOwedAsks(ctx, addressee) {
|
|
241
|
+
const owed = pendingAsksOwedBy(ctx, addressee).filter((e) => !e.reminded);
|
|
242
|
+
if (owed.length === 0) return false;
|
|
243
|
+
for (const entry of owed) entry.reminded = true;
|
|
244
|
+
const lines = owed.map(
|
|
245
|
+
(e) =>
|
|
246
|
+
`You have an unanswered ask from ${e.askerName} (askId=${e.askId}). Reply with Answer(message=…, askId=${e.askId}).`,
|
|
247
|
+
);
|
|
248
|
+
ctx.messageBus.synthetic(addressee, lines.join("\n"));
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// --- Tool descriptions (shared across roles) ---
|
|
253
|
+
|
|
254
|
+
const ASK_DESC_BROADCAST =
|
|
255
|
+
"Send a question to one named participant, or omit 'to' to broadcast to every other participant. Returns {askIds:[…]} immediately; the reply arrives on a later turn as `[answer#N] <from>: <text>` in your inbox.";
|
|
256
|
+
|
|
257
|
+
const ASK_DESC_TARGETED = (target) =>
|
|
258
|
+
`Send a question to ${target}. Returns {askIds:[N]} immediately; the reply arrives on a later turn as \`[answer#N] ${target}: <text>\` in your inbox.`;
|
|
259
|
+
|
|
260
|
+
const ANSWER_DESC =
|
|
261
|
+
"Reply to an ask addressed to you. Quote askId from the [ask#N] tag on the question; omit it and the handler auto-picks the only pending ask, or routes your message as an Announce when 0 or many are pending.";
|
|
262
|
+
|
|
263
|
+
const ANNOUNCE_DESC = "Broadcast a message with no reply expected.";
|
|
264
|
+
|
|
265
|
+
const ROLLCALL_DESC = "List all participants in the session.";
|
|
266
|
+
|
|
267
|
+
// Terminal-tool descriptions. Each one ends the run. Group them so the
|
|
268
|
+
// contrast is visible: Conclude (success/failure), Adjourn (settled in
|
|
269
|
+
// thread), Recess (paused for out-of-session input). Each description
|
|
270
|
+
// leads with the cost.
|
|
271
|
+
const CONCLUDE_DESC =
|
|
272
|
+
"End the session. Provide a verdict ('success' or 'failure') and a summary.";
|
|
273
|
+
|
|
274
|
+
const ADJOURN_DESC =
|
|
275
|
+
"End the discussion. Provide a verdict ('adjourned' or 'failed') and a summary. Cancels any unanswered Asks.";
|
|
276
|
+
|
|
277
|
+
const RECESS_DESC =
|
|
278
|
+
"End the run and schedule an out-of-session re-dispatch. Cancels any unanswered Asks. Use only when waiting on an external reply or duration. Do not use to wait on in-flight Asks.";
|
|
279
|
+
|
|
280
|
+
// --- Tool builders ---
|
|
281
|
+
|
|
282
|
+
/** Helper utilities for handler return values. */
|
|
283
|
+
function textResult(text) {
|
|
284
|
+
return { content: [{ type: "text", text }] };
|
|
285
|
+
}
|
|
286
|
+
/** Build an MCP tool error result wrapping a single text message. */
|
|
287
|
+
function errorResult(text) {
|
|
288
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
289
|
+
}
|
|
290
|
+
function jsonResult(obj) {
|
|
291
|
+
return { content: [{ type: "text", text: JSON.stringify(obj) }] };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Build the four-tool base for any role (lead or participant). Differences
|
|
296
|
+
* across roles live in `from` / `defaultTo` / whether broadcast is allowed.
|
|
297
|
+
*
|
|
298
|
+
* @param {object} ctx
|
|
299
|
+
* @param {object} opts
|
|
300
|
+
* @param {string} opts.from - Caller's canonical name.
|
|
301
|
+
* @param {string|undefined} opts.defaultTo - Default Ask target; `undefined`
|
|
302
|
+
* means "broadcast across everyone else when `to` is omitted."
|
|
303
|
+
* @param {boolean} opts.broadcast - Whether Ask accepts a `to` field at all.
|
|
304
|
+
* Leads with multiple participants set this true; supervise's
|
|
305
|
+
* single-participant roles set it false.
|
|
306
|
+
*/
|
|
307
|
+
function baseTools(ctx, { from, defaultTo, broadcast }) {
|
|
308
|
+
const askSchema = broadcast
|
|
309
|
+
? { question: z.string(), to: z.string().optional() }
|
|
310
|
+
: { question: z.string() };
|
|
311
|
+
const askDesc = broadcast ? ASK_DESC_BROADCAST : ASK_DESC_TARGETED(defaultTo);
|
|
312
|
+
return [
|
|
313
|
+
tool("Ask", askDesc, askSchema, createAskHandler(ctx, { from, defaultTo })),
|
|
314
|
+
tool(
|
|
315
|
+
"Answer",
|
|
316
|
+
ANSWER_DESC,
|
|
317
|
+
{ message: z.string(), askId: z.number().optional() },
|
|
318
|
+
createAnswerHandler(ctx, { from }),
|
|
319
|
+
),
|
|
320
|
+
tool(
|
|
321
|
+
"Announce",
|
|
322
|
+
ANNOUNCE_DESC,
|
|
323
|
+
{ message: z.string() },
|
|
324
|
+
createAnnounceHandler(ctx, { from }),
|
|
325
|
+
),
|
|
326
|
+
tool("RollCall", ROLLCALL_DESC, {}, createRollCallHandler(ctx)),
|
|
327
|
+
];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Conclude tool — shared by facilitator + supervisor. */
|
|
331
|
+
function concludeTool(ctx) {
|
|
332
|
+
return tool(
|
|
333
|
+
"Conclude",
|
|
334
|
+
CONCLUDE_DESC,
|
|
335
|
+
{ verdict: z.enum(["success", "failure"]), summary: z.string() },
|
|
336
|
+
createConcludeHandler(ctx),
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const orchestrationServer = (tools) =>
|
|
341
|
+
createSdkMcpServer({ name: "orchestration", tools });
|
|
342
|
+
|
|
343
|
+
// --- Per-role MCP server factories ---
|
|
344
|
+
|
|
345
|
+
/** Supervisor tools: Ask + Answer + Announce + RollCall + Conclude. */
|
|
346
|
+
export function createSupervisorToolServer(ctx) {
|
|
347
|
+
return orchestrationServer([
|
|
348
|
+
...baseTools(ctx, {
|
|
349
|
+
from: "supervisor",
|
|
350
|
+
defaultTo: "agent",
|
|
351
|
+
broadcast: false,
|
|
352
|
+
}),
|
|
353
|
+
concludeTool(ctx),
|
|
354
|
+
]);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Supervised agent tools: Ask + Answer + Announce + RollCall. */
|
|
358
|
+
export function createSupervisedAgentToolServer(ctx) {
|
|
359
|
+
return orchestrationServer(
|
|
360
|
+
baseTools(ctx, {
|
|
361
|
+
from: "agent",
|
|
362
|
+
defaultTo: "supervisor",
|
|
363
|
+
broadcast: false,
|
|
364
|
+
}),
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Facilitator tools: Ask + Answer + Announce + RollCall + Conclude. */
|
|
369
|
+
export function createFacilitatorToolServer(ctx) {
|
|
370
|
+
return orchestrationServer([
|
|
371
|
+
...baseTools(ctx, {
|
|
372
|
+
from: "facilitator",
|
|
373
|
+
defaultTo: undefined,
|
|
374
|
+
broadcast: true,
|
|
375
|
+
}),
|
|
376
|
+
concludeTool(ctx),
|
|
377
|
+
]);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Facilitated agent tools: Ask + Answer + Announce + RollCall + RequestForComment. */
|
|
381
|
+
export function createFacilitatedAgentToolServer(ctx, { from }) {
|
|
382
|
+
return orchestrationServer([
|
|
383
|
+
...baseTools(ctx, { from, defaultTo: "facilitator", broadcast: true }),
|
|
384
|
+
requestForCommentTool(ctx),
|
|
385
|
+
]);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Judge tools: Conclude only. The judge runs a single post-hoc session
|
|
390
|
+
* with no peer participants.
|
|
391
|
+
*/
|
|
392
|
+
export function createJudgeToolServer(ctx) {
|
|
393
|
+
return orchestrationServer([concludeTool(ctx)]);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// --- RequestForComment (agent-level coordination tool) ---
|
|
397
|
+
|
|
398
|
+
/** RequestForComment handler — queues RFC intent on `ctx.rfcs[]`. */
|
|
399
|
+
export function createRequestForCommentHandler(ctx) {
|
|
400
|
+
return async ({ channel, body, addressees }) => {
|
|
401
|
+
if (!ctx.rfcs) ctx.rfcs = [];
|
|
402
|
+
if (typeof ctx.rfcCounter !== "number") ctx.rfcCounter = 0;
|
|
403
|
+
const correlationId = `rfc_${++ctx.rfcCounter}`;
|
|
404
|
+
const addresseeList = addressees?.length ? addressees : [null];
|
|
405
|
+
for (const addressee of addresseeList) {
|
|
406
|
+
ctx.rfcs.push({
|
|
407
|
+
...(addressee && { addressee }),
|
|
408
|
+
body,
|
|
409
|
+
channel,
|
|
410
|
+
...(ctx.discussionId && { thread_id: ctx.discussionId }),
|
|
411
|
+
correlation_id: correlationId,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
return jsonResult({ correlation_id: correlationId, channel });
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Build the RequestForComment tool definition. */
|
|
419
|
+
function requestForCommentTool(ctx) {
|
|
420
|
+
return tool(
|
|
421
|
+
"RequestForComment",
|
|
422
|
+
"Open a new Discussion thread for long-horizon coordination on an open question. The bridge creates the thread; replies arrive asynchronously on future runs.",
|
|
423
|
+
{
|
|
424
|
+
channel: z.string(),
|
|
425
|
+
body: z.string(),
|
|
426
|
+
addressees: z.array(z.string()).optional(),
|
|
427
|
+
},
|
|
428
|
+
createRequestForCommentHandler(ctx),
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Re-export the building blocks discuss-tools.js needs to assemble its
|
|
433
|
+
// own lead tool surface (it has two extra terminal tools).
|
|
434
|
+
export {
|
|
435
|
+
ADJOURN_DESC,
|
|
436
|
+
baseTools,
|
|
437
|
+
errorResult,
|
|
438
|
+
orchestrationServer,
|
|
439
|
+
RECESS_DESC,
|
|
440
|
+
requestForCommentTool,
|
|
441
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a drained batch of bus messages as tagged text lines so the
|
|
3
|
+
* LLM can read its inbox at a glance. Asks and answers include the
|
|
4
|
+
* `askId` in the tag (`[ask#42] facilitator: …`, `[answer#42] agent: …`)
|
|
5
|
+
* so the addressee can quote it back via Answer's `askId` field.
|
|
6
|
+
*
|
|
7
|
+
* @param {Array<{from: string, text: string, kind?: string, askId?: number}>} messages
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
export function formatMessages(messages) {
|
|
11
|
+
return messages.map(formatMessage).join("\n");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function formatMessage(m) {
|
|
15
|
+
return `${tagFor(m)} ${m.from}: ${m.text}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function tagFor(m) {
|
|
19
|
+
if (m.kind === "ask") return `[ask#${m.askId}]`;
|
|
20
|
+
if (m.kind === "answer") return `[answer#${m.askId}]`;
|
|
21
|
+
if (m.kind === "synthetic") return "[system]";
|
|
22
|
+
return "[shared]";
|
|
23
|
+
}
|