@a-t-h-i/bot-lobby 0.6.54 → 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/master.md +10 -30
- package/prompts/worker.md +8 -12
- package/src/desk/ipc.ts +4 -3
- package/src/execution/agent-runner.ts +16 -129
- package/src/execution/delegation.ts +10 -32
- package/src/execution/live-run.ts +3 -14
- package/src/execution/pi-runner.ts +27 -162
- package/src/index.ts +3 -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 +1 -6
- package/src/master/master.ts +10 -22
- package/src/master/research.ts +3 -6
- package/src/pi/commands.ts +5 -45
- package/src/pi/delegation.ts +4 -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 +6 -6
- 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 +8 -11
- package/src/roles/worker.ts +0 -18
- package/src/schemas/configuration.ts +47 -39
- package/src/schemas/findings.ts +0 -12
- package/src/schemas/task.ts +0 -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 +23 -318
- 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/README.md
CHANGED
|
@@ -73,4 +73,4 @@ Settings live in `~/.pi/bot-lobby/config.json` (`BOT_LOBBY_CONFIG_DIR` moves the
|
|
|
73
73
|
project knowledge stay in `.pi/bot-lobby/` inside your project.
|
|
74
74
|
|
|
75
75
|
The [guide](docs/guide.md) covers the rest: delivery and commits, crash recovery, knowledge and
|
|
76
|
-
shared scripts, the classifier, delegation,
|
|
76
|
+
shared scripts, the classifier, delegation, compaction, and MCP tools and skills.
|
package/package.json
CHANGED
package/prompts/master.md
CHANGED
|
@@ -339,36 +339,16 @@ agents share facts without you:
|
|
|
339
339
|
`reports/` folder and the report gives its path; long findings go in the
|
|
340
340
|
task's `notes/` folder. Read the file when the detail matters rather than
|
|
341
341
|
asking for it again.
|
|
342
|
-
- A report flagged as
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
##
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
repeating failed approaches. Waiting for the user pauses clocks.
|
|
353
|
-
|
|
354
|
-
When the task has a time budget (your context says how much is used and
|
|
355
|
-
left), it covers everyone: you, scouts, workers and the QA gate. The clock
|
|
356
|
-
runs while you work and stops while you wait on the user.
|
|
357
|
-
|
|
358
|
-
- Size the plan to fit it, and say so in the proposal when it does not.
|
|
359
|
-
- Divide what is left by scope: give each `implement` its `minutes` (per
|
|
360
|
-
assignment in a parallel batch). Bigger steps get more; keep the QA gate's
|
|
361
|
-
reserve (the engine holds it back). Without `minutes` a step gets an even
|
|
362
|
-
share.
|
|
363
|
-
- Every agent is told its minutes. One that runs out stops, reports what it
|
|
364
|
-
did, where it left off and how much more it needs, and the user decides; if
|
|
365
|
-
they give it more, the same agent carries on where it stopped. A step that
|
|
366
|
-
was not given more comes back unfinished: trim the scope, or ask for task
|
|
367
|
-
time.
|
|
368
|
-
- When the budget is spent the engine starts no new work. Ask the user with
|
|
369
|
-
`action=budget` (`minutes` and a `reason`: what is left and why it is worth
|
|
370
|
-
it), or wrap up with what is done. `action=budget` with no minutes shows
|
|
371
|
-
where it stands.
|
|
342
|
+
- A report flagged as stalled may be partial: check what is missing and
|
|
343
|
+
delegate only the remainder.
|
|
344
|
+
|
|
345
|
+
## No time limits
|
|
346
|
+
|
|
347
|
+
Agents have no time limit. An assignment runs until its agent reports, the
|
|
348
|
+
user stops it, or it goes silent so long that the stall watchdog stops it.
|
|
349
|
+
Size steps by scope, not by the clock, and never cut a step short or split it
|
|
350
|
+
to save time. In auto mode keep going until the task is complete or truly
|
|
351
|
+
blocked: nobody needs to grant more time.
|
|
372
352
|
|
|
373
353
|
## Git branch or worktree
|
|
374
354
|
|
package/prompts/worker.md
CHANGED
|
@@ -58,18 +58,14 @@ no code deleted or bent just to make a rule pass. Every one of them is listed
|
|
|
58
58
|
for QA, who reads it as a finding unless it carries a reason that holds. When
|
|
59
59
|
a rule is wrong for your change, leave it failing and say why under Notes.
|
|
60
60
|
|
|
61
|
-
##
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
time is up you are told to stop: finish or revert the edit in progress, then
|
|
70
|
-
report with `## Left Off` (what you were doing, what is still to do) and
|
|
71
|
-
`## More Time` (`N minutes — why`), honestly sized. If the user gives you more,
|
|
72
|
-
you carry on from where you stopped.
|
|
61
|
+
## Pace
|
|
62
|
+
|
|
63
|
+
There is no time limit: finish the step properly and verify it rather than
|
|
64
|
+
cutting it short. Work efficiently all the same: read what you need, make the
|
|
65
|
+
change, verify, report, and do not explore past what the step needs. Keep
|
|
66
|
+
every file consistent as you go. A long silent command (one that prints
|
|
67
|
+
nothing for many minutes) can be stopped as stalled, so give such commands
|
|
68
|
+
output or a bash `timeout`.
|
|
73
69
|
|
|
74
70
|
## Working alongside other workers (file desk)
|
|
75
71
|
|
package/src/desk/ipc.ts
CHANGED
|
@@ -132,7 +132,7 @@ export interface DeskClient {
|
|
|
132
132
|
export function createDeskClient(address: string, worker: string): DeskClient {
|
|
133
133
|
let socket: Socket | undefined;
|
|
134
134
|
let nextId = 1;
|
|
135
|
-
const pending = new Map<number, { done: (response: DeskResponse) => void; timer: ReturnType<typeof setTimeout> }>();
|
|
135
|
+
const pending = new Map<number, { done: (response: DeskResponse) => void; timer: ReturnType<typeof setTimeout> | undefined }>();
|
|
136
136
|
const failAll = (text: string) => {
|
|
137
137
|
for (const [id, entry] of pending) {
|
|
138
138
|
clearTimeout(entry.timer);
|
|
@@ -170,10 +170,11 @@ export function createDeskClient(address: string, worker: string): DeskClient {
|
|
|
170
170
|
request(request, timeoutMs = 10_000) {
|
|
171
171
|
const id = nextId++;
|
|
172
172
|
return new Promise((done) => {
|
|
173
|
-
|
|
173
|
+
// 0 waits for as long as the answer takes (a delegation, a consult): no time limit.
|
|
174
|
+
const timer = timeoutMs > 0 ? setTimeout(() => {
|
|
174
175
|
pending.delete(id);
|
|
175
176
|
done({ id, ok: false, text: "the file desk did not answer" });
|
|
176
|
-
}, timeoutMs);
|
|
177
|
+
}, timeoutMs) : undefined;
|
|
177
178
|
pending.set(id, { done, timer });
|
|
178
179
|
try {
|
|
179
180
|
connect().write(`${JSON.stringify({ ...request, id, worker })}\n`);
|
|
@@ -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
|
|
|
@@ -54,7 +52,8 @@ export interface AgentRequest {
|
|
|
54
52
|
/** See PiRunOptions.expert. */
|
|
55
53
|
expert?: { model?: string; thinking: string };
|
|
56
54
|
thinking?: string;
|
|
57
|
-
|
|
55
|
+
/** Skill files this agent may load; absent keeps pi's own discovery. */
|
|
56
|
+
skills?: string[];
|
|
58
57
|
cwd: string;
|
|
59
58
|
signal?: AbortSignal;
|
|
60
59
|
onUpdate?: (run: AgentRun) => void;
|
|
@@ -64,8 +63,6 @@ export interface AgentRequest {
|
|
|
64
63
|
stallTimeoutMs?: number;
|
|
65
64
|
/** Silence allowed while one tool call runs. */
|
|
66
65
|
toolStallTimeoutMs?: number;
|
|
67
|
-
/** Fraction of `timeoutMs` at which the agent is asked to wrap up; 0 disables. */
|
|
68
|
-
wrapUpAt?: number;
|
|
69
66
|
/** Extra tools on top of the role allowlist (the file desk's tools). */
|
|
70
67
|
extraTools?: readonly string[];
|
|
71
68
|
env?: Record<string, string>;
|
|
@@ -76,76 +73,26 @@ export interface AgentRequest {
|
|
|
76
73
|
/** The classifier routed this run down from this configured profile (receipts and metrics show it). */
|
|
77
74
|
routedFrom?: string;
|
|
78
75
|
route?: string;
|
|
79
|
-
/**
|
|
80
|
-
time?: AgentTime;
|
|
81
|
-
/** 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). */
|
|
82
77
|
onAsk?: RelayAsk;
|
|
83
78
|
}
|
|
84
79
|
|
|
85
|
-
/**
|
|
86
|
-
* A run under a task time budget. The agent is told its minutes up front,
|
|
87
|
-
* warned at 75%, and asked to stop and report when they run out. A worker
|
|
88
|
-
* then reports where it left off and how much more it needs; `onTimeUp`
|
|
89
|
-
* (the engine asking the user) grants more, and the same agent carries on.
|
|
90
|
-
*/
|
|
91
|
-
export interface AgentTime {
|
|
92
|
-
/** Active time shared across attempts and model fallbacks. */
|
|
93
|
-
usedMs?: number;
|
|
94
|
-
/** Include reporting in the hard allowance. */
|
|
95
|
-
strict?: boolean;
|
|
96
|
-
/** When the delegation's time runs out (epoch ms); moved out when more is granted, shared by retries and re-runs. */
|
|
97
|
-
endsAt: number;
|
|
98
|
-
/** The allotment, as the agent and the lobby see it. */
|
|
99
|
-
allotMs: number;
|
|
100
|
-
/** What the agent is told about its time, in its context. */
|
|
101
|
-
note?: string;
|
|
102
|
-
/** Workers only: decide on more time once it has reported where it left off; resolve with ms, 0 to stop it. */
|
|
103
|
-
onTimeUp?: (run: AgentRun, report: string) => Promise<number>;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** The warning at 75% of an allotment. */
|
|
107
|
-
export function headsUpMessage(leftMs: number, allotMs: number): string {
|
|
108
|
-
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.`;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** 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. */
|
|
112
|
-
export function timeUpMessage(allotMs: number, canAskForMore: boolean): string {
|
|
113
|
-
return canAskForMore
|
|
114
|
-
? `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.`
|
|
115
|
-
: `bot-lobby: your ${formatMinutes(allotMs)} are up. Stop now and write your report in the required format with what you have.`;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** The follow-up that sends an agent on once the user gave it more time. */
|
|
119
|
-
export function carryOnMessage(extraMs: number): string {
|
|
120
|
-
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).`;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Least time an attempt starts with; a retry with less than this left is not started. */
|
|
124
|
-
const MIN_ATTEMPT_MS = 60_000;
|
|
125
|
-
|
|
126
|
-
export function boundedAgentTime(timeoutMs: number): AgentTime {
|
|
127
|
-
const allotMs = Math.min(STEP_LIMIT_MS, timeoutMs);
|
|
128
|
-
return { endsAt: Date.now() + allotMs, allotMs, strict: true };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
80
|
/** The watchdog fields of an AgentRequest, taken from the workflow config. */
|
|
132
81
|
export function watchdogOptions(workflow: {
|
|
133
82
|
maxAgentRetries: number;
|
|
134
83
|
stallTimeoutMs: number;
|
|
135
84
|
toolStallTimeoutMs: number;
|
|
136
|
-
|
|
137
|
-
}): Pick<AgentRequest, "retries" | "stallTimeoutMs" | "toolStallTimeoutMs" | "wrapUpAt"> {
|
|
85
|
+
}): Pick<AgentRequest, "retries" | "stallTimeoutMs" | "toolStallTimeoutMs"> {
|
|
138
86
|
return {
|
|
139
87
|
retries: workflow.maxAgentRetries,
|
|
140
88
|
stallTimeoutMs: workflow.stallTimeoutMs,
|
|
141
89
|
toolStallTimeoutMs: workflow.toolStallTimeoutMs,
|
|
142
|
-
wrapUpAt: workflow.wrapUpAt,
|
|
143
90
|
};
|
|
144
91
|
}
|
|
145
92
|
|
|
146
93
|
const activeControllers = new Set<AbortController>();
|
|
147
94
|
|
|
148
|
-
/** A stall or crash is worth one more try; a
|
|
95
|
+
/** A stall or crash is worth one more try; a cancel is not. */
|
|
149
96
|
function retryable(run: AgentRun): boolean {
|
|
150
97
|
if (run.status === "failed") return true;
|
|
151
98
|
return run.status === "timeout" && run.stalled === true;
|
|
@@ -153,23 +100,11 @@ function retryable(run: AgentRun): boolean {
|
|
|
153
100
|
|
|
154
101
|
/**
|
|
155
102
|
* Run one domain/role agent, retrying transient failures a bounded number of
|
|
156
|
-
* times. Cancellation
|
|
157
|
-
*
|
|
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.
|
|
158
105
|
*/
|
|
159
106
|
export async function runAgent(request: AgentRequest, run: ProcessRunner = spawnPiProcess): Promise<AgentRun> {
|
|
160
|
-
request.time ??= boundedAgentTime(request.timeoutMs);
|
|
161
|
-
if (!request.time.strict) {
|
|
162
|
-
request.time.endsAt = Math.min(request.time.endsAt, Date.now() + Math.min(STEP_LIMIT_MS, request.timeoutMs));
|
|
163
|
-
request.time.allotMs = Math.min(request.time.allotMs, STEP_LIMIT_MS, request.timeoutMs);
|
|
164
|
-
request.time.strict = true;
|
|
165
|
-
}
|
|
166
|
-
request.time.usedMs ??= Math.max(0, request.time.allotMs - (request.time.endsAt - Date.now()));
|
|
167
107
|
const startedAt = new Date().toISOString();
|
|
168
|
-
if (request.time.endsAt <= Date.now()) {
|
|
169
|
-
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 };
|
|
170
|
-
request.onUpdate?.(stopped);
|
|
171
|
-
return stopped;
|
|
172
|
-
}
|
|
173
108
|
const attempts = Math.max(1, (request.retries ?? 0) + 1);
|
|
174
109
|
// A failed attempt may have edited files before the retry: the run owns every edit.
|
|
175
110
|
const edits = new EditLog(request.cwd);
|
|
@@ -179,8 +114,6 @@ export async function runAgent(request: AgentRequest, run: ProcessRunner = spawn
|
|
|
179
114
|
last = await runAgentOnce(request, run, attempt, startedAt, edits, quota);
|
|
180
115
|
request.onAttemptEnd?.(last);
|
|
181
116
|
if (!retryable(last)) break;
|
|
182
|
-
// Under a budget a retry only uses what is left of the allotment.
|
|
183
|
-
if (request.time && request.time.endsAt - Date.now() < MIN_ATTEMPT_MS) break;
|
|
184
117
|
}
|
|
185
118
|
const edited = edits.list();
|
|
186
119
|
return edited.length > 0 ? { ...last!, edited } : last!;
|
|
@@ -206,7 +139,6 @@ function baseRun(request: AgentRequest, runId: string, startedAt: string, attemp
|
|
|
206
139
|
...(request.thinking ? { thinking: request.thinking } : {}),
|
|
207
140
|
...(request.routedFrom ? { routedFrom: request.routedFrom } : {}),
|
|
208
141
|
...(request.route ? { route: request.route } : {}),
|
|
209
|
-
...(request.time ? { allotMs: request.time.allotMs, endsAt: request.time.endsAt } : {}),
|
|
210
142
|
...(attempts > 1 ? { note: `retry ${attempts - 1} of ${(request.retries ?? 0)}`, noteKind: "warning" as const } : {}),
|
|
211
143
|
};
|
|
212
144
|
}
|
|
@@ -225,30 +157,18 @@ function toolsFor(request: AgentRequest): readonly string[] | undefined {
|
|
|
225
157
|
async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt: number, startedAt: string, edits: EditLog, quota: { used: number }): Promise<AgentRun> {
|
|
226
158
|
const base = baseRun(request, `${request.taskId}:${request.domain}:${request.role}:${randomUUID()}`, startedAt, attempt);
|
|
227
159
|
request.onUpdate?.(base);
|
|
228
|
-
const time = request.time!;
|
|
229
160
|
const attemptStart = Date.now();
|
|
230
161
|
let pausedMs = 0;
|
|
231
162
|
let asking = 0;
|
|
232
163
|
let askStarted = 0;
|
|
233
|
-
let expired = false;
|
|
234
164
|
const elapsed = () => Math.max(0, Date.now() - attemptStart - pausedMs - (asking ? Date.now() - askStarted : 0));
|
|
235
|
-
const live = createLiveRun(base, {
|
|
165
|
+
const live = createLiveRun(base, { onUpdate: (update) => request.onUpdate?.({ ...update, activeMs: elapsed() }) }, edits);
|
|
236
166
|
|
|
237
167
|
const controller = new AbortController();
|
|
238
168
|
activeControllers.add(controller);
|
|
239
169
|
const signal = request.signal ? AbortSignal.any([request.signal, controller.signal]) : controller.signal;
|
|
240
|
-
let deadline: ReturnType<typeof setTimeout>;
|
|
241
|
-
const arm = () => {
|
|
242
|
-
clearTimeout(deadline);
|
|
243
|
-
deadline = setTimeout(() => { expired = true; controller.abort(new Error("Assignment time limit reached")); }, Math.max(0, time.endsAt - Date.now()));
|
|
244
|
-
};
|
|
245
|
-
arm();
|
|
246
170
|
try {
|
|
247
171
|
const systemPrompt = request.systemPrompt ?? compilePrompt({ domain: request.domain, role: request.role, ...request.context });
|
|
248
|
-
const left = Math.max(1, time.endsAt - Date.now());
|
|
249
|
-
const reportMs = Math.min(REPORT_GRACE_MS, Math.floor(left / 4));
|
|
250
|
-
const wrapUp = Math.floor(left * (request.wrapUpAt || 0.75));
|
|
251
|
-
const onTimeUp = time?.onTimeUp;
|
|
252
172
|
const result = await runPiAgent({
|
|
253
173
|
cwd: request.cwd,
|
|
254
174
|
agent: { runId: base.runId, taskId: request.taskId, domain: request.domain, role: request.role },
|
|
@@ -261,88 +181,55 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
|
|
|
261
181
|
request.onUpdate?.(child);
|
|
262
182
|
},
|
|
263
183
|
mcpTools: request.mcpTools,
|
|
184
|
+
...(request.skills ? { skills: request.skills } : {}),
|
|
264
185
|
...(request.autoCompact === false ? { autoCompact: false } : {}),
|
|
265
186
|
task: request.instruction,
|
|
266
187
|
systemPrompt,
|
|
267
188
|
tools: toolsFor(request),
|
|
268
189
|
model: request.model,
|
|
269
190
|
thinking: request.thinking,
|
|
270
|
-
timeoutMs: left,
|
|
271
|
-
...(time ? {
|
|
272
|
-
time: {
|
|
273
|
-
hardLimit: true,
|
|
274
|
-
upAtMs: Math.max(1, left - reportMs),
|
|
275
|
-
headsUpAtMs: left >= 4 * 60_000 ? Math.floor(left * 0.75) : 0,
|
|
276
|
-
headsUpMessage: headsUpMessage(left - Math.floor(left * 0.75), time.allotMs),
|
|
277
|
-
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." : ""}`,
|
|
278
|
-
graceMs: reportMs,
|
|
279
|
-
...(onTimeUp ? {
|
|
280
|
-
onTimeUp: async (report: string) => {
|
|
281
|
-
clearTimeout(deadline);
|
|
282
|
-
const waiting = Date.now();
|
|
283
|
-
let extraMs: number;
|
|
284
|
-
try { extraMs = await onTimeUp(live.current(), report); }
|
|
285
|
-
finally { pausedMs += Date.now() - waiting; }
|
|
286
|
-
time.endsAt += Date.now() - waiting;
|
|
287
|
-
if (extraMs > 0) {
|
|
288
|
-
time.endsAt = Date.now() + extraMs;
|
|
289
|
-
live.annotate({ note: `given ${formatMinutes(extraMs)} more`, noteKind: "info" });
|
|
290
|
-
}
|
|
291
|
-
arm();
|
|
292
|
-
return { extraMs, message: carryOnMessage(extraMs) };
|
|
293
|
-
},
|
|
294
|
-
} : {}),
|
|
295
|
-
},
|
|
296
|
-
} : {}),
|
|
297
191
|
signal,
|
|
298
192
|
...(request.onAsk ? { onAsk: request.onAsk } : {}),
|
|
299
193
|
env: request.onAsk ? { ...request.env, [ASK_ENV]: "1" } : request.env,
|
|
300
194
|
stallTimeoutMs: request.stallTimeoutMs,
|
|
301
195
|
toolStallTimeoutMs: request.toolStallTimeoutMs,
|
|
302
|
-
wrapUpAtMs: wrapUp,
|
|
303
196
|
onEvent: (event) => {
|
|
304
|
-
if (event.type === "asking") { if (!asking) askStarted = Date.now(); asking += 1;
|
|
197
|
+
if (event.type === "asking") { if (!asking) askStarted = Date.now(); asking += 1; }
|
|
305
198
|
if (event.type === "answered") { asking = 0; pausedMs += event.waitedMs; }
|
|
306
199
|
live.onEvent(event);
|
|
307
|
-
if (event.type === "answered" && asking === 0) arm();
|
|
308
200
|
},
|
|
309
201
|
onStart: (handle) => request.onStart?.({ runId: base.runId, steer: handle.steer, annotate: live.annotate }),
|
|
310
202
|
}, run);
|
|
311
203
|
const current = live.current();
|
|
312
204
|
const final: AgentRun = {
|
|
313
205
|
...base,
|
|
314
|
-
status:
|
|
206
|
+
status: result.status,
|
|
315
207
|
output: result.output,
|
|
316
|
-
error:
|
|
208
|
+
error: result.error ? describeError(result.error, current, result.stalled === true) : undefined,
|
|
317
209
|
usage: result.usage,
|
|
318
210
|
finishedAt: new Date().toISOString(),
|
|
319
|
-
activeMs:
|
|
211
|
+
activeMs: elapsed(),
|
|
320
212
|
turns: current.turns ?? (result.usage.turns || undefined),
|
|
321
213
|
tools: current.tools,
|
|
322
214
|
model: result.model ?? current.model,
|
|
323
215
|
...(result.stalled ? { stalled: true } : {}),
|
|
324
|
-
...(result.wrappedUp ? { wrappedUp: true } : {}),
|
|
325
|
-
...(result.timeUp || expired ? { timeUp: true } : {}),
|
|
326
|
-
...(result.extendedMs ? { extendedMs: result.extendedMs } : {}),
|
|
327
216
|
note: undefined,
|
|
328
217
|
noteKind: undefined,
|
|
329
218
|
};
|
|
330
219
|
request.onUpdate?.(final);
|
|
331
220
|
return final;
|
|
332
221
|
} catch (error) {
|
|
333
|
-
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 };
|
|
334
223
|
request.onUpdate?.(final);
|
|
335
224
|
return final;
|
|
336
225
|
} finally {
|
|
337
|
-
clearTimeout(deadline!);
|
|
338
|
-
time.usedMs = (time.usedMs ?? 0) + elapsed();
|
|
339
226
|
activeControllers.delete(controller);
|
|
340
227
|
}
|
|
341
228
|
}
|
|
342
229
|
|
|
343
|
-
/** Name what the agent was doing when a stall
|
|
230
|
+
/** Name what the agent was doing when a stall hit. */
|
|
344
231
|
function describeError(error: string, current: AgentRun, stalled: boolean): string {
|
|
345
|
-
if (!stalled
|
|
232
|
+
if (!stalled) return error;
|
|
346
233
|
const doing = current.activity ? ` while ${current.activity}${current.detail ? ` ${current.detail}` : ""}` : "";
|
|
347
234
|
return `${error}${doing}`;
|
|
348
235
|
}
|
|
@@ -23,7 +23,6 @@ export const EXPERT_TOOL = "ask_expert";
|
|
|
23
23
|
/** Consults one step may make: advice on the hard part, not a second worker. */
|
|
24
24
|
export const MAX_CONSULTS = 2;
|
|
25
25
|
/** Longest an expert may take to answer. */
|
|
26
|
-
export const EXPERT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
27
26
|
/** Marks an expert's run, so its host gives it an adviser's note rather than a child's. */
|
|
28
27
|
export const EXPERT_ENV = "BOT_LOBBY_EXPERT";
|
|
29
28
|
|
|
@@ -85,7 +84,7 @@ export interface AgentPolicy {
|
|
|
85
84
|
depth: number;
|
|
86
85
|
}
|
|
87
86
|
|
|
88
|
-
/** The host owns quotas,
|
|
87
|
+
/** The host owns quotas, file identities and child lifetimes, outside model context. */
|
|
89
88
|
export async function delegationHost(options: PiRunOptions, run: ProcessRunner) {
|
|
90
89
|
const config = loadConfig();
|
|
91
90
|
const id = options.agent?.runId ?? randomUUID();
|
|
@@ -112,14 +111,11 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
|
|
|
112
111
|
desk ??= ownedDesk;
|
|
113
112
|
const controller = new AbortController();
|
|
114
113
|
const lifetime = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
|
|
115
|
-
|
|
116
|
-
const childSignal = () => AbortSignal.any([lifetime, timeController.signal]);
|
|
114
|
+
const childSignal = () => lifetime;
|
|
117
115
|
const key = `${options.cwd}:${agent.taskId}`;
|
|
118
116
|
const pool = pools.get(key) ?? { active: 0, users: 0, waiters: [] };
|
|
119
117
|
pool.users += 1;
|
|
120
118
|
pools.set(key, pool);
|
|
121
|
-
let deadlineAt = Date.now() + (options.time?.upAtMs ?? options.timeoutMs);
|
|
122
|
-
let deadline = setTimeout(() => timeController.abort(), Math.max(1, deadlineAt - Date.now()));
|
|
123
119
|
const children = new Map<string, AgentRun>();
|
|
124
120
|
const pending = new Set<Promise<AgentRun>>();
|
|
125
121
|
const parentDeskId = options.env?.[DESK_ENV.worker] ?? id;
|
|
@@ -137,18 +133,16 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
|
|
|
137
133
|
|
|
138
134
|
const childRun = async (brief: string, signal: AbortSignal): Promise<AgentRun> => {
|
|
139
135
|
const childId = randomUUID();
|
|
140
|
-
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",
|
|
136
|
+
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 } : {}) };
|
|
141
137
|
const edits = new EditLog(options.cwd);
|
|
142
|
-
const live = createLiveRun(base, { onUpdate: update
|
|
138
|
+
const live = createLiveRun(base, { onUpdate: update }, edits);
|
|
143
139
|
update(base);
|
|
144
140
|
try {
|
|
145
141
|
const configured = options.effortProfile ?? { model: options.model, thinking: options.thinking ?? "medium" };
|
|
146
142
|
const effort = options.effort ?? effortFor();
|
|
147
143
|
const route = await effort.childRoute?.(brief, configured, { context: options.task, signal }).catch(() => undefined);
|
|
148
144
|
const attempt = async (model: string | undefined, thinking: string) => {
|
|
149
|
-
const left = deadlineAt - Date.now();
|
|
150
145
|
signal.throwIfAborted();
|
|
151
|
-
if (left <= 0) throw new Error("The parent's time is up.");
|
|
152
146
|
const routed = route && model === route.model && thinking === route.thinking;
|
|
153
147
|
live.annotate({ model, thinking, route: routed ? routeLabel(route) : undefined, routedFrom: routed ? profileLabel(route.from) : undefined, note: routed ? routeLabel(route) : undefined, noteKind: "info" });
|
|
154
148
|
return runPiAgent({
|
|
@@ -159,10 +153,7 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
|
|
|
159
153
|
thinking,
|
|
160
154
|
tools: [...new Set([...tools.filter((tool) => tool !== DELEGATE_TOOL && tool !== EXPERT_TOOL), ...(childDesk ? childDesk.tools().filter((tool) => !PEER_TOOLS.includes(tool)) : [])])],
|
|
161
155
|
mcpTools,
|
|
162
|
-
timeoutMs: left,
|
|
163
156
|
signal,
|
|
164
|
-
time: undefined,
|
|
165
|
-
wrapUpAtMs: Math.floor(left * 0.85),
|
|
166
157
|
delegationQuota: undefined,
|
|
167
158
|
onChildRun: undefined,
|
|
168
159
|
onEvent: live.onEvent,
|
|
@@ -172,7 +163,7 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
|
|
|
172
163
|
};
|
|
173
164
|
let result = await attempt(route?.model ?? configured.model, route?.thinking ?? configured.thinking);
|
|
174
165
|
let retried = false;
|
|
175
|
-
if (route && (route.level === "simple" || route.level === "trivial") && fellShort(result) && !signal.aborted
|
|
166
|
+
if (route && (route.level === "simple" || route.level === "trivial") && fellShort(result) && !signal.aborted) {
|
|
176
167
|
retried = true;
|
|
177
168
|
result = await attempt(configured.model, configured.thinking);
|
|
178
169
|
}
|
|
@@ -180,7 +171,7 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
|
|
|
180
171
|
update(final);
|
|
181
172
|
return final;
|
|
182
173
|
} catch (error) {
|
|
183
|
-
const final: AgentRun = { ...live.current(), edited: edits.list(), status:
|
|
174
|
+
const final: AgentRun = { ...live.current(), edited: edits.list(), status: signal.aborted ? "cancelled" : "failed", error: (error as Error).message, finishedAt: new Date().toISOString(), note: undefined, noteKind: undefined };
|
|
184
175
|
update(final);
|
|
185
176
|
return final;
|
|
186
177
|
} finally {
|
|
@@ -192,14 +183,12 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
|
|
|
192
183
|
quota.consults ??= 0;
|
|
193
184
|
const consultations = new Map<string, AbortController>();
|
|
194
185
|
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
186
|
// The oracle resolves the expert (its session model when settings name none); settings alone, then the worker's own, without it.
|
|
198
187
|
const settled = options.expert ?? expertProfile(config);
|
|
199
188
|
const expert = { model: settled.model ?? options.model, thinking: settled.thinking };
|
|
200
189
|
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",
|
|
202
|
-
const live = createLiveRun(base, { onUpdate: update
|
|
190
|
+
const base: AgentRun = { runId: expertId, parentRunId: id, depth: 2, taskId: agent.taskId, domain: agent.domain, role: "reviewer", instruction: question, stepInstruction: options.task, status: "running", output: "", attempts: 1, startedAt: new Date().toISOString(), thinking: expert.thinking, ...(expert.model ? { model: expert.model } : {}), note: "expert consult", noteKind: "info" };
|
|
191
|
+
const live = createLiveRun(base, { onUpdate: update }, new EditLog(options.cwd));
|
|
203
192
|
update(base);
|
|
204
193
|
let final: AgentRun;
|
|
205
194
|
try {
|
|
@@ -212,8 +201,6 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
|
|
|
212
201
|
mcpTools: [],
|
|
213
202
|
...(expert.model ? { model: expert.model } : {}),
|
|
214
203
|
thinking: expert.thinking,
|
|
215
|
-
timeoutMs: left,
|
|
216
|
-
wrapUpAtMs: Math.floor(left * 0.8),
|
|
217
204
|
...(options.stallTimeoutMs !== undefined ? { stallTimeoutMs: options.stallTimeoutMs } : {}),
|
|
218
205
|
...(options.toolStallTimeoutMs !== undefined ? { toolStallTimeoutMs: options.toolStallTimeoutMs } : {}),
|
|
219
206
|
signal,
|
|
@@ -275,7 +262,6 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
|
|
|
275
262
|
const results = await Promise.all(runs);
|
|
276
263
|
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 }))) };
|
|
277
264
|
}, () => controller.abort()).catch(async (error) => {
|
|
278
|
-
clearTimeout(deadline);
|
|
279
265
|
if (ownedDesk) await ownedDesk.close();
|
|
280
266
|
pool.users -= 1;
|
|
281
267
|
if (pool.users === 0) pools.delete(key);
|
|
@@ -286,25 +272,17 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
|
|
|
286
272
|
return {
|
|
287
273
|
tools: desk ? [...new Set([...tools, ...desk.tools()])] : tools,
|
|
288
274
|
mcpTools,
|
|
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
|
|
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
|
|
275
|
+
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 }) },
|
|
276
|
+
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 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." : ""}`,
|
|
291
277
|
onStart: (handle: Parameters<NonNullable<PiRunOptions["onStart"]>>[0]) => {
|
|
292
278
|
desk?.attach(parentDeskId, { runId: id, steer: handle.steer, annotate: () => {} });
|
|
293
279
|
options.onStart?.(handle);
|
|
294
280
|
},
|
|
295
281
|
get children() { return [...children.values()]; },
|
|
296
|
-
pause() { clearTimeout(deadline); },
|
|
297
|
-
extend(ms: number, reset = false) {
|
|
298
|
-
deadlineAt = reset ? Date.now() + ms : deadlineAt + ms;
|
|
299
|
-
clearTimeout(deadline);
|
|
300
|
-
if (timeController.signal.aborted) timeController = new AbortController();
|
|
301
|
-
deadline = setTimeout(() => timeController.abort(), Math.max(1, deadlineAt - Date.now()));
|
|
302
|
-
},
|
|
303
282
|
async close() {
|
|
304
283
|
if (closed) return;
|
|
305
284
|
closed = true;
|
|
306
285
|
controller.abort();
|
|
307
|
-
clearTimeout(deadline);
|
|
308
286
|
await Promise.allSettled([...pending]);
|
|
309
287
|
await server.close();
|
|
310
288
|
if (ownedDesk) { ownedDesk.release(parentDeskId, () => "Parent finished; re-read before editing."); await ownedDesk.close(); }
|
|
@@ -3,7 +3,7 @@ import type { AgentRun } from "../schemas/findings.ts";
|
|
|
3
3
|
import type { PiStreamEvent } from "./pi-runner.ts";
|
|
4
4
|
import { EditLog } from "../state/changes.ts";
|
|
5
5
|
import { activityDetail, activityWord, describeToolCall } from "../pi/activity.ts";
|
|
6
|
-
import {
|
|
6
|
+
import { truncate } from "../text.ts";
|
|
7
7
|
|
|
8
8
|
const THROTTLE_MS = 2000;
|
|
9
9
|
|
|
@@ -12,7 +12,7 @@ const THROTTLE_MS = 2000;
|
|
|
12
12
|
* `onUpdate`: activity changes, retries and notes immediately, counters and
|
|
13
13
|
* heartbeats at most every couple of seconds.
|
|
14
14
|
*/
|
|
15
|
-
export function createLiveRun(base: AgentRun, request: Pick<AgentRequest, "onUpdate"
|
|
15
|
+
export function createLiveRun(base: AgentRun, request: Pick<AgentRequest, "onUpdate">, edits: EditLog) {
|
|
16
16
|
let state: AgentRun = base;
|
|
17
17
|
let lastEmit = 0;
|
|
18
18
|
const emit = (patch: Partial<AgentRun>, force: boolean) => {
|
|
@@ -55,22 +55,11 @@ export function createLiveRun(base: AgentRun, request: Pick<AgentRequest, "onUpd
|
|
|
55
55
|
case "compaction_end":
|
|
56
56
|
emit({ note: event.error, noteKind: event.error ? "warning" : undefined }, true);
|
|
57
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
58
|
case "asking":
|
|
68
59
|
emit({ note: `waiting on you: ${event.questions} question${event.questions === 1 ? "" : "s"}`, noteKind: "info" }, true);
|
|
69
60
|
return;
|
|
70
61
|
case "answered":
|
|
71
|
-
|
|
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);
|
|
62
|
+
emit({ note: "you answered; carrying on", noteKind: "info" }, true);
|
|
74
63
|
return;
|
|
75
64
|
case "usage": {
|
|
76
65
|
const usage = state.usage ?? { input: 0, output: 0, cost: 0, turns: 0 };
|