@a-t-h-i/bot-lobby 0.3.0 → 0.4.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.
@@ -0,0 +1,214 @@
1
+ /**
2
+ * One parallel batch's file desk as the master sees it: the checkout table,
3
+ * the socket the workers talk to, and the messages that keep every worker
4
+ * informed. Queue changes and handovers reach the right worker as steering
5
+ * messages over its RPC stdin, and the zen panel sees who is waiting.
6
+ */
7
+ import { basename } from "node:path";
8
+ import type { AgentHandle } from "../execution/agent-runner.ts";
9
+ import { FileDesk, type Claim, type DeskEvent, type FileView, type Handover, type WorkerId } from "./desk.ts";
10
+ import { startDeskServer, type DeskRequest, type DeskResponse, type DeskServer } from "./ipc.ts";
11
+
12
+ /** Tools the desk adds to every parallel worker's allowlist. */
13
+ export const DESK_TOOLS: readonly string[] = ["claim_file", "handover_file", "my_files", "wait_for_files"];
14
+
15
+ /** Env names the desk client extension reads inside a worker process. */
16
+ export const DESK_ENV = { address: "BOT_LOBBY_DESK", worker: "BOT_LOBBY_DESK_ID" } as const;
17
+
18
+ /** Longest a single `wait_for_files` call may block. */
19
+ export const MAX_WAIT_MS = 120_000;
20
+
21
+ const EDIT_TOOLS = new Set(["edit", "write"]);
22
+
23
+ export function isEditTool(name: string): boolean {
24
+ return EDIT_TOOLS.has(name);
25
+ }
26
+
27
+ function label(worker: WorkerId): string {
28
+ const names: Record<string, string> = { backend: "DEV", designer: "DESIGN", qa: "QA" };
29
+ return names[worker] ?? worker.toUpperCase();
30
+ }
31
+
32
+ function queueLines(queue: readonly Claim[]): string {
33
+ return queue.map((claim, index) => `${index + 1}) ${label(claim.worker)} — ${claim.intent}`).join("; ");
34
+ }
35
+
36
+ /** Steering text for a holder whose queue changed. */
37
+ export function queueMessage(path: string, queue: readonly Claim[]): string {
38
+ if (queue.length === 0) return `bot-lobby desk: nobody is waiting for \`${path}\` any more.`;
39
+ const next = queue[0]!;
40
+ return [
41
+ `bot-lobby desk: queue for \`${path}\` (you hold it): ${queueLines(queue)}.`,
42
+ `As soon as you are done with it, call handover_file with a short note for ${label(next.worker)}, who wants to: ${next.intent}.`,
43
+ ].join(" ");
44
+ }
45
+
46
+ /** Steering text for a worker who just received a file. */
47
+ export function grantMessage(event: Extract<DeskEvent, { type: "granted" }>): string {
48
+ const behind = event.queue.length > 0 ? ` Queued behind you: ${queueLines(event.queue)}; hand it over when you are done.` : "";
49
+ return `bot-lobby desk: \`${event.path}\` is yours now (for: ${event.intent}). ${label(event.from)}'s handover note: ${event.note || "none"}. Re-read the file before editing it.${behind}`;
50
+ }
51
+
52
+ function describeHeld(files: readonly FileView[]): string {
53
+ if (files.length === 0) return "You hold no files.";
54
+ return files
55
+ .map((file) => `You hold \`${file.path}\` (${file.holder.intent})${file.queue.length > 0 ? `; waiting: ${queueLines(file.queue)}` : "; nobody waiting"}.`)
56
+ .join("\n");
57
+ }
58
+
59
+ export interface DeskSessionOptions {
60
+ cwd: string;
61
+ /** A worker that finished without handing a file over leaves this note for the next worker. */
62
+ onHandover?: (handover: Handover) => void;
63
+ }
64
+
65
+ /** Master-side desk for one batch of parallel workers. */
66
+ export class DeskSession {
67
+ readonly desk: FileDesk;
68
+ private server?: DeskServer;
69
+ private readonly handles = new Map<WorkerId, AgentHandle>();
70
+ private readonly greeted = new Set<WorkerId>();
71
+ private readonly options: DeskSessionOptions;
72
+
73
+ constructor(options: DeskSessionOptions) {
74
+ this.options = options;
75
+ this.desk = new FileDesk(options.cwd, (event) => this.onEvent(event));
76
+ }
77
+
78
+ async open(): Promise<void> {
79
+ this.server = await startDeskServer((request) => this.handle(request));
80
+ }
81
+
82
+ /** Environment that points one worker's pi process at this desk. */
83
+ env(worker: WorkerId): Record<string, string> {
84
+ if (!this.server) throw new Error("desk is not open");
85
+ return { [DESK_ENV.address]: this.server.address, [DESK_ENV.worker]: worker };
86
+ }
87
+
88
+ /** Remember the running attempt's handle so the desk can steer it. */
89
+ attach(worker: WorkerId, handle: AgentHandle): void {
90
+ this.handles.set(worker, handle);
91
+ }
92
+
93
+ /** True once the worker's desk extension has checked in. */
94
+ greetedBy(worker: WorkerId): boolean {
95
+ return this.greeted.has(worker);
96
+ }
97
+
98
+ /** End of one attempt: hand everything it held to whoever is next, with `note` for them. */
99
+ release(worker: WorkerId, note: (path: string, next: Claim) => string): void {
100
+ this.handles.get(worker)?.annotate({ waitingFor: undefined });
101
+ this.handles.delete(worker);
102
+ for (const handover of this.desk.release(worker, note)) this.options.onHandover?.(handover);
103
+ }
104
+
105
+ handovers(): Handover[] {
106
+ return this.desk.handovers();
107
+ }
108
+
109
+ async close(): Promise<void> {
110
+ this.desk.close();
111
+ await this.server?.close();
112
+ this.server = undefined;
113
+ }
114
+
115
+ private steer(worker: WorkerId, text: string): void {
116
+ this.handles.get(worker)?.steer(text);
117
+ }
118
+
119
+ private onEvent(event: DeskEvent): void {
120
+ if (event.type === "queue") {
121
+ this.steer(event.to, queueMessage(event.path, event.queue));
122
+ return;
123
+ }
124
+ if (event.type === "queued") {
125
+ this.handles.get(event.worker)?.annotate({ note: `queued for ${basename(event.path)} behind ${label(event.holder)}`, noteKind: "info" });
126
+ return;
127
+ }
128
+ this.steer(event.to, grantMessage(event));
129
+ this.handles.get(event.to)?.annotate({ note: `got ${basename(event.path)} from ${label(event.from)}`, noteKind: "info", waitingFor: undefined });
130
+ }
131
+
132
+ private async handle(request: DeskRequest): Promise<Omit<DeskResponse, "id">> {
133
+ const worker = request.worker;
134
+ switch (request.op) {
135
+ case "hello":
136
+ this.greeted.add(worker);
137
+ return { ok: true, text: "desk connected" };
138
+ case "check":
139
+ return this.check(worker, request.path ?? "");
140
+ case "claim":
141
+ return this.claim(worker, request.path ?? "", request.intent ?? "");
142
+ case "handover":
143
+ return this.handover(worker, request.path ?? "", request.note ?? "");
144
+ case "mine":
145
+ return { ok: true, text: this.mine(worker) };
146
+ case "wait":
147
+ return this.wait(worker, Math.min(MAX_WAIT_MS, Math.max(1000, request.timeoutMs ?? MAX_WAIT_MS)));
148
+ default:
149
+ return { ok: false, text: `unknown desk operation ${String(request.op)}` };
150
+ }
151
+ }
152
+
153
+ private check(worker: WorkerId, path: string): Omit<DeskResponse, "id"> {
154
+ if (this.desk.holds(worker, path)) return { ok: true, allowed: true, text: "" };
155
+ const key = this.desk.normalize(path);
156
+ const holder = this.desk.holderOf(path);
157
+ if (!holder) {
158
+ return { ok: true, allowed: false, text: `Claim \`${key}\` first: call claim_file with its path and a one-line intent (what you will change), then retry this edit.` };
159
+ }
160
+ return {
161
+ ok: true,
162
+ allowed: false,
163
+ text: `\`${key}\` is checked out by ${label(holder.worker)} (${holder.intent}). Call claim_file to join its queue, keep working on your other files, and edit it once it is handed to you.`,
164
+ };
165
+ }
166
+
167
+ private claim(worker: WorkerId, path: string, intent: string): Omit<DeskResponse, "id"> {
168
+ if (!path.trim()) return { ok: false, text: "claim_file needs a path" };
169
+ const result = this.desk.claim(worker, path, intent);
170
+ if (result.status === "queued") {
171
+ return {
172
+ ok: true,
173
+ text: `\`${result.path}\` is with ${label(result.holder.worker)} (${result.holder.intent}); you are #${result.position} in line. Keep working on your other files — you will be told when it is handed to you, with their notes.`,
174
+ };
175
+ }
176
+ const behind = result.queue.length > 0 ? ` Waiting behind you: ${queueLines(result.queue)}.` : "";
177
+ return { ok: true, text: `\`${result.path}\` is yours. Edit it, then call handover_file with a note as soon as you are done with it.${behind}` };
178
+ }
179
+
180
+ private handover(worker: WorkerId, path: string, note: string): Omit<DeskResponse, "id"> {
181
+ const result = this.desk.handover(worker, path, note);
182
+ if (!result.ok) return { ok: false, text: result.error };
183
+ const handover = this.desk.handovers().at(-1);
184
+ if (result.to && handover) this.options.onHandover?.(handover);
185
+ if (!result.to) return { ok: true, text: `\`${result.path}\` released; nobody was waiting for it.` };
186
+ return { ok: true, text: `\`${result.path}\` handed to ${label(result.to.worker)} with your note.` };
187
+ }
188
+
189
+ private mine(worker: WorkerId): string {
190
+ const waiting = this.desk.waitingFor(worker).map((entry) => `You are #${entry.position} for \`${entry.path}\` (held by ${label(entry.holder.worker)}: ${entry.holder.intent}).`);
191
+ return [describeHeld(this.desk.heldBy(worker)), ...waiting].join("\n");
192
+ }
193
+
194
+ private async wait(worker: WorkerId, timeoutMs: number): Promise<Omit<DeskResponse, "id">> {
195
+ const queuedFor = this.desk.waitingFor(worker);
196
+ if (queuedFor.length > 0) this.handles.get(worker)?.annotate({ waitingFor: basename(queuedFor[0]!.path) });
197
+ const result = await this.desk.wait(worker, timeoutMs);
198
+ this.handles.get(worker)?.annotate({ waitingFor: undefined });
199
+ if (result.refused) {
200
+ return { ok: false, text: `Hand these over first — others are waiting for them:\n${describeHeld(result.refused)}` };
201
+ }
202
+ const grants = result.grants.filter((event): event is Extract<DeskEvent, { type: "granted" }> => event.type === "granted");
203
+ if (grants.length > 0) return { ok: true, text: grants.map(grantMessage).join("\n") };
204
+ if (queuedFor.length === 0) return { ok: true, text: "You are not waiting for any file." };
205
+ return { ok: true, text: `Still waiting:\n${this.mine(worker)}\nIf nothing else is left, finish your report and list the dependency under Notes; files are handed over automatically when their holder finishes.` };
206
+ }
207
+ }
208
+
209
+ /** A handover note for `next`, written from a finished worker's report. */
210
+ export function autoNote(from: WorkerId, path: string, next: Claim, changes: ReadonlyArray<{ path: string; change: string }>, completed: string): string {
211
+ const match = changes.find((change) => change.path.replace(/\\/g, "/").endsWith(path) || path.endsWith(change.path.replace(/\\/g, "/")));
212
+ const what = match ? `changed it: ${match.change}` : completed ? `finished; its report says: ${completed.replace(/\s+/g, " ").slice(0, 300)}` : "finished without describing its change";
213
+ return `${label(from)} ${what} (auto-handover on finish; you asked for: ${next.intent})`;
214
+ }
@@ -2,7 +2,9 @@ import type { Domain, Role } from "../schemas/agent.ts";
2
2
  import type { AgentRun } from "../schemas/findings.ts";
3
3
  import { roleSpec } from "../roles/registry.ts";
4
4
  import { compilePrompt } from "../prompts/compiler.ts";
5
- import { runPiAgent, spawnPiProcess, type ProcessRunner } from "./pi-runner.ts";
5
+ import { activityDetail, activityWord } from "../pi/activity.ts";
6
+ import { shortDuration, truncate } from "../text.ts";
7
+ import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "./pi-runner.ts";
6
8
 
7
9
  export interface AgentContext {
8
10
  task: string;
@@ -14,6 +16,16 @@ export interface AgentContext {
14
16
  workflowContext?: string;
15
17
  }
16
18
 
19
+ /** Live fields a caller (the file desk) may set on a running agent. */
20
+ export type LivePatch = Pick<AgentRun, "note" | "noteKind" | "waitingFor">;
21
+
22
+ /** Control over one running attempt, handed to `onStart`. */
23
+ export interface AgentHandle {
24
+ runId: string;
25
+ steer(text: string): void;
26
+ annotate(patch: LivePatch): void;
27
+ }
28
+
17
29
  export interface AgentRequest {
18
30
  taskId: string;
19
31
  domain: Domain;
@@ -28,15 +40,50 @@ export interface AgentRequest {
28
40
  cwd: string;
29
41
  signal?: AbortSignal;
30
42
  onUpdate?: (run: AgentRun) => void;
31
- /** Bounded retries for transient failures (crash/timeout). */
43
+ /** Bounded retries for transient failures (crash/stall). */
32
44
  retries?: number;
45
+ /** Kill after this long without output; 0 or absent disables. */
46
+ stallTimeoutMs?: number;
47
+ /** Silence allowed while one tool call runs. */
48
+ toolStallTimeoutMs?: number;
49
+ /** Fraction of `timeoutMs` at which the agent is asked to wrap up; 0 disables. */
50
+ wrapUpAt?: number;
51
+ /** Extra tools on top of the role allowlist (the file desk's tools). */
52
+ extraTools?: readonly string[];
53
+ env?: Record<string, string>;
54
+ /** Called once per attempt when the process is running. */
55
+ onStart?: (handle: AgentHandle) => void;
56
+ /** Called once per attempt after it ends, before any retry. */
57
+ onAttemptEnd?: (run: AgentRun) => void;
58
+ }
59
+
60
+ /** The watchdog fields of an AgentRequest, taken from the workflow config. */
61
+ export function watchdogOptions(workflow: {
62
+ maxAgentRetries: number;
63
+ stallTimeoutMs: number;
64
+ toolStallTimeoutMs: number;
65
+ wrapUpAt: number;
66
+ }): Pick<AgentRequest, "retries" | "stallTimeoutMs" | "toolStallTimeoutMs" | "wrapUpAt"> {
67
+ return {
68
+ retries: workflow.maxAgentRetries,
69
+ stallTimeoutMs: workflow.stallTimeoutMs,
70
+ toolStallTimeoutMs: workflow.toolStallTimeoutMs,
71
+ wrapUpAt: workflow.wrapUpAt,
72
+ };
33
73
  }
34
74
 
35
75
  const activeControllers = new Set<AbortController>();
36
76
 
77
+ /** A stall or crash is worth one more try; a spent deadline or a cancel is not. */
78
+ function retryable(run: AgentRun): boolean {
79
+ if (run.status === "failed") return true;
80
+ return run.status === "timeout" && run.stalled === true;
81
+ }
82
+
37
83
  /**
38
84
  * Run one domain/role agent, retrying transient failures a bounded number of
39
- * times. Cancellation never retries, so Esc/quit stays responsive.
85
+ * times. Cancellation and a spent deadline never retry, so Esc stays responsive
86
+ * and a slow agent cannot double its time budget.
40
87
  */
41
88
  export async function runAgent(request: AgentRequest, run: ProcessRunner = spawnPiProcess): Promise<AgentRun> {
42
89
  const startedAt = new Date().toISOString();
@@ -44,7 +91,8 @@ export async function runAgent(request: AgentRequest, run: ProcessRunner = spawn
44
91
  let last: AgentRun | undefined;
45
92
  for (let attempt = 1; attempt <= attempts; attempt++) {
46
93
  last = await runAgentOnce(request, run, attempt, startedAt);
47
- if (last.status === "success" || last.status === "cancelled") break;
94
+ request.onAttemptEnd?.(last);
95
+ if (!retryable(last)) break;
48
96
  }
49
97
  return last!;
50
98
  }
@@ -66,9 +114,16 @@ function baseRun(request: AgentRequest, runId: string, startedAt: string, attemp
66
114
  output: "",
67
115
  attempts,
68
116
  startedAt,
117
+ ...(attempts > 1 ? { note: `retry ${attempts - 1} of ${(request.retries ?? 0)}`, noteKind: "warning" as const } : {}),
69
118
  };
70
119
  }
71
120
 
121
+ function toolsFor(request: AgentRequest): readonly string[] | undefined {
122
+ const tools = roleSpec(request.role).tools;
123
+ if (!request.extraTools?.length) return tools;
124
+ return [...(tools ?? []), ...request.extraTools];
125
+ }
126
+
72
127
  /**
73
128
  * Run one domain/role agent in an isolated pi process. The role's tool
74
129
  * allowlist comes from its spec, so read-only roles cannot modify anything.
@@ -76,24 +131,46 @@ function baseRun(request: AgentRequest, runId: string, startedAt: string, attemp
76
131
  async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt: number, startedAt: string): Promise<AgentRun> {
77
132
  const base = baseRun(request, `${request.taskId}:${request.domain}:${request.role}:${Date.now().toString(36)}`, startedAt, attempt);
78
133
  request.onUpdate?.(base);
134
+ const live = createLiveRun(base, request);
79
135
 
80
136
  const controller = new AbortController();
81
137
  activeControllers.add(controller);
82
138
  const signal = request.signal ? AbortSignal.any([request.signal, controller.signal]) : controller.signal;
83
139
  try {
84
140
  const systemPrompt = compilePrompt({ domain: request.domain, role: request.role, ...request.context });
141
+ const wrapUp = request.wrapUpAt && request.wrapUpAt > 0 && request.wrapUpAt < 1 ? Math.floor(request.timeoutMs * request.wrapUpAt) : 0;
85
142
  const result = await runPiAgent({
86
143
  cwd: request.cwd,
87
144
  task: request.instruction,
88
145
  systemPrompt,
89
- tools: roleSpec(request.role).tools,
146
+ tools: toolsFor(request),
90
147
  model: request.model,
91
148
  thinking: request.thinking,
92
149
  timeoutMs: request.timeoutMs,
93
150
  signal,
94
- onActivity: activityReporter(base, request),
151
+ env: request.env,
152
+ stallTimeoutMs: request.stallTimeoutMs,
153
+ toolStallTimeoutMs: request.toolStallTimeoutMs,
154
+ wrapUpAtMs: wrapUp,
155
+ onEvent: live.onEvent,
156
+ onStart: (handle) => request.onStart?.({ runId: base.runId, steer: handle.steer, annotate: live.annotate }),
95
157
  }, run);
96
- const final: AgentRun = { ...base, status: result.status, output: result.output, error: result.error, usage: result.usage, finishedAt: new Date().toISOString() };
158
+ const current = live.current();
159
+ const final: AgentRun = {
160
+ ...base,
161
+ status: result.status,
162
+ output: result.output,
163
+ error: result.error ? describeError(result.error, current, result.stalled === true) : undefined,
164
+ usage: result.usage,
165
+ finishedAt: new Date().toISOString(),
166
+ turns: current.turns ?? (result.usage.turns || undefined),
167
+ tools: current.tools,
168
+ model: result.model ?? current.model,
169
+ ...(result.stalled ? { stalled: true } : {}),
170
+ ...(result.wrappedUp ? { wrappedUp: true } : {}),
171
+ note: undefined,
172
+ noteKind: undefined,
173
+ };
97
174
  request.onUpdate?.(final);
98
175
  return final;
99
176
  } finally {
@@ -101,14 +178,78 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
101
178
  }
102
179
  }
103
180
 
104
- /** Streams activity-word changes for one run, deduping consecutive repeats. */
105
- function activityReporter(base: AgentRun, request: AgentRequest): (activity: string) => void {
106
- let last: string | undefined;
107
- return (activity) => {
108
- if (activity === last) return;
109
- last = activity;
110
- request.onUpdate?.({ ...base, activity });
181
+ /** Name what the agent was doing when a stall or deadline hit. */
182
+ function describeError(error: string, current: AgentRun, stalled: boolean): string {
183
+ if (!stalled && !/time limit/.test(error)) return error;
184
+ const doing = current.activity ? ` while ${current.activity}${current.detail ? ` ${current.detail}` : ""}` : "";
185
+ return `${error}${doing}`;
186
+ }
187
+
188
+ const THROTTLE_MS = 2000;
189
+
190
+ /**
191
+ * Tracks one attempt's live state from its stream and reports it through
192
+ * `onUpdate`: activity changes, retries and notes immediately, counters and
193
+ * heartbeats at most every couple of seconds.
194
+ */
195
+ function createLiveRun(base: AgentRun, request: AgentRequest) {
196
+ let state: AgentRun = base;
197
+ let lastEmit = 0;
198
+ const emit = (patch: Partial<AgentRun>, force: boolean) => {
199
+ state = { ...state, ...patch, lastEventAt: Date.now() };
200
+ const now = Date.now();
201
+ if (!force && now - lastEmit < THROTTLE_MS) return;
202
+ lastEmit = now;
203
+ request.onUpdate?.(state);
204
+ };
205
+ const changed = (activity: string, detail: string | undefined) => activity !== state.activity || detail !== state.detail;
206
+ const onEvent = (event: PiStreamEvent) => {
207
+ switch (event.type) {
208
+ case "tool_execution_start": {
209
+ const activity = activityWord(event.toolName);
210
+ const detail = activityDetail(event.toolName, event.args);
211
+ emit({ activity, detail, tools: (state.tools ?? 0) + 1 }, changed(activity, detail));
212
+ return;
213
+ }
214
+ case "thinking":
215
+ case "writing":
216
+ emit({ activity: event.type, detail: undefined }, changed(event.type, undefined));
217
+ return;
218
+ case "turn_start":
219
+ emit({ turns: (state.turns ?? 0) + 1 }, false);
220
+ return;
221
+ case "retry":
222
+ emit({ note: `provider retry ${event.attempt}/${event.maxAttempts}: ${truncateLine(event.error)}`, noteKind: "warning" }, true);
223
+ return;
224
+ case "retry_end":
225
+ emit({ note: undefined, noteKind: undefined }, true);
226
+ return;
227
+ case "compaction":
228
+ emit({ note: "compacting context", noteKind: "info" }, true);
229
+ return;
230
+ case "wrap_up":
231
+ emit({ note: `asked to wrap up (${shortDuration(request.timeoutMs)} limit)`, noteKind: "warning", wrappedUp: true }, true);
232
+ return;
233
+ case "usage": {
234
+ const usage = state.usage ?? { input: 0, output: 0, cost: 0, turns: 0 };
235
+ const next = { input: usage.input + event.input, output: usage.output + event.output, cost: usage.cost + event.cost, turns: usage.turns + 1 };
236
+ emit({ usage: next, model: event.model ?? state.model }, false);
237
+ return;
238
+ }
239
+ default:
240
+ emit({}, false);
241
+ }
111
242
  };
243
+ return {
244
+ onEvent,
245
+ annotate: (patch: LivePatch) => emit(patch, true),
246
+ current: () => state,
247
+ };
248
+ }
249
+
250
+ function truncateLine(text: string): string {
251
+ const first = text.split("\n")[0] ?? "";
252
+ return truncate(first, 48).split("\n")[0]!;
112
253
  }
113
254
 
114
255
  async function mapWithConcurrencyLimit<TIn, TOut>(items: TIn[], concurrency: number, fn: (item: TIn) => Promise<TOut>): Promise<TOut[]> {
@@ -126,6 +267,11 @@ async function mapWithConcurrencyLimit<TIn, TOut>(items: TIn[], concurrency: num
126
267
  return results;
127
268
  }
128
269
 
270
+ /** Run independent tasks concurrently, bounded by `limit`. */
271
+ export async function mapConcurrent<TIn, TOut>(items: TIn[], limit: number, fn: (item: TIn) => Promise<TOut>): Promise<TOut[]> {
272
+ return mapWithConcurrencyLimit(items, limit, fn);
273
+ }
274
+
129
275
  /** Run independent agents concurrently, bounded by `limit`. */
130
276
  export async function runParallel(requests: AgentRequest[], limit: number, run: ProcessRunner = spawnPiProcess): Promise<AgentRun[]> {
131
277
  return mapWithConcurrencyLimit(requests, limit, (request) => runAgent(request, run));