@a-t-h-i/bot-lobby 0.6.53 → 0.6.55
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/README.md +1 -1
- package/package.json +1 -1
- package/prompts/expert.md +17 -0
- package/prompts/goal-check.md +44 -0
- package/prompts/master.md +70 -32
- package/prompts/worker.md +25 -11
- 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 +16 -4
- package/src/desk/session.ts +129 -1
- package/src/execution/agent-runner.ts +27 -132
- package/src/execution/delegation.ts +112 -38
- package/src/execution/live-run.ts +3 -14
- package/src/execution/pi-runner.ts +30 -163
- package/src/execution/worker-trees.ts +396 -0
- package/src/index.ts +6 -0
- package/src/lobby/feed.ts +1 -1
- package/src/lobby/host.ts +2 -0
- package/src/lobby/planner.ts +2 -2
- package/src/lobby/pr-review.ts +3 -3
- package/src/lobby/quickfix.ts +4 -3
- package/src/lobby/recovery.ts +2 -10
- package/src/lobby/runtime.ts +1 -1
- package/src/lobby/service.ts +3 -1
- package/src/master/goal-check.ts +114 -0
- package/src/master/master.ts +60 -24
- package/src/master/research.ts +3 -6
- package/src/pi/commands.ts +5 -45
- package/src/pi/delegation.ts +23 -4
- package/src/pi/events.ts +6 -19
- package/src/pi/grants.ts +89 -0
- package/src/pi/model-settings.ts +2 -4
- package/src/pi/model-support.ts +12 -5
- package/src/pi/route.ts +1 -1
- package/src/pi/run-summary.ts +3 -7
- package/src/pi/start-flags.ts +3 -3
- package/src/pi/start-task.ts +2 -8
- package/src/pi/tools.ts +10 -12
- package/src/roles/worker.ts +0 -18
- package/src/schemas/configuration.ts +81 -39
- package/src/schemas/findings.ts +0 -12
- package/src/schemas/task.ts +12 -1
- package/src/state/metrics.ts +1 -1
- package/src/state/work-clock.ts +140 -0
- package/src/state/work-time.ts +4 -7
- package/src/webui/api/index.ts +2 -1
- package/src/webui/api/settings.ts +6 -18
- package/src/webui/dev/fake-service.ts +14 -0
- package/src/webui/protocol.ts +23 -0
- package/src/workflow/workflow.ts +204 -344
- package/webui/dist/assets/{TerminalView-CbRc77C8.js → TerminalView-CIfjkb5H.js} +1 -1
- package/webui/dist/assets/index-BkXhtB6A.css +2 -0
- package/webui/dist/assets/index-DIM2-FUQ.js +114 -0
- package/webui/dist/build.json +10 -6
- package/webui/dist/index.html +2 -2
- package/src/state/budget.ts +0 -301
- package/src/state/step-limits.ts +0 -41
- package/webui/dist/assets/index-Cca5uEVs.js +0 -115
- package/webui/dist/assets/index-DsVdXmh5.css +0 -2
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. */
|
|
@@ -5,10 +5,8 @@ import type { AgentRun } from "../schemas/findings.ts";
|
|
|
5
5
|
import { roleSpec } from "../roles/registry.ts";
|
|
6
6
|
import { compilePrompt } from "../prompts/compiler.ts";
|
|
7
7
|
import { EditLog } from "../state/changes.ts";
|
|
8
|
-
import { formatMinutes, REPORT_GRACE_MS } from "../state/budget.ts";
|
|
9
8
|
import { runPiAgent, spawnPiProcess, type ProcessRunner, type RelayAsk } from "./pi-runner.ts";
|
|
10
9
|
import { ASK_ENV } from "../ask/relay.ts";
|
|
11
|
-
import { STEP_LIMIT_MS } from "../state/step-limits.ts";
|
|
12
10
|
import type { EffortRouter, RunProfileLike } from "../classifier/effort.ts";
|
|
13
11
|
import { ASK_TOOL } from "../ask/types.ts";
|
|
14
12
|
|
|
@@ -44,13 +42,18 @@ export interface AgentRequest {
|
|
|
44
42
|
instruction: string;
|
|
45
43
|
/** Prompt layers selected for this run. */
|
|
46
44
|
context: AgentContext;
|
|
45
|
+
/** A whole system prompt of its own instead of the compiled role layers (the goal check). */
|
|
46
|
+
systemPrompt?: string;
|
|
47
47
|
model?: string;
|
|
48
48
|
mcpTools?: string[];
|
|
49
49
|
/** False when the agent's settings turn its auto-compaction off. */
|
|
50
50
|
autoCompact?: boolean;
|
|
51
|
-
delegationQuota?: { used: number };
|
|
51
|
+
delegationQuota?: { used: number; consults?: number };
|
|
52
|
+
/** See PiRunOptions.expert. */
|
|
53
|
+
expert?: { model?: string; thinking: string };
|
|
52
54
|
thinking?: string;
|
|
53
|
-
|
|
55
|
+
/** Skill files this agent may load; absent keeps pi's own discovery. */
|
|
56
|
+
skills?: string[];
|
|
54
57
|
cwd: string;
|
|
55
58
|
signal?: AbortSignal;
|
|
56
59
|
onUpdate?: (run: AgentRun) => void;
|
|
@@ -60,8 +63,6 @@ export interface AgentRequest {
|
|
|
60
63
|
stallTimeoutMs?: number;
|
|
61
64
|
/** Silence allowed while one tool call runs. */
|
|
62
65
|
toolStallTimeoutMs?: number;
|
|
63
|
-
/** Fraction of `timeoutMs` at which the agent is asked to wrap up; 0 disables. */
|
|
64
|
-
wrapUpAt?: number;
|
|
65
66
|
/** Extra tools on top of the role allowlist (the file desk's tools). */
|
|
66
67
|
extraTools?: readonly string[];
|
|
67
68
|
env?: Record<string, string>;
|
|
@@ -72,76 +73,26 @@ export interface AgentRequest {
|
|
|
72
73
|
/** The classifier routed this run down from this configured profile (receipts and metrics show it). */
|
|
73
74
|
routedFrom?: string;
|
|
74
75
|
route?: string;
|
|
75
|
-
/**
|
|
76
|
-
time?: AgentTime;
|
|
77
|
-
/** Lets the agent ask the user (ask_user_question, relayed through the master); its clocks wait for the answers. */
|
|
76
|
+
/** Lets the agent ask the user (ask_user_question, relayed through the master). */
|
|
78
77
|
onAsk?: RelayAsk;
|
|
79
78
|
}
|
|
80
79
|
|
|
81
|
-
/**
|
|
82
|
-
* A run under a task time budget. The agent is told its minutes up front,
|
|
83
|
-
* warned at 75%, and asked to stop and report when they run out. A worker
|
|
84
|
-
* then reports where it left off and how much more it needs; `onTimeUp`
|
|
85
|
-
* (the engine asking the user) grants more, and the same agent carries on.
|
|
86
|
-
*/
|
|
87
|
-
export interface AgentTime {
|
|
88
|
-
/** Active time shared across attempts and model fallbacks. */
|
|
89
|
-
usedMs?: number;
|
|
90
|
-
/** Include reporting in the hard allowance. */
|
|
91
|
-
strict?: boolean;
|
|
92
|
-
/** When the delegation's time runs out (epoch ms); moved out when more is granted, shared by retries and re-runs. */
|
|
93
|
-
endsAt: number;
|
|
94
|
-
/** The allotment, as the agent and the lobby see it. */
|
|
95
|
-
allotMs: number;
|
|
96
|
-
/** What the agent is told about its time, in its context. */
|
|
97
|
-
note?: string;
|
|
98
|
-
/** Workers only: decide on more time once it has reported where it left off; resolve with ms, 0 to stop it. */
|
|
99
|
-
onTimeUp?: (run: AgentRun, report: string) => Promise<number>;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** The warning at 75% of an allotment. */
|
|
103
|
-
export function headsUpMessage(leftMs: number, allotMs: number): string {
|
|
104
|
-
return `bot-lobby: about ${formatMinutes(leftMs)} of your ${formatMinutes(allotMs)} for this step are left. Finish what you have started and make sure it works; leave nice-to-haves.`;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** The steer when an allotment is spent: a worker says where it left off and what more it needs; any other role reports what it has. */
|
|
108
|
-
export function timeUpMessage(allotMs: number, canAskForMore: boolean): string {
|
|
109
|
-
return canAskForMore
|
|
110
|
-
? `bot-lobby: your ${formatMinutes(allotMs)} for this step are up. Stop here: finish or revert the edit in progress so every file is consistent, start nothing new, and write your report now in the required format, adding \`## Left Off\` (what you were doing and what is still to do) and \`## More Time\` (how many more minutes you need, and why). The user decides whether you get more time to finish.`
|
|
111
|
-
: `bot-lobby: your ${formatMinutes(allotMs)} are up. Stop now and write your report in the required format with what you have.`;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** The follow-up that sends an agent on once the user gave it more time. */
|
|
115
|
-
export function carryOnMessage(extraMs: number): string {
|
|
116
|
-
return `bot-lobby: the user gave you ${formatMinutes(extraMs)} more. Carry on from where you left off and finish the step, then write your final report in the required format (leave out Left Off and More Time if you finish).`;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** Least time an attempt starts with; a retry with less than this left is not started. */
|
|
120
|
-
const MIN_ATTEMPT_MS = 60_000;
|
|
121
|
-
|
|
122
|
-
export function boundedAgentTime(timeoutMs: number): AgentTime {
|
|
123
|
-
const allotMs = Math.min(STEP_LIMIT_MS, timeoutMs);
|
|
124
|
-
return { endsAt: Date.now() + allotMs, allotMs, strict: true };
|
|
125
|
-
}
|
|
126
|
-
|
|
127
80
|
/** The watchdog fields of an AgentRequest, taken from the workflow config. */
|
|
128
81
|
export function watchdogOptions(workflow: {
|
|
129
82
|
maxAgentRetries: number;
|
|
130
83
|
stallTimeoutMs: number;
|
|
131
84
|
toolStallTimeoutMs: number;
|
|
132
|
-
|
|
133
|
-
}): Pick<AgentRequest, "retries" | "stallTimeoutMs" | "toolStallTimeoutMs" | "wrapUpAt"> {
|
|
85
|
+
}): Pick<AgentRequest, "retries" | "stallTimeoutMs" | "toolStallTimeoutMs"> {
|
|
134
86
|
return {
|
|
135
87
|
retries: workflow.maxAgentRetries,
|
|
136
88
|
stallTimeoutMs: workflow.stallTimeoutMs,
|
|
137
89
|
toolStallTimeoutMs: workflow.toolStallTimeoutMs,
|
|
138
|
-
wrapUpAt: workflow.wrapUpAt,
|
|
139
90
|
};
|
|
140
91
|
}
|
|
141
92
|
|
|
142
93
|
const activeControllers = new Set<AbortController>();
|
|
143
94
|
|
|
144
|
-
/** A stall or crash is worth one more try; a
|
|
95
|
+
/** A stall or crash is worth one more try; a cancel is not. */
|
|
145
96
|
function retryable(run: AgentRun): boolean {
|
|
146
97
|
if (run.status === "failed") return true;
|
|
147
98
|
return run.status === "timeout" && run.stalled === true;
|
|
@@ -149,23 +100,11 @@ function retryable(run: AgentRun): boolean {
|
|
|
149
100
|
|
|
150
101
|
/**
|
|
151
102
|
* Run one domain/role agent, retrying transient failures a bounded number of
|
|
152
|
-
* times. Cancellation
|
|
153
|
-
*
|
|
103
|
+
* times. Cancellation never retries, so Esc stays responsive. There is no
|
|
104
|
+
* time limit: an agent works until it reports, is cancelled, or stalls.
|
|
154
105
|
*/
|
|
155
106
|
export async function runAgent(request: AgentRequest, run: ProcessRunner = spawnPiProcess): Promise<AgentRun> {
|
|
156
|
-
request.time ??= boundedAgentTime(request.timeoutMs);
|
|
157
|
-
if (!request.time.strict) {
|
|
158
|
-
request.time.endsAt = Math.min(request.time.endsAt, Date.now() + Math.min(STEP_LIMIT_MS, request.timeoutMs));
|
|
159
|
-
request.time.allotMs = Math.min(request.time.allotMs, STEP_LIMIT_MS, request.timeoutMs);
|
|
160
|
-
request.time.strict = true;
|
|
161
|
-
}
|
|
162
|
-
request.time.usedMs ??= Math.max(0, request.time.allotMs - (request.time.endsAt - Date.now()));
|
|
163
107
|
const startedAt = new Date().toISOString();
|
|
164
|
-
if (request.time.endsAt <= Date.now()) {
|
|
165
|
-
const stopped: AgentRun = { ...baseRun(request, `${request.taskId}:${randomUUID()}`, startedAt), status: "timeout", error: "The assignment's shared time limit is spent; request user-approved time before continuing.", finishedAt: startedAt, timeUp: true };
|
|
166
|
-
request.onUpdate?.(stopped);
|
|
167
|
-
return stopped;
|
|
168
|
-
}
|
|
169
108
|
const attempts = Math.max(1, (request.retries ?? 0) + 1);
|
|
170
109
|
// A failed attempt may have edited files before the retry: the run owns every edit.
|
|
171
110
|
const edits = new EditLog(request.cwd);
|
|
@@ -175,8 +114,6 @@ export async function runAgent(request: AgentRequest, run: ProcessRunner = spawn
|
|
|
175
114
|
last = await runAgentOnce(request, run, attempt, startedAt, edits, quota);
|
|
176
115
|
request.onAttemptEnd?.(last);
|
|
177
116
|
if (!retryable(last)) break;
|
|
178
|
-
// Under a budget a retry only uses what is left of the allotment.
|
|
179
|
-
if (request.time && request.time.endsAt - Date.now() < MIN_ATTEMPT_MS) break;
|
|
180
117
|
}
|
|
181
118
|
const edited = edits.list();
|
|
182
119
|
return edited.length > 0 ? { ...last!, edited } : last!;
|
|
@@ -202,7 +139,6 @@ function baseRun(request: AgentRequest, runId: string, startedAt: string, attemp
|
|
|
202
139
|
...(request.thinking ? { thinking: request.thinking } : {}),
|
|
203
140
|
...(request.routedFrom ? { routedFrom: request.routedFrom } : {}),
|
|
204
141
|
...(request.route ? { route: request.route } : {}),
|
|
205
|
-
...(request.time ? { allotMs: request.time.allotMs, endsAt: request.time.endsAt } : {}),
|
|
206
142
|
...(attempts > 1 ? { note: `retry ${attempts - 1} of ${(request.retries ?? 0)}`, noteKind: "warning" as const } : {}),
|
|
207
143
|
};
|
|
208
144
|
}
|
|
@@ -221,34 +157,23 @@ function toolsFor(request: AgentRequest): readonly string[] | undefined {
|
|
|
221
157
|
async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt: number, startedAt: string, edits: EditLog, quota: { used: number }): Promise<AgentRun> {
|
|
222
158
|
const base = baseRun(request, `${request.taskId}:${request.domain}:${request.role}:${randomUUID()}`, startedAt, attempt);
|
|
223
159
|
request.onUpdate?.(base);
|
|
224
|
-
const time = request.time!;
|
|
225
160
|
const attemptStart = Date.now();
|
|
226
161
|
let pausedMs = 0;
|
|
227
162
|
let asking = 0;
|
|
228
163
|
let askStarted = 0;
|
|
229
|
-
let expired = false;
|
|
230
164
|
const elapsed = () => Math.max(0, Date.now() - attemptStart - pausedMs - (asking ? Date.now() - askStarted : 0));
|
|
231
|
-
const live = createLiveRun(base, {
|
|
165
|
+
const live = createLiveRun(base, { onUpdate: (update) => request.onUpdate?.({ ...update, activeMs: elapsed() }) }, edits);
|
|
232
166
|
|
|
233
167
|
const controller = new AbortController();
|
|
234
168
|
activeControllers.add(controller);
|
|
235
169
|
const signal = request.signal ? AbortSignal.any([request.signal, controller.signal]) : controller.signal;
|
|
236
|
-
let deadline: ReturnType<typeof setTimeout>;
|
|
237
|
-
const arm = () => {
|
|
238
|
-
clearTimeout(deadline);
|
|
239
|
-
deadline = setTimeout(() => { expired = true; controller.abort(new Error("Assignment time limit reached")); }, Math.max(0, time.endsAt - Date.now()));
|
|
240
|
-
};
|
|
241
|
-
arm();
|
|
242
170
|
try {
|
|
243
|
-
const systemPrompt = compilePrompt({ domain: request.domain, role: request.role, ...request.context });
|
|
244
|
-
const left = Math.max(1, time.endsAt - Date.now());
|
|
245
|
-
const reportMs = Math.min(REPORT_GRACE_MS, Math.floor(left / 4));
|
|
246
|
-
const wrapUp = Math.floor(left * (request.wrapUpAt || 0.75));
|
|
247
|
-
const onTimeUp = time?.onTimeUp;
|
|
171
|
+
const systemPrompt = request.systemPrompt ?? compilePrompt({ domain: request.domain, role: request.role, ...request.context });
|
|
248
172
|
const result = await runPiAgent({
|
|
249
173
|
cwd: request.cwd,
|
|
250
174
|
agent: { runId: base.runId, taskId: request.taskId, domain: request.domain, role: request.role },
|
|
251
175
|
delegationQuota: quota,
|
|
176
|
+
...(request.expert ? { expert: request.expert } : {}),
|
|
252
177
|
effort: request.effort,
|
|
253
178
|
effortProfile: request.effortProfile,
|
|
254
179
|
onChildRun: (child) => {
|
|
@@ -256,88 +181,55 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
|
|
|
256
181
|
request.onUpdate?.(child);
|
|
257
182
|
},
|
|
258
183
|
mcpTools: request.mcpTools,
|
|
184
|
+
...(request.skills ? { skills: request.skills } : {}),
|
|
259
185
|
...(request.autoCompact === false ? { autoCompact: false } : {}),
|
|
260
186
|
task: request.instruction,
|
|
261
187
|
systemPrompt,
|
|
262
188
|
tools: toolsFor(request),
|
|
263
189
|
model: request.model,
|
|
264
190
|
thinking: request.thinking,
|
|
265
|
-
timeoutMs: left,
|
|
266
|
-
...(time ? {
|
|
267
|
-
time: {
|
|
268
|
-
hardLimit: true,
|
|
269
|
-
upAtMs: Math.max(1, left - reportMs),
|
|
270
|
-
headsUpAtMs: left >= 4 * 60_000 ? Math.floor(left * 0.75) : 0,
|
|
271
|
-
headsUpMessage: headsUpMessage(left - Math.floor(left * 0.75), time.allotMs),
|
|
272
|
-
upMessage: `bot-lobby: your shared step allowance is nearly spent. Start nothing new; finish or revert the edit in progress and report completed work, verification, failed approaches, blockers, and the next action now.${onTimeUp ? " Include ## Left Off and ## More Time (minutes and reason). Only the user can grant more time." : ""}`,
|
|
273
|
-
graceMs: reportMs,
|
|
274
|
-
...(onTimeUp ? {
|
|
275
|
-
onTimeUp: async (report: string) => {
|
|
276
|
-
clearTimeout(deadline);
|
|
277
|
-
const waiting = Date.now();
|
|
278
|
-
let extraMs: number;
|
|
279
|
-
try { extraMs = await onTimeUp(live.current(), report); }
|
|
280
|
-
finally { pausedMs += Date.now() - waiting; }
|
|
281
|
-
time.endsAt += Date.now() - waiting;
|
|
282
|
-
if (extraMs > 0) {
|
|
283
|
-
time.endsAt = Date.now() + extraMs;
|
|
284
|
-
live.annotate({ note: `given ${formatMinutes(extraMs)} more`, noteKind: "info" });
|
|
285
|
-
}
|
|
286
|
-
arm();
|
|
287
|
-
return { extraMs, message: carryOnMessage(extraMs) };
|
|
288
|
-
},
|
|
289
|
-
} : {}),
|
|
290
|
-
},
|
|
291
|
-
} : {}),
|
|
292
191
|
signal,
|
|
293
192
|
...(request.onAsk ? { onAsk: request.onAsk } : {}),
|
|
294
193
|
env: request.onAsk ? { ...request.env, [ASK_ENV]: "1" } : request.env,
|
|
295
194
|
stallTimeoutMs: request.stallTimeoutMs,
|
|
296
195
|
toolStallTimeoutMs: request.toolStallTimeoutMs,
|
|
297
|
-
wrapUpAtMs: wrapUp,
|
|
298
196
|
onEvent: (event) => {
|
|
299
|
-
if (event.type === "asking") { if (!asking) askStarted = Date.now(); asking += 1;
|
|
197
|
+
if (event.type === "asking") { if (!asking) askStarted = Date.now(); asking += 1; }
|
|
300
198
|
if (event.type === "answered") { asking = 0; pausedMs += event.waitedMs; }
|
|
301
199
|
live.onEvent(event);
|
|
302
|
-
if (event.type === "answered" && asking === 0) arm();
|
|
303
200
|
},
|
|
304
201
|
onStart: (handle) => request.onStart?.({ runId: base.runId, steer: handle.steer, annotate: live.annotate }),
|
|
305
202
|
}, run);
|
|
306
203
|
const current = live.current();
|
|
307
204
|
const final: AgentRun = {
|
|
308
205
|
...base,
|
|
309
|
-
status:
|
|
206
|
+
status: result.status,
|
|
310
207
|
output: result.output,
|
|
311
|
-
error:
|
|
208
|
+
error: result.error ? describeError(result.error, current, result.stalled === true) : undefined,
|
|
312
209
|
usage: result.usage,
|
|
313
210
|
finishedAt: new Date().toISOString(),
|
|
314
|
-
activeMs:
|
|
211
|
+
activeMs: elapsed(),
|
|
315
212
|
turns: current.turns ?? (result.usage.turns || undefined),
|
|
316
213
|
tools: current.tools,
|
|
317
214
|
model: result.model ?? current.model,
|
|
318
215
|
...(result.stalled ? { stalled: true } : {}),
|
|
319
|
-
...(result.wrappedUp ? { wrappedUp: true } : {}),
|
|
320
|
-
...(result.timeUp || expired ? { timeUp: true } : {}),
|
|
321
|
-
...(result.extendedMs ? { extendedMs: result.extendedMs } : {}),
|
|
322
216
|
note: undefined,
|
|
323
217
|
noteKind: undefined,
|
|
324
218
|
};
|
|
325
219
|
request.onUpdate?.(final);
|
|
326
220
|
return final;
|
|
327
221
|
} catch (error) {
|
|
328
|
-
const final: AgentRun = { ...live.current(), status:
|
|
222
|
+
const final: AgentRun = { ...live.current(), status: signal.aborted ? "cancelled" : "failed", error: (error as Error).message, activeMs: elapsed(), edited: edits.list(), finishedAt: new Date().toISOString(), note: undefined, noteKind: undefined };
|
|
329
223
|
request.onUpdate?.(final);
|
|
330
224
|
return final;
|
|
331
225
|
} finally {
|
|
332
|
-
clearTimeout(deadline!);
|
|
333
|
-
time.usedMs = (time.usedMs ?? 0) + elapsed();
|
|
334
226
|
activeControllers.delete(controller);
|
|
335
227
|
}
|
|
336
228
|
}
|
|
337
229
|
|
|
338
|
-
/** Name what the agent was doing when a stall
|
|
230
|
+
/** Name what the agent was doing when a stall hit. */
|
|
339
231
|
function describeError(error: string, current: AgentRun, stalled: boolean): string {
|
|
340
|
-
if (!stalled
|
|
232
|
+
if (!stalled) return error;
|
|
341
233
|
const doing = current.activity ? ` while ${current.activity}${current.detail ? ` ${current.detail}` : ""}` : "";
|
|
342
234
|
return `${error}${doing}`;
|
|
343
235
|
}
|
|
@@ -353,7 +245,10 @@ async function mapWithConcurrencyLimit<TIn, TOut>(items: TIn[], concurrency: num
|
|
|
353
245
|
results[index] = await fn(items[index]!);
|
|
354
246
|
}
|
|
355
247
|
});
|
|
356
|
-
|
|
248
|
+
// Every worker settles before a failure is raised, so no caller cleans up under a sibling still running.
|
|
249
|
+
const settled = await Promise.allSettled(workers);
|
|
250
|
+
const failed = settled.find((entry): entry is PromiseRejectedResult => entry.status === "rejected");
|
|
251
|
+
if (failed) throw failed.reason;
|
|
357
252
|
return results;
|
|
358
253
|
}
|
|
359
254
|
|