@a-t-h-i/bot-lobby 0.6.53 → 0.6.54

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.
@@ -8,10 +8,20 @@ import { basename } from "node:path";
8
8
  import type { AgentHandle } from "../execution/agent-runner.ts";
9
9
  import { FileDesk, type Claim, type DeskEvent, type FileView, type Handover, type WorkerId } from "./desk.ts";
10
10
  import { startDeskServer, type DeskRequest, type DeskResponse, type DeskServer } from "./ipc.ts";
11
+ import { appendPost, formatPost, isPostKind, PUSHED_KINDS, SUBSCRIPTIONS, subscriptionFor, type BoardPost } from "./board.ts";
11
12
 
12
13
  /** Tools the desk adds to every parallel worker's allowlist. */
13
14
  export const DESK_TOOLS: readonly string[] = ["claim_file", "handover_file", "my_files", "wait_for_files"];
14
15
 
16
+ /** The desk tools of a worker with a checkout of its own: questions only, no claims. */
17
+ export const PEER_TOOLS: readonly string[] = ["ask_peer", "answer_peer"];
18
+
19
+ /** How long `ask_peer` waits for an answer by default, and at most. */
20
+ export const ASK_WAIT_MS = 90_000;
21
+ export const MAX_ASK_WAIT_MS = 180_000;
22
+ const MAX_QUESTION = 800;
23
+ const MAX_ANSWER = 1500;
24
+
15
25
  /** Env names the desk client extension reads inside a worker process. */
16
26
  export const DESK_ENV = { address: "BOT_LOBBY_DESK", worker: "BOT_LOBBY_DESK_ID" } as const;
17
27
 
@@ -29,6 +39,27 @@ function label(worker: WorkerId): string {
29
39
  return names[worker] ?? worker.toUpperCase();
30
40
  }
31
41
 
42
+ /** A peer named the way workers know each other (DEV, DESIGN, QA) or by domain. */
43
+ export function peerId(name: string): WorkerId {
44
+ const key = name.trim().toLowerCase();
45
+ const ids: Record<string, string> = { dev: "backend", backend: "backend", design: "designer", designer: "designer", qa: "qa" };
46
+ return ids[key] ?? name.trim();
47
+ }
48
+
49
+ function flat(text: string, max: number): string {
50
+ const value = text.replace(/\s+/g, " ").trim();
51
+ return value.length > max ? `${value.slice(0, max - 1)}…` : value;
52
+ }
53
+
54
+ interface PeerQuestion {
55
+ id: number;
56
+ from: WorkerId;
57
+ to: WorkerId;
58
+ question: string;
59
+ resolve: (answer: string | undefined) => void;
60
+ timer: ReturnType<typeof setTimeout>;
61
+ }
62
+
32
63
  function queueLines(queue: readonly Claim[]): string {
33
64
  return queue.map((claim, index) => `${index + 1}) ${label(claim.worker)} — ${claim.intent}`).join("; ");
34
65
  }
@@ -58,6 +89,12 @@ function describeHeld(files: readonly FileView[]): string {
58
89
 
59
90
  export interface DeskSessionOptions {
60
91
  cwd: string;
92
+ /** The task board's file: workers post to it through the desk, and contracts and blockers reach the other workers at once. */
93
+ boardPath?: string;
94
+ /** False when every worker has a checkout of its own: edits need no claim, and the desk only carries questions and the board. */
95
+ claims?: boolean;
96
+ /** Workers of one parallel batch may ask each other questions (`ask_peer`). */
97
+ peers?: boolean;
61
98
  /** A worker that finished without handing a file over leaves this note for the next worker. */
62
99
  onHandover?: (handover: Handover) => void;
63
100
  }
@@ -75,6 +112,8 @@ export class DeskSession {
75
112
  private readonly handles = new Map<WorkerId, AgentHandle>();
76
113
  private readonly greeted = new Set<WorkerId>();
77
114
  private readonly options: DeskSessionOptions;
115
+ private readonly questions = new Map<number, PeerQuestion>();
116
+ private nextQuestion = 1;
78
117
 
79
118
  constructor(options: DeskSessionOptions) {
80
119
  this.options = options;
@@ -97,6 +136,16 @@ export class DeskSession {
97
136
  this.handles.set(worker, handle);
98
137
  }
99
138
 
139
+ /** False when every worker has a checkout of its own and edits need no claim. */
140
+ get usesClaims(): boolean {
141
+ return this.options.claims !== false;
142
+ }
143
+
144
+ /** The desk tools its workers get: claims, questions, or both. */
145
+ tools(): readonly string[] {
146
+ return [...(this.usesClaims ? DESK_TOOLS : []), ...(this.options.peers ? PEER_TOOLS : [])];
147
+ }
148
+
100
149
  /** True once the worker's desk extension has checked in. */
101
150
  greetedBy(worker: WorkerId): boolean {
102
151
  return this.greeted.has(worker);
@@ -106,8 +155,13 @@ export class DeskSession {
106
155
  release(worker: WorkerId, note: (path: string, next: Claim) => string): void {
107
156
  this.releaseFiles(worker, note);
108
157
  this.handles.delete(worker);
158
+ // A finished worker can no longer answer, and its own questions need no answer.
159
+ for (const question of [...this.questions.values()]) {
160
+ if (question.to === worker || question.from === worker) this.settle(question, undefined);
161
+ }
109
162
  }
110
163
 
164
+
111
165
  /** A coordinating worker releases claims but still receives steering messages. */
112
166
  releaseFiles(worker: WorkerId, note: (path: string, next: Claim) => string): void {
113
167
  this.handles.get(worker)?.annotate({ waitingFor: undefined });
@@ -119,6 +173,7 @@ export class DeskSession {
119
173
  }
120
174
 
121
175
  async close(): Promise<void> {
176
+ for (const question of [...this.questions.values()]) this.settle(question, undefined);
122
177
  if (this.server) sessions.delete(this.server.address);
123
178
  this.desk.close();
124
179
  await this.server?.close();
@@ -158,13 +213,19 @@ export class DeskSession {
158
213
  return { ok: true, text: this.mine(worker) };
159
214
  case "wait":
160
215
  return this.wait(worker, Math.min(MAX_WAIT_MS, Math.max(1000, request.timeoutMs ?? MAX_WAIT_MS)));
216
+ case "post":
217
+ return this.post(request.from?.trim() || worker, worker, request.kind ?? "", request.text ?? "", request.files ?? []);
218
+ case "ask":
219
+ return this.ask(worker, request.to ?? "", request.path ?? "", request.text ?? "", Math.min(MAX_ASK_WAIT_MS, Math.max(5000, request.timeoutMs ?? ASK_WAIT_MS)));
220
+ case "answer":
221
+ return this.answer(worker, request.questionId ?? 0, request.text ?? "");
161
222
  default:
162
223
  return { ok: false, text: `unknown desk operation ${String(request.op)}` };
163
224
  }
164
225
  }
165
226
 
166
227
  private check(worker: WorkerId, path: string): Omit<DeskResponse, "id"> {
167
- if (this.desk.holds(worker, path)) return { ok: true, allowed: true, text: "" };
228
+ if (this.options.claims === false || this.desk.holds(worker, path)) return { ok: true, allowed: true, text: "" };
168
229
  const key = this.desk.normalize(path);
169
230
  const holder = this.desk.holderOf(path);
170
231
  if (!holder) {
@@ -217,6 +278,73 @@ export class DeskSession {
217
278
  if (queuedFor.length === 0) return { ok: true, text: "You are not waiting for any file." };
218
279
  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.` };
219
280
  }
281
+
282
+ private post(from: string, worker: WorkerId, kind: string, text: string, files: readonly string[]): Omit<DeskResponse, "id"> {
283
+ if (!this.options.boardPath) return { ok: false, text: "This task has no board." };
284
+ if (!isPostKind(kind)) return { ok: false, text: `Unknown post kind "${kind}".` };
285
+ let post: BoardPost;
286
+ try {
287
+ post = appendPost(this.options.boardPath, { from, kind, text, files: files.map((file) => this.desk.normalize(file)) });
288
+ } catch (error) {
289
+ return { ok: false, text: (error as Error).message };
290
+ }
291
+ let told: string[] = [];
292
+ if (PUSHED_KINDS.has(post.kind)) {
293
+ // Only the batch's named workers hear posts as they land; children read the board when they need it.
294
+ told = [...this.handles.keys()].filter((peer) => peer !== worker && peer in SUBSCRIPTIONS && subscriptionFor(peer).includes(post.kind));
295
+ for (const peer of told) this.steer(peer, `bot-lobby board: ${formatPost(post)}`);
296
+ }
297
+ return { ok: true, text: `Posted #${post.seq}.${told.length > 0 ? ` ${told.map(label).join(", ")} ${told.length === 1 ? "was" : "were"} told now.` : ""}` };
298
+ }
299
+
300
+ private async ask(worker: WorkerId, to: string, path: string, question: string, timeoutMs: number): Promise<Omit<DeskResponse, "id">> {
301
+ if (!this.options.peers) return { ok: false, text: "No peers work alongside you here." };
302
+ const text = flat(question, MAX_QUESTION);
303
+ if (!text) return { ok: false, text: "ask_peer needs a question." };
304
+ if (path.trim() && !this.usesClaims) return { ok: false, text: "Every worker in this batch has a checkout of its own, so nobody holds files: name the peer (DEV, DESIGN or QA) instead." };
305
+ const target = path.trim() ? this.desk.holderOf(path)?.worker : to.trim() ? peerId(to) : undefined;
306
+ if (!target) return { ok: false, text: path.trim() ? `Nobody holds \`${this.desk.normalize(path)}\`; read it yourself.` : "Name the peer (DEV, DESIGN or QA) or a file its holder should answer about." };
307
+ if (target === worker) return { ok: false, text: "That is you." };
308
+ if (!this.handles.has(target)) return { ok: false, text: `${label(target)} is not working right now. Post a \`question\` on the board instead; the oracle sees it.` };
309
+ // A worker waiting on its own question cannot answer until that returns: asking it would chain the waits (or close a cycle).
310
+ const theirs = [...this.questions.values()].find((entry) => entry.from === target);
311
+ if (theirs?.to === worker) return { ok: false, text: `${label(target)} is waiting on your answer to its question #${theirs.id} ("${theirs.question}"). Answer it with answer_peer first.` };
312
+ if (theirs) return { ok: false, text: `${label(target)} is waiting on an answer from ${label(theirs.to)} and cannot answer yet. Carry on with your other work and ask again shortly, or post a \`question\` on the board.` };
313
+ const id = this.nextQuestion++;
314
+ const answer = await new Promise<string | undefined>((resolve) => {
315
+ const timer = setTimeout(() => this.settle(this.questions.get(id), undefined), timeoutMs);
316
+ this.questions.set(id, { id, from: worker, to: target, question: text, resolve, timer });
317
+ this.steer(target, `bot-lobby desk: question #${id} from ${label(worker)}: ${text} Answer it now with answer_peer (id ${id}) in a line or two, from what you know or have built, then carry on with your own work. It is a question, not a task: you take work only from your brief.`);
318
+ this.handles.get(worker)?.annotate({ note: `asked ${label(target)}`, noteKind: "info" });
319
+ });
320
+ if (answer === undefined) {
321
+ return { ok: true, text: `${label(target)} did not answer within ${Math.round(timeoutMs / 1000)} seconds (it may be in a long command, or it finished). Carry on with your best reading of the brief and the board, and list the open question under Notes.` };
322
+ }
323
+ if (this.options.boardPath) {
324
+ try {
325
+ appendPost(this.options.boardPath, { from: worker, kind: "note", text: `Asked ${label(target)}: ${text} Answer: ${answer}` });
326
+ } catch {
327
+ // The answer still reaches the asker.
328
+ }
329
+ }
330
+ return { ok: true, text: `${label(target)} answers: ${answer}` };
331
+ }
332
+
333
+ private answer(worker: WorkerId, id: number, text: string): Omit<DeskResponse, "id"> {
334
+ const question = this.questions.get(id);
335
+ if (!question || question.to !== worker) return { ok: false, text: `There is no open question #${id} for you (it may have timed out).` };
336
+ const answer = flat(text, MAX_ANSWER);
337
+ if (!answer) return { ok: false, text: "answer_peer needs an answer." };
338
+ this.settle(question, answer);
339
+ return { ok: true, text: `Answer sent to ${label(question.from)}.` };
340
+ }
341
+
342
+ private settle(question: PeerQuestion | undefined, answer: string | undefined): void {
343
+ if (!question || !this.questions.has(question.id)) return;
344
+ clearTimeout(question.timer);
345
+ this.questions.delete(question.id);
346
+ question.resolve(answer);
347
+ }
220
348
  }
221
349
 
222
350
  /** A handover note for `next`, written from a finished worker's report. */
@@ -44,11 +44,15 @@ export interface AgentRequest {
44
44
  instruction: string;
45
45
  /** Prompt layers selected for this run. */
46
46
  context: AgentContext;
47
+ /** A whole system prompt of its own instead of the compiled role layers (the goal check). */
48
+ systemPrompt?: string;
47
49
  model?: string;
48
50
  mcpTools?: string[];
49
51
  /** False when the agent's settings turn its auto-compaction off. */
50
52
  autoCompact?: boolean;
51
- delegationQuota?: { used: number };
53
+ delegationQuota?: { used: number; consults?: number };
54
+ /** See PiRunOptions.expert. */
55
+ expert?: { model?: string; thinking: string };
52
56
  thinking?: string;
53
57
  timeoutMs: number;
54
58
  cwd: string;
@@ -240,7 +244,7 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
240
244
  };
241
245
  arm();
242
246
  try {
243
- const systemPrompt = compilePrompt({ domain: request.domain, role: request.role, ...request.context });
247
+ const systemPrompt = request.systemPrompt ?? compilePrompt({ domain: request.domain, role: request.role, ...request.context });
244
248
  const left = Math.max(1, time.endsAt - Date.now());
245
249
  const reportMs = Math.min(REPORT_GRACE_MS, Math.floor(left / 4));
246
250
  const wrapUp = Math.floor(left * (request.wrapUpAt || 0.75));
@@ -249,6 +253,7 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
249
253
  cwd: request.cwd,
250
254
  agent: { runId: base.runId, taskId: request.taskId, domain: request.domain, role: request.role },
251
255
  delegationQuota: quota,
256
+ ...(request.expert ? { expert: request.expert } : {}),
252
257
  effort: request.effort,
253
258
  effortProfile: request.effortProfile,
254
259
  onChildRun: (child) => {
@@ -353,7 +358,10 @@ async function mapWithConcurrencyLimit<TIn, TOut>(items: TIn[], concurrency: num
353
358
  results[index] = await fn(items[index]!);
354
359
  }
355
360
  });
356
- await Promise.all(workers);
361
+ // Every worker settles before a failure is raised, so no caller cleans up under a sibling still running.
362
+ const settled = await Promise.allSettled(workers);
363
+ const failed = settled.find((entry): entry is PromiseRejectedResult => entry.status === "rejected");
364
+ if (failed) throw failed.reason;
357
365
  return results;
358
366
  }
359
367
 
@@ -7,16 +7,32 @@ import type { AgentRun } from "../schemas/findings.ts";
7
7
  import type { Domain, Role } from "../schemas/agent.ts";
8
8
  import { effortFor } from "../classifier/instance.ts";
9
9
  import { fellShort, profileLabel, routeLabel } from "../classifier/effort.ts";
10
- import { agentProfile } from "../schemas/configuration.ts";
10
+ import { agentProfile, expertProfile } from "../schemas/configuration.ts";
11
+ import { loadPrompt } from "../prompts/loader.ts";
11
12
  import { dataRoot, detectProjectRoot, loadConfig } from "../state/project.ts";
12
13
  import { EditLog } from "../state/changes.ts";
13
14
  import { createLiveRun } from "./live-run.ts";
14
15
  import { runPiAgent, type PiRunOptions, type ProcessRunner } from "./pi-runner.ts";
15
16
  import { startDeskServer } from "../desk/ipc.ts";
16
- import { DESK_ENV, DESK_TOOLS, DeskSession, deskSessionAt } from "../desk/session.ts";
17
+ import { DESK_ENV, DeskSession, deskSessionAt, PEER_TOOLS } from "../desk/session.ts";
18
+ import { BOARD_ENV } from "../desk/board.ts";
17
19
 
18
20
  export const MAX_CHILDREN = 5;
19
21
  export const DELEGATE_TOOL = "delegate_subtasks";
22
+ export const EXPERT_TOOL = "ask_expert";
23
+ /** Consults one step may make: advice on the hard part, not a second worker. */
24
+ export const MAX_CONSULTS = 2;
25
+ /** Longest an expert may take to answer. */
26
+ export const EXPERT_TIMEOUT_MS = 5 * 60 * 1000;
27
+ /** Marks an expert's run, so its host gives it an adviser's note rather than a child's. */
28
+ export const EXPERT_ENV = "BOT_LOBBY_EXPERT";
29
+
30
+ /** The worker's env without its desk and board: an expert only reads and answers. */
31
+ function withoutTeamEnv(env: Record<string, string> | undefined): Record<string, string> {
32
+ const { [DESK_ENV.address]: _address, [DESK_ENV.worker]: _worker, [BOARD_ENV]: _board, BOT_LOBBY_BOARD_FROM: _from, ...rest } = env ?? {};
33
+ return rest;
34
+ }
35
+ const EXPERT_TOOLS = ["read", "grep", "find", "ls"];
20
36
  export const AGENT_ENV = "BOT_LOBBY_AGENT_POLICY";
21
37
  export const DELEGATION_ENV = "BOT_LOBBY_DELEGATION";
22
38
 
@@ -27,6 +43,12 @@ export function canDelegate(agent: { role: Role; domain: Domain; depth?: number
27
43
  return agent.role === "reviewer" && agent.domain === "qa";
28
44
  }
29
45
 
46
+ /** A task's DESIGN/DEV workers (not their children, nor quick fixes) may consult a stronger model, when the setting is on. */
47
+ export function canConsult(agent: { runId: string; taskId: string; role: Role; domain: Domain; depth?: number }, enabled: boolean): boolean {
48
+ // A lobby job (a quick fix) runs as its own task: it is one agent on a small change, not a task's worker.
49
+ return enabled && agent.taskId !== agent.runId && (agent.depth ?? 1) === 1 && agent.role === "worker" && (agent.domain === "designer" || agent.domain === "backend");
50
+ }
51
+
30
52
  /** One shared child pool per task; parents waiting on children never hold its slots. */
31
53
  const pools = new Map<string, { active: number; users: number; waiters: Array<() => void> }>();
32
54
 
@@ -72,16 +94,22 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
72
94
  const agent = options.agent ?? { runId: id, taskId: id, domain: "backend" as const, role: editable ? "worker" as const : "reviewer" as const };
73
95
  const depth = agent.depth ?? 1;
74
96
  const quota = options.delegationQuota ?? { used: 0 };
75
- const tools = [...new Set([...baseTools, "codemode", "tool_search", ...(canDelegate(agent, quota.used) ? [DELEGATE_TOOL] : [])])];
97
+ const consults = canConsult(agent, config.workflow.consultExpert);
98
+ const tools = [...new Set([...baseTools, "codemode", "tool_search", ...(canDelegate(agent, quota.used) ? [DELEGATE_TOOL] : []), ...(consults ? [EXPERT_TOOL] : [])])];
76
99
  const mcpTools = options.mcpTools ?? agentProfile(config, agent.domain, agent.role).mcpTools ?? [];
77
100
  const policy: AgentPolicy = { tools, mcpTools, readOnly: !editable, depth };
78
- if (depth > 1) return { tools, mcpTools, env: { ...options.env, [AGENT_ENV]: JSON.stringify(policy), [DELEGATION_ENV]: "" }, note: "You are a child agent. Finish only your assigned brief and report evidence to your parent; you cannot delegate further.", close: async () => {}, onStart: options.onStart, children: [] as AgentRun[] };
101
+ if (depth > 1) return { tools, mcpTools, env: { ...options.env, [AGENT_ENV]: JSON.stringify(policy), [DELEGATION_ENV]: "" }, note: options.env?.[EXPERT_ENV] ? "You are advising a worker: read what you need, answer its question, and change nothing." : "You are a child agent. Finish only your assigned brief and report evidence to your parent; you cannot delegate further.", close: async () => {}, onStart: options.onStart, children: [] as AgentRun[] };
79
102
 
80
103
  const address = options.env?.[DESK_ENV.address];
81
104
  let desk = address ? deskSessionAt(address) : undefined;
82
105
  if (address && !desk) throw new Error("The inherited file desk is unavailable.");
83
- const ownedDesk = editable && !desk ? new DeskSession({ cwd: options.cwd }) : undefined;
84
- if (ownedDesk) { await ownedDesk.open(); desk = ownedDesk; }
106
+ const board = options.env?.[BOARD_ENV];
107
+ // A worker with a checkout of its own (no claims on the batch desk) still needs claims among its children.
108
+ const ownedDesk = editable && (!desk || !desk.usesClaims) ? new DeskSession({ cwd: options.cwd, ...(board ? { boardPath: board } : {}) }) : undefined;
109
+ if (ownedDesk) await ownedDesk.open();
110
+ // Children claim files among themselves; the parent keeps the batch desk (and its peers) when it has one.
111
+ const childDesk = ownedDesk ?? desk;
112
+ desk ??= ownedDesk;
85
113
  const controller = new AbortController();
86
114
  const lifetime = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
87
115
  let timeController = new AbortController();
@@ -129,7 +157,7 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
129
157
  task: brief,
130
158
  model,
131
159
  thinking,
132
- tools: [...new Set([...tools.filter((tool) => tool !== DELEGATE_TOOL), ...(desk ? DESK_TOOLS : [])])],
160
+ tools: [...new Set([...tools.filter((tool) => tool !== DELEGATE_TOOL && tool !== EXPERT_TOOL), ...(childDesk ? childDesk.tools().filter((tool) => !PEER_TOOLS.includes(tool)) : [])])],
133
161
  mcpTools,
134
162
  timeoutMs: left,
135
163
  signal,
@@ -138,8 +166,8 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
138
166
  delegationQuota: undefined,
139
167
  onChildRun: undefined,
140
168
  onEvent: live.onEvent,
141
- env: { ...options.env, ...(desk ? desk.env(childId) : {}) },
142
- onStart: (handle) => desk?.attach(childId, { runId: childId, steer: handle.steer, annotate: live.annotate }),
169
+ env: { ...options.env, ...(childDesk ? childDesk.env(childId) : {}) },
170
+ onStart: (handle) => childDesk?.attach(childId, { runId: childId, steer: handle.steer, annotate: live.annotate }),
143
171
  }, run);
144
172
  };
145
173
  let result = await attempt(route?.model ?? configured.model, route?.thinking ?? configured.thinking);
@@ -156,20 +184,88 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
156
184
  update(final);
157
185
  return final;
158
186
  } finally {
159
- desk?.release(childId, () => "Child finished; re-read the file and check its report before continuing.");
187
+ childDesk?.release(childId, () => "Child finished; re-read the file and check its report before continuing.");
160
188
  }
161
189
  };
162
190
 
191
+ // Kept on the quota, which a step's retries share, so a retry does not grant fresh consults.
192
+ quota.consults ??= 0;
193
+ const consultations = new Map<string, AbortController>();
194
+ const consult = async (question: string, signal: AbortSignal): Promise<{ ok: boolean; text: string }> => {
195
+ const left = Math.min(EXPERT_TIMEOUT_MS, deadlineAt - Date.now());
196
+ if (left < 30_000) return { ok: false, text: "Too little of your time is left to consult; carry on with your best reading." };
197
+ // The oracle resolves the expert (its session model when settings name none); settings alone, then the worker's own, without it.
198
+ const settled = options.expert ?? expertProfile(config);
199
+ const expert = { model: settled.model ?? options.model, thinking: settled.thinking };
200
+ const expertId = randomUUID();
201
+ const base: AgentRun = { runId: expertId, parentRunId: id, depth: 2, taskId: agent.taskId, domain: agent.domain, role: "reviewer", instruction: question, stepInstruction: options.task, status: "running", endsAt: Date.now() + left, output: "", attempts: 1, startedAt: new Date().toISOString(), thinking: expert.thinking, ...(expert.model ? { model: expert.model } : {}), note: "expert consult", noteKind: "info" };
202
+ const live = createLiveRun(base, { onUpdate: update, timeoutMs: left }, new EditLog(options.cwd));
203
+ update(base);
204
+ let final: AgentRun;
205
+ try {
206
+ const result = await runPiAgent({
207
+ cwd: options.cwd,
208
+ agent: { ...agent, runId: expertId, role: "reviewer", depth: 2 },
209
+ task: [`A worker asks: ${question}`, `The worker's brief, for context:\n${options.task.slice(0, 8000)}`].join("\n\n"),
210
+ systemPrompt: loadPrompt("expert.md"),
211
+ tools: EXPERT_TOOLS,
212
+ mcpTools: [],
213
+ ...(expert.model ? { model: expert.model } : {}),
214
+ thinking: expert.thinking,
215
+ timeoutMs: left,
216
+ wrapUpAtMs: Math.floor(left * 0.8),
217
+ ...(options.stallTimeoutMs !== undefined ? { stallTimeoutMs: options.stallTimeoutMs } : {}),
218
+ ...(options.toolStallTimeoutMs !== undefined ? { toolStallTimeoutMs: options.toolStallTimeoutMs } : {}),
219
+ signal,
220
+ onEvent: live.onEvent,
221
+ // Its own env: the expert is not one of the worker's children, nor on its desk or board.
222
+ env: { ...withoutTeamEnv(options.env), [EXPERT_ENV]: "1" },
223
+ }, run);
224
+ final = { ...live.current(), ...result, finishedAt: new Date().toISOString(), note: undefined, noteKind: undefined };
225
+ } catch (error) {
226
+ final = { ...live.current(), status: signal.aborted ? "cancelled" : "failed", error: (error as Error).message, finishedAt: new Date().toISOString(), note: undefined, noteKind: undefined };
227
+ }
228
+ update(final);
229
+ if (final.status === "success" && final.output.trim()) return { ok: true, text: final.output.trim().slice(0, 8000) };
230
+ // An expert that never answered does not use up a consult.
231
+ quota.consults = Math.max(0, (quota.consults ?? 1) - 1);
232
+ return { ok: false, text: `The expert did not answer (${final.status}${final.error ? `: ${final.error}` : ""}); carry on with your best reading.` };
233
+ };
234
+
163
235
  const token = randomUUID();
164
236
  const server = await startDeskServer(async (request) => {
165
237
  if (request.worker !== token) return { ok: false, text: "Unknown parent agent." };
166
238
  if (request.op === "cancel_delegate") { controller.abort(); return { ok: true, text: "Children cancelled." }; }
239
+ if (request.op === "cancel_consult") {
240
+ consultations.get(request.consultId ?? "")?.abort();
241
+ return { ok: true, text: "Consult cancelled." };
242
+ }
243
+ if (request.op === "consult") {
244
+ if (!consults) return { ok: false, text: "Consulting is off for this agent." };
245
+ if ((quota.consults ?? 0) >= MAX_CONSULTS) return { ok: false, text: `You have used your ${MAX_CONSULTS} consults for this step; decide with what you have, or report the open question.` };
246
+ const question = (request.text ?? "").trim();
247
+ if (!question) return { ok: false, text: "ask_expert needs a question." };
248
+ quota.consults = (quota.consults ?? 0) + 1;
249
+ // Its own controller, so the worker abandoning its tool call stops this expert and nothing else.
250
+ const own = new AbortController();
251
+ const parent = childSignal();
252
+ const stop = () => own.abort();
253
+ parent.addEventListener("abort", stop, { once: true });
254
+ const consultId = request.consultId ?? randomUUID();
255
+ consultations.set(consultId, own);
256
+ try {
257
+ return await consult(question.slice(0, 6000), own.signal);
258
+ } finally {
259
+ consultations.delete(consultId);
260
+ parent.removeEventListener("abort", stop);
261
+ }
262
+ }
167
263
  if (request.op !== "delegate") return { ok: false, text: "Unknown delegation operation." };
168
264
  const signal = childSignal();
169
265
  signal.throwIfAborted();
170
266
  const briefs = reserveChildren(quota, request.briefs);
171
267
  // A coordinating parent must release claims before waiting for children that may need them.
172
- desk?.releaseFiles(parentDeskId, () => "Delegating this work; re-read before editing.");
268
+ for (const held of new Set([desk, childDesk])) held?.releaseFiles(parentDeskId, () => "Delegating this work; re-read before editing.");
173
269
  const runs = briefs.map((brief) => {
174
270
  const promise = inPool(key, Math.max(1, config.workflow.maxParallelWorkers), signal, () => childRun(brief, signal));
175
271
  pending.add(promise);
@@ -188,10 +284,10 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
188
284
 
189
285
  let closed = false;
190
286
  return {
191
- tools: desk ? [...new Set([...tools, ...DESK_TOOLS])] : tools,
287
+ tools: desk ? [...new Set([...tools, ...desk.tools()])] : tools,
192
288
  mcpTools,
193
- env: { ...options.env, ...(desk ? desk.env(parentDeskId) : {}), [AGENT_ENV]: JSON.stringify({ ...policy, tools: desk ? [...new Set([...tools, ...DESK_TOOLS])] : tools }), [DELEGATION_ENV]: JSON.stringify({ address: server.address, token, timeoutMs: options.timeoutMs }) },
194
- note: `${canDelegate(agent, quota.used) ? `Work directly when you can finish the brief yourself. Use ${DELEGATE_TOOL} only for useful, separable work with disjoint files, at most five children total; children share your remaining time and cannot delegate further. Integrate and verify their work yourself. ` : ""}Use codemode for concise parallel calls, inspect failures, and return only useful evidence.${desk ? " Before edit/write, claim_file; handover_file when finished. Release files before delegating or waiting." : ""}`,
289
+ env: { ...options.env, ...(desk ? desk.env(parentDeskId) : {}), [AGENT_ENV]: JSON.stringify({ ...policy, tools: desk ? [...new Set([...tools, ...desk.tools()])] : tools }), [DELEGATION_ENV]: JSON.stringify({ address: server.address, token, timeoutMs: options.timeoutMs }) },
290
+ note: `${consults ? `When you are stuck on a hard step after a real attempt, or about to make a choice you are unsure of (a tricky algorithm, a subtle bug, an unfamiliar API), ${EXPERT_TOOL} asks a stronger model for advice, at most ${MAX_CONSULTS} times; it reads the repository and answers, you still do the work. ` : ""}${canDelegate(agent, quota.used) ? `Work directly when you can finish the brief yourself. Use ${DELEGATE_TOOL} only for useful, separable work with disjoint files, at most five children total; children share your remaining time and cannot delegate further. Integrate and verify their work yourself. ` : ""}Use codemode for concise parallel calls, inspect failures, and return only useful evidence.${desk?.usesClaims ? " Before edit/write, claim_file; handover_file when finished. Release files before delegating or waiting." : ""}`,
195
291
  onStart: (handle: Parameters<NonNullable<PiRunOptions["onStart"]>>[0]) => {
196
292
  desk?.attach(parentDeskId, { runId: id, steer: handle.steer, annotate: () => {} });
197
293
  options.onStart?.(handle);
@@ -211,7 +307,7 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
211
307
  clearTimeout(deadline);
212
308
  await Promise.allSettled([...pending]);
213
309
  await server.close();
214
- if (ownedDesk) { desk?.release(parentDeskId, () => "Parent finished; re-read before editing."); await ownedDesk.close(); }
310
+ if (ownedDesk) { ownedDesk.release(parentDeskId, () => "Parent finished; re-read before editing."); await ownedDesk.close(); }
215
311
  pool.users -= 1;
216
312
  if (pool.users === 0) pools.delete(key);
217
313
  },
@@ -29,7 +29,9 @@ export interface PiRunOptions {
29
29
  effortProfile?: RunProfileLike;
30
30
  agent?: { runId: string; taskId: string; domain: Domain; role: Role; depth?: number };
31
31
  mcpTools?: string[];
32
- delegationQuota?: { used: number };
32
+ delegationQuota?: { used: number; consults?: number };
33
+ /** The model and thinking ask_expert consults on, resolved by the oracle (its session model when settings name none). */
34
+ expert?: { model?: string; thinking: string };
33
35
  onChildRun?: (run: AgentRun) => void;
34
36
  cwd: string;
35
37
  task: string;