@a-t-h-i/bot-lobby 0.6.33 → 0.6.35
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 +30 -0
- package/package.json +7 -7
- package/src/desk/client-extension.ts +2 -2
- package/src/desk/ipc.ts +4 -3
- package/src/desk/session.ts +14 -1
- package/src/execution/agent-runner.ts +16 -92
- package/src/execution/delegation.ts +193 -0
- package/src/execution/live-run.ts +95 -0
- package/src/execution/pi-runner.ts +35 -9
- package/src/index.ts +5 -0
- package/src/lobby/planner.ts +9 -0
- package/src/lobby/pr-review.ts +9 -0
- package/src/lobby/quickfix.ts +16 -0
- package/src/master/master.ts +3 -1
- package/src/master/research.ts +1 -0
- package/src/pi/auto-compaction.ts +72 -0
- package/src/pi/delegation.ts +68 -0
- package/src/pi/model-support.ts +1 -1
- package/src/pi/plan-checklist.ts +18 -7
- package/src/pi/run-summary.ts +9 -0
- package/src/schemas/configuration.ts +19 -7
- package/src/schemas/findings.ts +4 -0
- package/src/schemas/task.ts +4 -0
- package/src/state/work-time.ts +4 -2
- package/src/webui/api/lobby.ts +1 -1
- package/src/webui/api/plan-facts.ts +4 -3
- package/src/webui/api/tasks.ts +1 -1
- package/src/webui/dev/fake-service.ts +1 -1
- package/src/webui/dev/fixtures.ts +1 -1
- package/src/webui/protocol.ts +2 -2
- package/src/workflow/workflow.ts +4 -1
- package/webui/dist/assets/index-C8hgfw6m.css +2 -0
- package/webui/dist/assets/{index-BFra71Rf.js → index-CoRTgsIG.js} +5 -4
- package/webui/dist/build.json +13 -13
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-D5_tWSK0.css +0 -2
package/README.md
CHANGED
|
@@ -124,3 +124,33 @@ Settings live in `~/.pi/bot-lobby/config.json` (`BOT_LOBBY_CONFIG_DIR` moves the
|
|
|
124
124
|
`/bot-lobby config` shows what is in effect. Colour themes you import, under a name you choose,
|
|
125
125
|
are kept in `themes.json` beside it, so every Pi session and project shows them. Tasks and
|
|
126
126
|
project knowledge stay in `.pi/bot-lobby/` inside your project.
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
Requires **Pi 1.0.x** (1.0.1 or later). Every bot-lobby agent can use
|
|
130
|
+
`delegate_subtasks` for substantial, separable work. Each parent gets five child
|
|
131
|
+
slots total, shared across retries; children inherit its model, thinking, role,
|
|
132
|
+
permissions, worktree and remaining time. Children report to their parent, which
|
|
133
|
+
integrates and verifies the result. Children cannot delegate further. Active
|
|
134
|
+
children share `workflow.maxParallelWorkers`; waiting parents do not use those
|
|
135
|
+
child slots. Cancelling or finishing a parent stops its children and releases
|
|
136
|
+
their file claims. Full reports are saved under `.pi/bot-lobby/delegations/`.
|
|
137
|
+
|
|
138
|
+
Active task steps show the parent and active worker children, for example
|
|
139
|
+
**Step 6 – Implement large feature (6 devs working)**. Queued and finished agents
|
|
140
|
+
are excluded. A child finishing does not complete a checklist step.
|
|
141
|
+
|
|
142
|
+
The oracle, agents and children compact at settled turn boundaries before context
|
|
143
|
+
reaches 250,000 estimated tokens, or earlier at 80% of the model window or Pi's
|
|
144
|
+
native reserve threshold. Recent messages and summaries retain unfinished work;
|
|
145
|
+
raw session history remains available. A failed summary leaves context intact.
|
|
146
|
+
The boundary gives headroom below 300,000 tokens; a single very large input or tool
|
|
147
|
+
result can temporarily exceed it before the next boundary.
|
|
148
|
+
|
|
149
|
+
Pi's native MCP, `tool_search` and `codemode` are available. **MCP tools** in each
|
|
150
|
+
agent's Settings entry grants exact tool names, one per line, such as
|
|
151
|
+
`mcp__github__get_issue`. In `config.json` the same field is `mcpTools: ["mcp__github__get_issue"]`
|
|
152
|
+
on `master`, `agents.backend`, `agents.designer`, `agents.qa`, `scout`, `researcher`,
|
|
153
|
+
`quickFix` and `planner`. Grants default to empty and children inherit them.
|
|
154
|
+
Read-only roles also require the server's `readOnlyHint: true`; hidden tools and
|
|
155
|
+
ungranted calls are blocked, including calls nested inside codemode. Configure
|
|
156
|
+
server connections through Pi's native MCP settings.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a-t-h-i/bot-lobby",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.35",
|
|
4
4
|
"description": "Structured multi-agent software engineering orchestrator for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -46,16 +46,16 @@
|
|
|
46
46
|
"test": "node --test --test-reporter=dot test/*.test.ts"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@earendil-works/pi-ai": "
|
|
50
|
-
"@earendil-works/pi-coding-agent": "
|
|
51
|
-
"@earendil-works/pi-tui": "
|
|
49
|
+
"@earendil-works/pi-ai": ">=1.0.1 <1.1.0",
|
|
50
|
+
"@earendil-works/pi-coding-agent": ">=1.0.1 <1.1.0",
|
|
51
|
+
"@earendil-works/pi-tui": ">=1.0.1 <1.1.0",
|
|
52
52
|
"typebox": "*"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@axe-core/playwright": "4.13.0",
|
|
56
|
-
"@earendil-works/pi-ai": "0.
|
|
57
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
58
|
-
"@earendil-works/pi-tui": "0.
|
|
56
|
+
"@earendil-works/pi-ai": "1.0.1",
|
|
57
|
+
"@earendil-works/pi-coding-agent": "1.0.1",
|
|
58
|
+
"@earendil-works/pi-tui": "1.0.1",
|
|
59
59
|
"@fontsource-variable/inter": "5.3.0",
|
|
60
60
|
"@playwright/test": "1.62.0",
|
|
61
61
|
"@tailwindcss/vite": "4.3.3",
|
|
@@ -42,8 +42,8 @@ export function registerDeskClient(pi: ExtensionAPI, client?: DeskClient): void
|
|
|
42
42
|
const path = editPath(event.input);
|
|
43
43
|
if (!path) return undefined;
|
|
44
44
|
const answer = await desk.request({ op: "check", path });
|
|
45
|
-
//
|
|
46
|
-
if (
|
|
45
|
+
// A vanished coordinator must not let an orphaned worker edit shared files.
|
|
46
|
+
if (answer.ok && answer.allowed) return undefined;
|
|
47
47
|
return { block: true, reason: answer.text };
|
|
48
48
|
});
|
|
49
49
|
|
package/src/desk/ipc.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { createConnection, createServer, type Socket } from "node:net";
|
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
|
|
12
|
-
export type DeskOp = "hello" | "check" | "claim" | "handover" | "mine" | "wait";
|
|
12
|
+
export type DeskOp = "hello" | "check" | "claim" | "handover" | "mine" | "wait" | "delegate" | "cancel_delegate";
|
|
13
13
|
|
|
14
14
|
export interface DeskRequest {
|
|
15
15
|
id: number;
|
|
@@ -17,6 +17,7 @@ export interface DeskRequest {
|
|
|
17
17
|
op: DeskOp;
|
|
18
18
|
path?: string;
|
|
19
19
|
intent?: string;
|
|
20
|
+
briefs?: string[];
|
|
20
21
|
note?: string;
|
|
21
22
|
timeoutMs?: number;
|
|
22
23
|
}
|
|
@@ -79,12 +80,12 @@ function serve(socket: Socket, handler: DeskHandler): void {
|
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
/** Start a desk server on a fresh private socket. */
|
|
82
|
-
export async function startDeskServer(handler: DeskHandler): Promise<DeskServer> {
|
|
83
|
+
export async function startDeskServer(handler: DeskHandler, onDisconnect?: () => void): Promise<DeskServer> {
|
|
83
84
|
const address = socketAddress();
|
|
84
85
|
const sockets = new Set<Socket>();
|
|
85
86
|
const server = createServer((socket) => {
|
|
86
87
|
sockets.add(socket);
|
|
87
|
-
socket.on("close", () => sockets.delete(socket));
|
|
88
|
+
socket.on("close", () => { sockets.delete(socket); onDisconnect?.(); });
|
|
88
89
|
serve(socket, handler);
|
|
89
90
|
});
|
|
90
91
|
await new Promise<void>((done, fail) => {
|
package/src/desk/session.ts
CHANGED
|
@@ -63,6 +63,12 @@ export interface DeskSessionOptions {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/** Master-side desk for one batch of parallel workers. */
|
|
66
|
+
const sessions = new Map<string, DeskSession>();
|
|
67
|
+
|
|
68
|
+
export function deskSessionAt(address: string): DeskSession | undefined {
|
|
69
|
+
return sessions.get(address);
|
|
70
|
+
}
|
|
71
|
+
|
|
66
72
|
export class DeskSession {
|
|
67
73
|
readonly desk: FileDesk;
|
|
68
74
|
private server?: DeskServer;
|
|
@@ -77,6 +83,7 @@ export class DeskSession {
|
|
|
77
83
|
|
|
78
84
|
async open(): Promise<void> {
|
|
79
85
|
this.server = await startDeskServer((request) => this.handle(request));
|
|
86
|
+
sessions.set(this.server.address, this);
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
/** Environment that points one worker's pi process at this desk. */
|
|
@@ -97,8 +104,13 @@ export class DeskSession {
|
|
|
97
104
|
|
|
98
105
|
/** End of one attempt: hand everything it held to whoever is next, with `note` for them. */
|
|
99
106
|
release(worker: WorkerId, note: (path: string, next: Claim) => string): void {
|
|
100
|
-
this.
|
|
107
|
+
this.releaseFiles(worker, note);
|
|
101
108
|
this.handles.delete(worker);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A coordinating worker releases claims but still receives steering messages. */
|
|
112
|
+
releaseFiles(worker: WorkerId, note: (path: string, next: Claim) => string): void {
|
|
113
|
+
this.handles.get(worker)?.annotate({ waitingFor: undefined });
|
|
102
114
|
for (const handover of this.desk.release(worker, note)) this.options.onHandover?.(handover);
|
|
103
115
|
}
|
|
104
116
|
|
|
@@ -107,6 +119,7 @@ export class DeskSession {
|
|
|
107
119
|
}
|
|
108
120
|
|
|
109
121
|
async close(): Promise<void> {
|
|
122
|
+
if (this.server) sessions.delete(this.server.address);
|
|
110
123
|
this.desk.close();
|
|
111
124
|
await this.server?.close();
|
|
112
125
|
this.server = undefined;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createLiveRun } from "./live-run.ts";
|
|
1
3
|
import type { Domain, Role } from "../schemas/agent.ts";
|
|
2
4
|
import type { AgentRun } from "../schemas/findings.ts";
|
|
3
5
|
import { roleSpec } from "../roles/registry.ts";
|
|
4
6
|
import { compilePrompt } from "../prompts/compiler.ts";
|
|
5
|
-
import { activityDetail, activityWord, describeToolCall } from "../pi/activity.ts";
|
|
6
|
-
import { shortDuration, truncate } from "../text.ts";
|
|
7
7
|
import { EditLog } from "../state/changes.ts";
|
|
8
8
|
import { formatMinutes, REPORT_GRACE_MS } from "../state/budget.ts";
|
|
9
|
-
import { runPiAgent, spawnPiProcess, type
|
|
9
|
+
import { runPiAgent, spawnPiProcess, type ProcessRunner, type RelayAsk } from "./pi-runner.ts";
|
|
10
10
|
import { ASK_ENV } from "../ask/relay.ts";
|
|
11
11
|
import { ASK_TOOL } from "../ask/types.ts";
|
|
12
12
|
import { agentOfRun, grantOption } from "../excalidraw/sessions.ts";
|
|
@@ -40,6 +40,8 @@ export interface AgentRequest {
|
|
|
40
40
|
/** Prompt layers selected for this run. */
|
|
41
41
|
context: AgentContext;
|
|
42
42
|
model?: string;
|
|
43
|
+
mcpTools?: string[];
|
|
44
|
+
delegationQuota?: { used: number };
|
|
43
45
|
thinking?: string;
|
|
44
46
|
timeoutMs: number;
|
|
45
47
|
cwd: string;
|
|
@@ -140,8 +142,9 @@ export async function runAgent(request: AgentRequest, run: ProcessRunner = spawn
|
|
|
140
142
|
// A failed attempt may have edited files before the retry: the run owns every edit.
|
|
141
143
|
const edits = new EditLog(request.cwd);
|
|
142
144
|
let last: AgentRun | undefined;
|
|
145
|
+
const quota = request.delegationQuota ??= { used: 0 };
|
|
143
146
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
144
|
-
last = await runAgentOnce(request, run, attempt, startedAt, edits);
|
|
147
|
+
last = await runAgentOnce(request, run, attempt, startedAt, edits, quota);
|
|
145
148
|
request.onAttemptEnd?.(last);
|
|
146
149
|
if (!retryable(last)) break;
|
|
147
150
|
// Under a budget a retry only uses what is left of the allotment.
|
|
@@ -187,8 +190,8 @@ function toolsFor(request: AgentRequest): readonly string[] | undefined {
|
|
|
187
190
|
* Run one domain/role agent in an isolated pi process. The role's tool
|
|
188
191
|
* allowlist comes from its spec, so read-only roles cannot modify anything.
|
|
189
192
|
*/
|
|
190
|
-
async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt: number, startedAt: string, edits: EditLog): Promise<AgentRun> {
|
|
191
|
-
const base = baseRun(request, `${request.taskId}:${request.domain}:${request.role}:${
|
|
193
|
+
async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt: number, startedAt: string, edits: EditLog, quota: { used: number }): Promise<AgentRun> {
|
|
194
|
+
const base = baseRun(request, `${request.taskId}:${request.domain}:${request.role}:${randomUUID()}`, startedAt, attempt);
|
|
192
195
|
request.onUpdate?.(base);
|
|
193
196
|
const live = createLiveRun(base, request, edits);
|
|
194
197
|
|
|
@@ -203,6 +206,13 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
|
|
|
203
206
|
const onTimeUp = time?.onTimeUp;
|
|
204
207
|
const result = await runPiAgent({
|
|
205
208
|
cwd: request.cwd,
|
|
209
|
+
agent: { runId: base.runId, taskId: request.taskId, domain: request.domain, role: request.role },
|
|
210
|
+
delegationQuota: quota,
|
|
211
|
+
onChildRun: (child) => {
|
|
212
|
+
for (const path of child.edited ?? []) edits.note("edit", { path });
|
|
213
|
+
request.onUpdate?.(child);
|
|
214
|
+
},
|
|
215
|
+
mcpTools: request.mcpTools,
|
|
206
216
|
task: request.instruction,
|
|
207
217
|
systemPrompt,
|
|
208
218
|
tools: toolsFor(request),
|
|
@@ -270,92 +280,6 @@ function describeError(error: string, current: AgentRun, stalled: boolean): stri
|
|
|
270
280
|
return `${error}${doing}`;
|
|
271
281
|
}
|
|
272
282
|
|
|
273
|
-
const THROTTLE_MS = 2000;
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Tracks one attempt's live state from its stream and reports it through
|
|
277
|
-
* `onUpdate`: activity changes, retries and notes immediately, counters and
|
|
278
|
-
* heartbeats at most every couple of seconds.
|
|
279
|
-
*/
|
|
280
|
-
function createLiveRun(base: AgentRun, request: AgentRequest, edits: EditLog) {
|
|
281
|
-
let state: AgentRun = base;
|
|
282
|
-
let lastEmit = 0;
|
|
283
|
-
const emit = (patch: Partial<AgentRun>, force: boolean) => {
|
|
284
|
-
state = { ...state, ...patch, lastEventAt: Date.now() };
|
|
285
|
-
const now = Date.now();
|
|
286
|
-
if (!force && now - lastEmit < THROTTLE_MS) return;
|
|
287
|
-
lastEmit = now;
|
|
288
|
-
request.onUpdate?.(state);
|
|
289
|
-
};
|
|
290
|
-
const changed = (activity: string, detail: string | undefined) => activity !== state.activity || detail !== state.detail;
|
|
291
|
-
const onEvent = (event: PiStreamEvent) => {
|
|
292
|
-
switch (event.type) {
|
|
293
|
-
case "tool_execution_start": {
|
|
294
|
-
edits.note(event.toolName, event.args);
|
|
295
|
-
const activity = activityWord(event.toolName);
|
|
296
|
-
const detail = activityDetail(event.toolName, event.args);
|
|
297
|
-
const step = describeToolCall(event.toolName, event.args);
|
|
298
|
-
emit({ activity, detail, step, tools: (state.tools ?? 0) + 1 }, changed(activity, detail) || step !== state.step);
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
case "thought":
|
|
302
|
-
emit({ thought: event.text }, true);
|
|
303
|
-
return;
|
|
304
|
-
case "thinking":
|
|
305
|
-
case "writing":
|
|
306
|
-
emit({ activity: event.type, detail: undefined }, changed(event.type, undefined));
|
|
307
|
-
return;
|
|
308
|
-
case "turn_start":
|
|
309
|
-
emit({ turns: (state.turns ?? 0) + 1 }, false);
|
|
310
|
-
return;
|
|
311
|
-
case "retry":
|
|
312
|
-
emit({ note: `provider retry ${event.attempt}/${event.maxAttempts}: ${truncateLine(event.error)}`, noteKind: "warning" }, true);
|
|
313
|
-
return;
|
|
314
|
-
case "retry_end":
|
|
315
|
-
emit({ note: undefined, noteKind: undefined }, true);
|
|
316
|
-
return;
|
|
317
|
-
case "compaction":
|
|
318
|
-
emit({ note: "compacting context", noteKind: "info" }, true);
|
|
319
|
-
return;
|
|
320
|
-
case "wrap_up":
|
|
321
|
-
emit({ note: `asked to wrap up (${shortDuration(request.timeoutMs)} limit)`, noteKind: "warning", wrappedUp: true }, true);
|
|
322
|
-
return;
|
|
323
|
-
case "time_up":
|
|
324
|
-
emit({ note: request.time?.onTimeUp ? "out of time: reporting where it left off" : "out of time: reporting", noteKind: "warning" }, true);
|
|
325
|
-
return;
|
|
326
|
-
case "extended":
|
|
327
|
-
emit({ endsAt: request.time?.endsAt, extendedMs: (state.extendedMs ?? 0) + event.ms }, true);
|
|
328
|
-
return;
|
|
329
|
-
case "asking":
|
|
330
|
-
emit({ note: `waiting on you: ${event.questions} question${event.questions === 1 ? "" : "s"}`, noteKind: "info" }, true);
|
|
331
|
-
return;
|
|
332
|
-
case "answered":
|
|
333
|
-
// Its time waited with it.
|
|
334
|
-
if (request.time) request.time.endsAt += event.waitedMs;
|
|
335
|
-
emit({ note: "you answered; carrying on", noteKind: "info", ...(request.time ? { endsAt: request.time.endsAt } : {}) }, true);
|
|
336
|
-
return;
|
|
337
|
-
case "usage": {
|
|
338
|
-
const usage = state.usage ?? { input: 0, output: 0, cost: 0, turns: 0 };
|
|
339
|
-
const next = { input: usage.input + event.input, output: usage.output + event.output, cost: usage.cost + event.cost, turns: usage.turns + 1 };
|
|
340
|
-
emit({ usage: next, model: event.model ?? state.model }, false);
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
default:
|
|
344
|
-
emit({}, false);
|
|
345
|
-
}
|
|
346
|
-
};
|
|
347
|
-
return {
|
|
348
|
-
onEvent,
|
|
349
|
-
annotate: (patch: LivePatch) => emit(patch, true),
|
|
350
|
-
current: () => state,
|
|
351
|
-
};
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
function truncateLine(text: string): string {
|
|
355
|
-
const first = text.split("\n")[0] ?? "";
|
|
356
|
-
return truncate(first, 48).split("\n")[0]!;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
283
|
async function mapWithConcurrencyLimit<TIn, TOut>(items: TIn[], concurrency: number, fn: (item: TIn) => Promise<TOut>): Promise<TOut[]> {
|
|
360
284
|
const limit = Math.max(1, Math.min(concurrency, items.length));
|
|
361
285
|
const results: TOut[] = new Array(items.length);
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { toolsOf } from "../excalidraw/sessions.ts";
|
|
2
|
+
import { appendMetrics, metricFromRun } from "../state/metrics.ts";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { AgentRun } from "../schemas/findings.ts";
|
|
8
|
+
import { agentProfile } from "../schemas/configuration.ts";
|
|
9
|
+
import { dataRoot, detectProjectRoot, loadConfig } from "../state/project.ts";
|
|
10
|
+
import { EditLog } from "../state/changes.ts";
|
|
11
|
+
import { createLiveRun } from "./live-run.ts";
|
|
12
|
+
import { runPiAgent, type PiRunOptions, type ProcessRunner } from "./pi-runner.ts";
|
|
13
|
+
import { startDeskServer } from "../desk/ipc.ts";
|
|
14
|
+
import { DESK_ENV, DESK_TOOLS, DeskSession, deskSessionAt } from "../desk/session.ts";
|
|
15
|
+
|
|
16
|
+
export const MAX_CHILDREN = 5;
|
|
17
|
+
export const DELEGATE_TOOL = "delegate_subtasks";
|
|
18
|
+
export const AGENT_ENV = "BOT_LOBBY_AGENT_POLICY";
|
|
19
|
+
export const DELEGATION_ENV = "BOT_LOBBY_DELEGATION";
|
|
20
|
+
|
|
21
|
+
/** One shared child pool per task; parents waiting on children never hold its slots. */
|
|
22
|
+
const pools = new Map<string, { active: number; users: number; waiters: Array<() => void> }>();
|
|
23
|
+
|
|
24
|
+
async function inPool<T>(key: string, limit: number, signal: AbortSignal, work: () => Promise<T>): Promise<T> {
|
|
25
|
+
const pool = pools.get(key)!;
|
|
26
|
+
while (pool.active >= limit) {
|
|
27
|
+
signal.throwIfAborted();
|
|
28
|
+
await new Promise<void>((resolve, reject) => {
|
|
29
|
+
const wake = () => { signal.removeEventListener("abort", abort); resolve(); };
|
|
30
|
+
const abort = () => { pool.waiters = pool.waiters.filter((entry) => entry !== wake); reject(signal.reason); };
|
|
31
|
+
pool.waiters.push(wake);
|
|
32
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
signal.throwIfAborted();
|
|
36
|
+
pool.active += 1;
|
|
37
|
+
try { return await work(); }
|
|
38
|
+
finally { pool.active -= 1; pool.waiters.shift()?.(); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function reserveChildren(quota: { used: number }, briefs: unknown): string[] {
|
|
42
|
+
if (!Array.isArray(briefs) || briefs.length === 0 || briefs.some((brief) => typeof brief !== "string" || !brief.trim() || brief.length > 32_000)) {
|
|
43
|
+
throw new Error("Provide non-empty self-contained briefs, each at most 32,000 characters.");
|
|
44
|
+
}
|
|
45
|
+
if (briefs.length > MAX_CHILDREN - quota.used) throw new Error(`Only ${MAX_CHILDREN - quota.used} of your five child slots remain.`);
|
|
46
|
+
quota.used += briefs.length;
|
|
47
|
+
return briefs.map((brief: string) => brief.trim());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface AgentPolicy {
|
|
51
|
+
tools: string[];
|
|
52
|
+
mcpTools: string[];
|
|
53
|
+
readOnly: boolean;
|
|
54
|
+
depth: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The host owns quotas, deadlines, file identities and child lifetimes, outside model context. */
|
|
58
|
+
export async function delegationHost(options: PiRunOptions, run: ProcessRunner) {
|
|
59
|
+
const config = loadConfig();
|
|
60
|
+
const id = options.agent?.runId ?? randomUUID();
|
|
61
|
+
const baseTools = options.tools ?? ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
|
62
|
+
const editable = baseTools.includes("edit") || baseTools.includes("write");
|
|
63
|
+
const agent = options.agent ?? { runId: id, taskId: id, domain: "backend" as const, role: editable ? "worker" as const : "reviewer" as const };
|
|
64
|
+
const depth = agent.depth ?? 1;
|
|
65
|
+
const quota = options.delegationQuota ?? { used: 0 };
|
|
66
|
+
const tools = [...new Set([...baseTools, ...(options.excalidraw ? toolsOf(options.excalidraw) : []), "codemode", "tool_search", ...(depth === 1 ? [DELEGATE_TOOL] : [])])];
|
|
67
|
+
const mcpTools = options.mcpTools ?? agentProfile(config, agent.domain, agent.role).mcpTools ?? [];
|
|
68
|
+
const policy: AgentPolicy = { tools, mcpTools, readOnly: !editable, depth };
|
|
69
|
+
if (depth > 1) return { tools, 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[] };
|
|
70
|
+
|
|
71
|
+
const address = options.env?.[DESK_ENV.address];
|
|
72
|
+
let desk = address ? deskSessionAt(address) : undefined;
|
|
73
|
+
if (address && !desk) throw new Error("The inherited file desk is unavailable.");
|
|
74
|
+
const ownedDesk = editable && !desk ? new DeskSession({ cwd: options.cwd }) : undefined;
|
|
75
|
+
if (ownedDesk) { await ownedDesk.open(); desk = ownedDesk; }
|
|
76
|
+
const controller = new AbortController();
|
|
77
|
+
const lifetime = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
|
|
78
|
+
let timeController = new AbortController();
|
|
79
|
+
const childSignal = () => AbortSignal.any([lifetime, timeController.signal]);
|
|
80
|
+
const key = `${options.cwd}:${agent.taskId}`;
|
|
81
|
+
const pool = pools.get(key) ?? { active: 0, users: 0, waiters: [] };
|
|
82
|
+
pool.users += 1;
|
|
83
|
+
pools.set(key, pool);
|
|
84
|
+
let deadlineAt = Date.now() + (options.time?.upAtMs ?? options.timeoutMs);
|
|
85
|
+
let deadline = setTimeout(() => timeController.abort(), Math.max(1, deadlineAt - Date.now()));
|
|
86
|
+
const children = new Map<string, AgentRun>();
|
|
87
|
+
const pending = new Set<Promise<AgentRun>>();
|
|
88
|
+
const parentDeskId = options.env?.[DESK_ENV.worker] ?? id;
|
|
89
|
+
const update = (child: AgentRun) => {
|
|
90
|
+
children.set(child.runId, child);
|
|
91
|
+
options.onChildRun?.(child);
|
|
92
|
+
if (child.status === "running") return;
|
|
93
|
+
// Keep full child reports outside the parent's bounded tool result, including lobby jobs.
|
|
94
|
+
const root = detectProjectRoot(options.cwd, CONFIG_DIR_NAME);
|
|
95
|
+
const folder = join(dataRoot(root, CONFIG_DIR_NAME), "delegations");
|
|
96
|
+
mkdirSync(folder, { recursive: true });
|
|
97
|
+
writeFileSync(join(folder, `${child.runId}.json`), `${JSON.stringify(child)}\n`);
|
|
98
|
+
if (!options.agent || options.agent.taskId === options.agent.runId) appendMetrics(root, CONFIG_DIR_NAME, [metricFromRun(child)]);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const childRun = async (brief: string, signal: AbortSignal): Promise<AgentRun> => {
|
|
102
|
+
const childId = randomUUID();
|
|
103
|
+
const base: AgentRun = { runId: childId, parentRunId: id, depth: 2, taskId: agent.taskId, domain: agent.domain, role: agent.role, instruction: brief, stepInstruction: options.task, status: "running", output: "", attempts: 1, startedAt: new Date().toISOString(), ...(options.thinking ? { thinking: options.thinking } : {}) };
|
|
104
|
+
const edits = new EditLog(options.cwd);
|
|
105
|
+
const live = createLiveRun(base, { onUpdate: update, timeoutMs: options.timeoutMs }, edits);
|
|
106
|
+
update(base);
|
|
107
|
+
try {
|
|
108
|
+
const left = deadlineAt - Date.now();
|
|
109
|
+
if (left <= 0) throw new Error("The parent's time is up.");
|
|
110
|
+
const result = await runPiAgent({
|
|
111
|
+
...options,
|
|
112
|
+
agent: { ...agent, runId: childId, depth: 2 },
|
|
113
|
+
task: brief,
|
|
114
|
+
tools: [...new Set([...tools.filter((tool) => tool !== DELEGATE_TOOL), ...(desk ? DESK_TOOLS : [])])],
|
|
115
|
+
mcpTools,
|
|
116
|
+
timeoutMs: left,
|
|
117
|
+
signal,
|
|
118
|
+
time: undefined,
|
|
119
|
+
wrapUpAtMs: Math.floor(left * 0.85),
|
|
120
|
+
delegationQuota: undefined,
|
|
121
|
+
onChildRun: undefined,
|
|
122
|
+
onEvent: live.onEvent,
|
|
123
|
+
env: { ...options.env, ...(desk ? desk.env(childId) : {}) },
|
|
124
|
+
onStart: (handle) => desk?.attach(childId, { runId: childId, steer: handle.steer, annotate: live.annotate }),
|
|
125
|
+
}, run);
|
|
126
|
+
const final: AgentRun = { ...live.current(), ...result, edited: edits.list(), finishedAt: new Date().toISOString(), note: undefined, noteKind: undefined };
|
|
127
|
+
update(final);
|
|
128
|
+
return final;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
const final: AgentRun = { ...live.current(), edited: edits.list(), status: signal.aborted ? "cancelled" : "failed", error: (error as Error).message, finishedAt: new Date().toISOString() };
|
|
131
|
+
update(final);
|
|
132
|
+
return final;
|
|
133
|
+
} finally {
|
|
134
|
+
desk?.release(childId, () => "Child finished; re-read the file and check its report before continuing.");
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const token = randomUUID();
|
|
139
|
+
const server = await startDeskServer(async (request) => {
|
|
140
|
+
if (request.worker !== token) return { ok: false, text: "Unknown parent agent." };
|
|
141
|
+
if (request.op === "cancel_delegate") { controller.abort(); return { ok: true, text: "Children cancelled." }; }
|
|
142
|
+
if (request.op !== "delegate") return { ok: false, text: "Unknown delegation operation." };
|
|
143
|
+
const signal = childSignal();
|
|
144
|
+
signal.throwIfAborted();
|
|
145
|
+
const briefs = reserveChildren(quota, request.briefs);
|
|
146
|
+
// A coordinating parent must release claims before waiting for children that may need them.
|
|
147
|
+
desk?.releaseFiles(parentDeskId, () => "Delegating this work; re-read before editing.");
|
|
148
|
+
const runs = briefs.map((brief) => {
|
|
149
|
+
const promise = inPool(key, Math.max(1, config.workflow.maxParallelWorkers), signal, () => childRun(brief, signal));
|
|
150
|
+
pending.add(promise);
|
|
151
|
+
void promise.finally(() => pending.delete(promise)).catch(() => {});
|
|
152
|
+
return promise;
|
|
153
|
+
});
|
|
154
|
+
const results = await Promise.all(runs);
|
|
155
|
+
return { ok: true, text: JSON.stringify(results.map((child) => ({ runId: child.runId, status: child.status, report: child.output.slice(0, 12_000), reportPath: join(dataRoot(detectProjectRoot(options.cwd, CONFIG_DIR_NAME), CONFIG_DIR_NAME), "delegations", `${child.runId}.json`), error: child.error, usage: child.usage }))) };
|
|
156
|
+
}, () => controller.abort()).catch(async (error) => {
|
|
157
|
+
clearTimeout(deadline);
|
|
158
|
+
if (ownedDesk) await ownedDesk.close();
|
|
159
|
+
pool.users -= 1;
|
|
160
|
+
if (pool.users === 0) pools.delete(key);
|
|
161
|
+
throw error;
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
let closed = false;
|
|
165
|
+
return {
|
|
166
|
+
tools: desk ? [...new Set([...tools, ...DESK_TOOLS])] : tools,
|
|
167
|
+
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 }) },
|
|
168
|
+
note: `For substantial, separable work, use ${DELEGATE_TOOL} with complete briefs (goal, files, contracts, constraints, done criteria). You have at most five children total; children cannot delegate. 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." : ""}`,
|
|
169
|
+
onStart: (handle: Parameters<NonNullable<PiRunOptions["onStart"]>>[0]) => {
|
|
170
|
+
desk?.attach(parentDeskId, { runId: id, steer: handle.steer, annotate: () => {} });
|
|
171
|
+
options.onStart?.(handle);
|
|
172
|
+
},
|
|
173
|
+
get children() { return [...children.values()]; },
|
|
174
|
+
pause() { clearTimeout(deadline); },
|
|
175
|
+
extend(ms: number, reset = false) {
|
|
176
|
+
deadlineAt = reset ? Date.now() + ms : deadlineAt + ms;
|
|
177
|
+
clearTimeout(deadline);
|
|
178
|
+
if (timeController.signal.aborted) timeController = new AbortController();
|
|
179
|
+
deadline = setTimeout(() => timeController.abort(), Math.max(1, deadlineAt - Date.now()));
|
|
180
|
+
},
|
|
181
|
+
async close() {
|
|
182
|
+
if (closed) return;
|
|
183
|
+
closed = true;
|
|
184
|
+
controller.abort();
|
|
185
|
+
clearTimeout(deadline);
|
|
186
|
+
await Promise.allSettled([...pending]);
|
|
187
|
+
await server.close();
|
|
188
|
+
if (ownedDesk) { desk?.release(parentDeskId, () => "Parent finished; re-read before editing."); await ownedDesk.close(); }
|
|
189
|
+
pool.users -= 1;
|
|
190
|
+
if (pool.users === 0) pools.delete(key);
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { AgentRequest, LivePatch } from "./agent-runner.ts";
|
|
2
|
+
import type { AgentRun } from "../schemas/findings.ts";
|
|
3
|
+
import type { PiStreamEvent } from "./pi-runner.ts";
|
|
4
|
+
import { EditLog } from "../state/changes.ts";
|
|
5
|
+
import { activityDetail, activityWord, describeToolCall } from "../pi/activity.ts";
|
|
6
|
+
import { shortDuration, truncate } from "../text.ts";
|
|
7
|
+
|
|
8
|
+
const THROTTLE_MS = 2000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Tracks one attempt's live state from its stream and reports it through
|
|
12
|
+
* `onUpdate`: activity changes, retries and notes immediately, counters and
|
|
13
|
+
* heartbeats at most every couple of seconds.
|
|
14
|
+
*/
|
|
15
|
+
export function createLiveRun(base: AgentRun, request: Pick<AgentRequest, "onUpdate" | "timeoutMs" | "time">, edits: EditLog) {
|
|
16
|
+
let state: AgentRun = base;
|
|
17
|
+
let lastEmit = 0;
|
|
18
|
+
const emit = (patch: Partial<AgentRun>, force: boolean) => {
|
|
19
|
+
state = { ...state, ...patch, lastEventAt: Date.now() };
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
if (!force && now - lastEmit < THROTTLE_MS) return;
|
|
22
|
+
lastEmit = now;
|
|
23
|
+
request.onUpdate?.(state);
|
|
24
|
+
};
|
|
25
|
+
const changed = (activity: string, detail: string | undefined) => activity !== state.activity || detail !== state.detail;
|
|
26
|
+
const onEvent = (event: PiStreamEvent) => {
|
|
27
|
+
switch (event.type) {
|
|
28
|
+
case "tool_execution_start": {
|
|
29
|
+
edits.note(event.toolName, event.args);
|
|
30
|
+
const activity = activityWord(event.toolName);
|
|
31
|
+
const detail = activityDetail(event.toolName, event.args);
|
|
32
|
+
const step = describeToolCall(event.toolName, event.args);
|
|
33
|
+
emit({ activity, detail, step, tools: (state.tools ?? 0) + 1 }, changed(activity, detail) || step !== state.step);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
case "thought":
|
|
37
|
+
emit({ thought: event.text }, true);
|
|
38
|
+
return;
|
|
39
|
+
case "thinking":
|
|
40
|
+
case "writing":
|
|
41
|
+
emit({ activity: event.type, detail: undefined }, changed(event.type, undefined));
|
|
42
|
+
return;
|
|
43
|
+
case "turn_start":
|
|
44
|
+
emit({ turns: (state.turns ?? 0) + 1 }, false);
|
|
45
|
+
return;
|
|
46
|
+
case "retry":
|
|
47
|
+
emit({ note: `provider retry ${event.attempt}/${event.maxAttempts}: ${truncateLine(event.error)}`, noteKind: "warning" }, true);
|
|
48
|
+
return;
|
|
49
|
+
case "retry_end":
|
|
50
|
+
emit({ note: undefined, noteKind: undefined }, true);
|
|
51
|
+
return;
|
|
52
|
+
case "compaction":
|
|
53
|
+
emit({ note: "compacting context", noteKind: "info" }, true);
|
|
54
|
+
return;
|
|
55
|
+
case "compaction_end":
|
|
56
|
+
emit({ note: event.error, noteKind: event.error ? "warning" : undefined }, true);
|
|
57
|
+
return;
|
|
58
|
+
case "wrap_up":
|
|
59
|
+
emit({ note: `asked to wrap up (${shortDuration(request.timeoutMs)} limit)`, noteKind: "warning", wrappedUp: true }, true);
|
|
60
|
+
return;
|
|
61
|
+
case "time_up":
|
|
62
|
+
emit({ note: request.time?.onTimeUp ? "out of time: reporting where it left off" : "out of time: reporting", noteKind: "warning" }, true);
|
|
63
|
+
return;
|
|
64
|
+
case "extended":
|
|
65
|
+
emit({ endsAt: request.time?.endsAt, extendedMs: (state.extendedMs ?? 0) + event.ms }, true);
|
|
66
|
+
return;
|
|
67
|
+
case "asking":
|
|
68
|
+
emit({ note: `waiting on you: ${event.questions} question${event.questions === 1 ? "" : "s"}`, noteKind: "info" }, true);
|
|
69
|
+
return;
|
|
70
|
+
case "answered":
|
|
71
|
+
// Its time waited with it.
|
|
72
|
+
if (request.time) request.time.endsAt += event.waitedMs;
|
|
73
|
+
emit({ note: "you answered; carrying on", noteKind: "info", ...(request.time ? { endsAt: request.time.endsAt } : {}) }, true);
|
|
74
|
+
return;
|
|
75
|
+
case "usage": {
|
|
76
|
+
const usage = state.usage ?? { input: 0, output: 0, cost: 0, turns: 0 };
|
|
77
|
+
const next = { input: usage.input + event.input, output: usage.output + event.output, cost: usage.cost + event.cost, turns: usage.turns + 1 };
|
|
78
|
+
emit({ usage: next, model: event.model ?? state.model }, false);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
default:
|
|
82
|
+
emit({}, false);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
onEvent,
|
|
87
|
+
annotate: (patch: LivePatch) => emit(patch, true),
|
|
88
|
+
current: () => state,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function truncateLine(text: string): string {
|
|
93
|
+
const first = text.split("\n")[0] ?? "";
|
|
94
|
+
return truncate(first, 48).split("\n")[0]!;
|
|
95
|
+
}
|