@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.
- package/package.json +1 -1
- package/prompts/expert.md +17 -0
- package/prompts/goal-check.md +44 -0
- package/prompts/master.md +60 -2
- package/prompts/worker.md +18 -0
- package/src/desk/board-client.ts +65 -0
- package/src/desk/board.ts +200 -0
- package/src/desk/client-extension.ts +31 -1
- package/src/desk/ipc.ts +12 -1
- package/src/desk/session.ts +129 -1
- package/src/execution/agent-runner.ts +11 -3
- package/src/execution/delegation.ts +111 -15
- package/src/execution/pi-runner.ts +3 -1
- package/src/execution/worker-trees.ts +396 -0
- package/src/index.ts +3 -0
- package/src/master/goal-check.ts +119 -0
- package/src/master/master.ts +50 -2
- package/src/pi/delegation.ts +21 -2
- package/src/pi/model-support.ts +7 -0
- package/src/pi/tools.ts +3 -2
- package/src/schemas/configuration.ts +34 -0
- package/src/schemas/task.ts +12 -0
- package/src/workflow/workflow.ts +183 -28
- package/webui/dist/build.json +1 -1
package/src/desk/session.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
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
|
|
84
|
-
|
|
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), ...(
|
|
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, ...(
|
|
142
|
-
onStart: (handle) =>
|
|
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
|
-
|
|
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, ...
|
|
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, ...
|
|
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) {
|
|
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;
|