@a-t-h-i/bot-lobby 0.6.36 → 0.6.38
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 +32 -7
- package/package.json +1 -1
- package/prompts/master.md +32 -0
- package/prompts/worker.md +11 -0
- package/src/classifier/effort.ts +14 -2
- package/src/classifier/instance.ts +10 -2
- package/src/classifier/knowledge.ts +5 -2
- package/src/classifier/scripts.ts +76 -0
- package/src/execution/agent-runner.ts +75 -14
- package/src/execution/delegation.ts +25 -7
- package/src/execution/pi-runner.ts +9 -3
- package/src/knowledge/selector.ts +2 -0
- package/src/lobby/quickfix.ts +10 -2
- package/src/lobby/runtime.ts +2 -1
- package/src/lobby/service.ts +8 -1
- package/src/lobby/sessions.ts +1 -1
- package/src/master/master.ts +19 -6
- package/src/pi/activity.ts +2 -0
- package/src/pi/auto-compaction.ts +4 -4
- package/src/pi/events.ts +4 -1
- package/src/pi/plan-checklist.ts +4 -2
- package/src/pi/start-task.ts +15 -9
- package/src/pi/task-title.ts +46 -0
- package/src/pi/tools.ts +6 -4
- package/src/prompts/compiler.ts +4 -1
- package/src/roles/worker.ts +15 -1
- package/src/schemas/configuration.ts +5 -2
- package/src/schemas/findings.ts +14 -0
- package/src/scripts/library.ts +168 -0
- package/src/scripts/pick.ts +79 -0
- package/src/state/step-limits.ts +41 -0
- package/src/state/work-time.ts +16 -11
- package/src/webui/api/lobby.ts +1 -1
- package/src/webui/api/plan-facts.ts +3 -2
- package/src/webui/protocol.ts +1 -1
- package/src/workflow/workflow.ts +122 -40
- package/webui/dist/assets/index-BFsMFIr5.js +110 -0
- package/webui/dist/assets/index-Ccc6rsPD.css +2 -0
- package/webui/dist/build.json +99 -79
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-4Zm1DbOM.css +0 -2
- package/webui/dist/assets/index-CjhlNym4.js +0 -109
package/README.md
CHANGED
|
@@ -36,6 +36,11 @@ you to confirm merging and pushing main, then removes its worktree after verifyi
|
|
|
36
36
|
Declining or failed verification keeps the worktree. Delivery can also be reviewed in **Tasks**,
|
|
37
37
|
where you can create a PR instead. The task branch is kept.
|
|
38
38
|
|
|
39
|
+
New tasks get a short title summarizing the full request, which also names their branch and
|
|
40
|
+
session. Naming uses low reasoning on `classifier.effort.cheapModel` when configured, otherwise
|
|
41
|
+
the session model. If naming fails or takes more than eight seconds, the request's first content
|
|
42
|
+
words are used. Tasks started from an agreed plan keep that plan's title.
|
|
43
|
+
|
|
39
44
|
After every worker step, before QA and at completion, the engine runs the project's own linter
|
|
40
45
|
(ESLint, Biome, Oxlint, Ruff, or a command you set) on the files the task touched. Only problems on
|
|
41
46
|
changed lines count, and QA judges any lint suppression a worker adds. **Settings → Linting** sets it
|
|
@@ -101,6 +106,18 @@ first, searches the codebase for missing information, and records verified findi
|
|
|
101
106
|
agent's knowledge. Answers appear in the side pane; the **Chat / Docs** toggle inside the composer
|
|
102
107
|
switches back to the selected document. Questions do not create tasks.
|
|
103
108
|
|
|
109
|
+
## Scripts
|
|
110
|
+
|
|
111
|
+
Jobs that come back (seeding data, regenerating fixtures, a set of checks before review) are kept
|
|
112
|
+
as scripts in `.pi/bot-lobby/scripts/`, shared by every agent, so a step done once is run the next
|
|
113
|
+
time instead of being worked out again, which saves tokens and time. Each is named for what it
|
|
114
|
+
does, in snake_case with its language's extension (`seed_public_holidays.py`,
|
|
115
|
+
`run_changed_tests.sh`), and its first lines say what it is for and how to run it. The oracle
|
|
116
|
+
decides when a step is worth a script and saves it (`action=script`); workers only propose one, and
|
|
117
|
+
a replaced script is kept under `archive/`. For each step, Jev picks the scripts that do part of it
|
|
118
|
+
and the agent is told to run them (**Settings → Classifier → Shared scripts**; with Jev off, scripts
|
|
119
|
+
whose names share words with the step are suggested).
|
|
120
|
+
|
|
104
121
|
## Keys
|
|
105
122
|
|
|
106
123
|
The lobby is keyboard-first, and every action button shows its key (`Archive E`, `Delete Del`).
|
|
@@ -126,25 +143,33 @@ are kept in `themes.json` beside it, so every Pi session and project shows them.
|
|
|
126
143
|
project knowledge stay in `.pi/bot-lobby/` inside your project.
|
|
127
144
|
|
|
128
145
|
|
|
129
|
-
Requires **Pi 1.0.x** (1.0.1 or later).
|
|
130
|
-
`delegate_subtasks` for
|
|
131
|
-
slots total, shared across retries;
|
|
146
|
+
Requires **Pi 1.0.x** (1.0.1 or later). DEV and DESIGN workers and QA reviewers can use
|
|
147
|
+
`delegate_subtasks` for useful, separable work. Delegation is optional. Each parent gets five child
|
|
148
|
+
slots total, shared across retries; Jev selects each child’s model and reasoning effort from its brief, lowering
|
|
149
|
+
inference for simple work and raising reasoning for complex work on the configured
|
|
150
|
+
model. Children inherit the parent’s role,
|
|
132
151
|
permissions, worktree and remaining time. Children report to their parent, which
|
|
133
152
|
integrates and verifies the result. Children cannot delegate further. Active
|
|
134
153
|
children share `workflow.maxParallelWorkers`; waiting parents do not use those
|
|
135
154
|
child slots. Cancelling or finishing a parent stops its children and releases
|
|
136
155
|
their file claims. Full reports are saved under `.pi/bot-lobby/delegations/`.
|
|
137
156
|
|
|
138
|
-
|
|
157
|
+
Every assignment has a 15-minute total allowance (or its shorter configured limit),
|
|
158
|
+
including startup, tools, retries, children, compaction and reporting. Per-step
|
|
159
|
+
allowances persist across reassignments; only the user can extend them, including
|
|
160
|
+
in auto mode. Waiting for user answers pauses clocks.
|
|
161
|
+
|
|
162
|
+
The conversation pane groups running agents beneath their current step, for example
|
|
139
163
|
**Step 6 – Implement large feature (6 devs working)**. Queued and finished agents
|
|
140
164
|
are excluded. A child finishing does not complete a checklist step.
|
|
141
165
|
|
|
142
166
|
The oracle, agents and children compact at settled turn boundaries before context
|
|
143
|
-
reaches
|
|
167
|
+
reaches 64,000 estimated tokens, or earlier at 60% of the model window or Pi's
|
|
144
168
|
native reserve threshold. Recent messages and summaries retain unfinished work;
|
|
145
169
|
raw session history remains available. A failed summary leaves context intact.
|
|
146
|
-
|
|
147
|
-
|
|
170
|
+
Checkpoints preserve constraints, decisions, changed files, verification, failed
|
|
171
|
+
approaches, and next actions. A single large input can exceed the threshold before
|
|
172
|
+
the next settled boundary.
|
|
148
173
|
|
|
149
174
|
Pi's native MCP, `tool_search` and `codemode` are available. **MCP tools** in each
|
|
150
175
|
agent's Settings entry grants exact tool names, one per line, such as
|
package/package.json
CHANGED
package/prompts/master.md
CHANGED
|
@@ -282,6 +282,13 @@ Every delegation costs a full agent run, so keep the loop short:
|
|
|
282
282
|
|
|
283
283
|
## Time budget
|
|
284
284
|
|
|
285
|
+
Every assignment has at most 15 minutes total, or its shorter configured limit,
|
|
286
|
+
including retries, children, tools, compaction and reporting. Step allowances
|
|
287
|
+
persist across reassignments: name the plan step explicitly, and do not reword
|
|
288
|
+
or split an exhausted step to reset its clock. Only the user can extend it,
|
|
289
|
+
including in auto mode. Return an unfinished report when time is spent; stop
|
|
290
|
+
repeating failed approaches. Waiting for the user pauses clocks.
|
|
291
|
+
|
|
285
292
|
When the task has a time budget (your context says how much is used and
|
|
286
293
|
left), it covers everyone: you, scouts, workers and the QA gate. The clock
|
|
287
294
|
runs while you work and stops while you wait on the user.
|
|
@@ -348,6 +355,31 @@ the relevant knowledge files: omit `taskId` to update project knowledge without
|
|
|
348
355
|
creating or advancing a task. Answer from saved knowledge first, cite code
|
|
349
356
|
evidence for newly learned facts, and never record a guess.
|
|
350
357
|
|
|
358
|
+
## Scripts
|
|
359
|
+
|
|
360
|
+
The project keeps a shared scripts library that every agent can run; your
|
|
361
|
+
prompt lists it under Scripts, and each agent is shown the scripts that fit its
|
|
362
|
+
step. A script saves the tokens and time of working the same job out again, so
|
|
363
|
+
you decide when work becomes one.
|
|
364
|
+
|
|
365
|
+
- Make a script of a job that will come back in much the same form: seeding or
|
|
366
|
+
resetting data, regenerating fixtures or generated files, a migration run per
|
|
367
|
+
environment, a set of checks run before every review, a scaffold. Not of a
|
|
368
|
+
one-off change, and not of a single command anyone would type.
|
|
369
|
+
- When a step is such a job, say so in its brief: have the worker write the
|
|
370
|
+
step as a script, drafted in the task's directory under `scripts/`, and run
|
|
371
|
+
it. Then save it with `action=script`, `name`, `purpose`, `usage` and
|
|
372
|
+
`file` (the draft), or `text` (the script itself). Read it first: it runs
|
|
373
|
+
for every agent from then on.
|
|
374
|
+
- Name it for what it does, in snake_case, with its language's extension:
|
|
375
|
+
`seed_public_holidays.py`, `run_changed_tests.sh`. Prefer the project's own
|
|
376
|
+
language and runtime. Arguments over hard-coded values, so it serves the
|
|
377
|
+
next case too; and it says what it did, and fails loudly.
|
|
378
|
+
- Before you brief a step, look for a script that does it, and name it in the
|
|
379
|
+
brief. To improve one, save it again under the same name; the old version is
|
|
380
|
+
archived.
|
|
381
|
+
- Workers may propose scripts; take one only when it meets the bar above.
|
|
382
|
+
|
|
351
383
|
## Review
|
|
352
384
|
|
|
353
385
|
The repository state is the source of truth; do not blindly trust Scout or
|
package/prompts/worker.md
CHANGED
|
@@ -113,3 +113,14 @@ then add a `## Pushback` block to your output:
|
|
|
113
113
|
|
|
114
114
|
The engine records the pushback and blocks that domain until the Master
|
|
115
115
|
resolves it, so be specific and keep working on the rest of the task.
|
|
116
|
+
|
|
117
|
+
## Shared scripts
|
|
118
|
+
|
|
119
|
+
Your prompt's Scripts section lists the shared scripts that fit your step. Run
|
|
120
|
+
one instead of doing its work by hand, and check its output as you would your
|
|
121
|
+
own; a script that does not do what it says is a finding for `## Notes`. Do not
|
|
122
|
+
edit the library yourself. When your brief asks you to write a step as a
|
|
123
|
+
script, or you wrote one for a job that will clearly come back, draft it in the
|
|
124
|
+
folder your Workflow Context names for script drafts, named for what it does in snake_case
|
|
125
|
+
(`seed_public_holidays.py`), run it, and propose it under `## Script
|
|
126
|
+
Proposals`.
|
package/src/classifier/effort.ts
CHANGED
|
@@ -30,9 +30,9 @@ export interface RunProfileLike {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export interface EffortRoute extends RunProfileLike {
|
|
33
|
-
level:
|
|
33
|
+
level: EffortLevel;
|
|
34
34
|
confidence: number;
|
|
35
|
-
/** The configured profile
|
|
35
|
+
/** The configured profile before routing. */
|
|
36
36
|
from: RunProfileLike;
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -109,6 +109,8 @@ export interface EffortScore {
|
|
|
109
109
|
|
|
110
110
|
/** Lowers the model or thinking of a step the classifier judges simple or trivial. */
|
|
111
111
|
export interface EffortRouter {
|
|
112
|
+
/** Children may raise reasoning for complex work, using the parent's configured model. */
|
|
113
|
+
childRoute?(instruction: string, profile: RunProfileLike, options?: { context?: string; signal?: AbortSignal }): Promise<EffortRoute | undefined>;
|
|
112
114
|
/** Score a step and plan its route in one go. */
|
|
113
115
|
route(instruction: string, profile: RunProfileLike, options?: { thinkingFixed?: boolean; context?: string; signal?: AbortSignal }): Promise<EffortRoute | undefined>;
|
|
114
116
|
/** Score a step once (several agents may share it)… */
|
|
@@ -119,6 +121,16 @@ export interface EffortRouter {
|
|
|
119
121
|
|
|
120
122
|
export function effortRouter(classifier: Classifier, deps: { clamp?: RouteOptions["clamp"]; log?: (text: string) => void } = {}): EffortRouter {
|
|
121
123
|
const router: EffortRouter = {
|
|
124
|
+
async childRoute(instruction, profile, options = {}) {
|
|
125
|
+
const scored = await router.score(instruction, options);
|
|
126
|
+
if (!scored) return undefined;
|
|
127
|
+
if (scored.level !== "complex" || scored.confidence < classifier.config.thresholds.simpleAt) return router.plan(scored, profile);
|
|
128
|
+
const thinking = profile.model && deps.clamp ? deps.clamp(profile.model, "max") : rank(profile.thinking) > rank("high") ? profile.thinking : "high";
|
|
129
|
+
if (thinking === profile.thinking) return undefined;
|
|
130
|
+
const route: EffortRoute = { ...profile, thinking, level: scored.level, confidence: scored.confidence, from: { ...profile } };
|
|
131
|
+
deps.log?.(`routed child ${routeLabel(route)}`);
|
|
132
|
+
return route;
|
|
133
|
+
},
|
|
122
134
|
async score(instruction, options = {}) {
|
|
123
135
|
if (!classifier.enabled("effort")) return undefined;
|
|
124
136
|
return scoreEffort(classifier, instruction, options.context, options.signal);
|
|
@@ -9,6 +9,8 @@ import { appendMetrics } from "../state/metrics.ts";
|
|
|
9
9
|
import { detectProjectRoot, loadConfig } from "../state/project.ts";
|
|
10
10
|
import { isSubagentProcess } from "../pi/quiet.ts";
|
|
11
11
|
import { Classifier } from "./classifier.ts";
|
|
12
|
+
import { scriptPicker } from "./scripts.ts";
|
|
13
|
+
import type { ScriptPicker } from "../scripts/pick.ts";
|
|
12
14
|
import { registerJevProvider, type KeySource, type KeyStatus } from "./hosts.ts";
|
|
13
15
|
import { fileHinter, type FileHinter, type FileScope } from "./files.ts";
|
|
14
16
|
import { knowledgePicker, type KnowledgePicker } from "./knowledge.ts";
|
|
@@ -46,9 +48,15 @@ export function hintsFor(scope: FileScope): FileHinter {
|
|
|
46
48
|
return fileHinter(classifier(), scope, (text) => lobbyFeed.log("CLASSIFIER", text, "info"));
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
/** Relevant knowledge for agents, with what Jev kept
|
|
51
|
+
/** Relevant knowledge and shared scripts for agents, with what Jev kept and matched logged to the lobby's activity feed. */
|
|
50
52
|
export function knowledgeFor(): KnowledgePicker {
|
|
51
|
-
|
|
53
|
+
const log = (text: string) => lobbyFeed.log("CLASSIFIER", text, "info");
|
|
54
|
+
return { ...knowledgePicker(classifier(), log), scripts: scriptPicker(classifier(), log) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Shared scripts for a quick fix, with what Jev matched logged to the lobby's activity feed. */
|
|
58
|
+
export function scriptsPickerFor(): ScriptPicker {
|
|
59
|
+
return scriptPicker(classifier(), (text) => lobbyFeed.log("CLASSIFIER", text, "info"));
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
/** Triage a request for a task in a tree, logged to the lobby's activity feed; undefined when triage is off or fails. */
|
|
@@ -16,6 +16,7 @@ import { score, selectRelevant, splitSections, tokenize, type KnowledgeInput, ty
|
|
|
16
16
|
import type { Classifier } from "./classifier.ts";
|
|
17
17
|
import { noul, yesOf, type SystemOneRequest } from "./client.ts";
|
|
18
18
|
import { clip } from "./limits.ts";
|
|
19
|
+
import type { ScriptPicker } from "../scripts/pick.ts";
|
|
19
20
|
|
|
20
21
|
/** How many characters of each knowledge file a prompt has room for. */
|
|
21
22
|
export const KNOWLEDGE_BUDGET_CHARS = 4000;
|
|
@@ -27,7 +28,7 @@ export const MAX_SECTIONS = 60;
|
|
|
27
28
|
export const KNOWLEDGE_BUDGET_MS = 1500;
|
|
28
29
|
|
|
29
30
|
/** The three files a prompt carries: `knowledge`, `standards` and `decisions`. */
|
|
30
|
-
export type KnowledgeSlice = keyof KnowledgeSelection
|
|
31
|
+
export type KnowledgeSlice = Exclude<keyof KnowledgeSelection, "scripts">;
|
|
31
32
|
export type KnowledgePaths = Partial<Record<KnowledgeSlice, string>>;
|
|
32
33
|
|
|
33
34
|
export interface SectionCall {
|
|
@@ -100,9 +101,11 @@ export function leftOutNote(kept: Kept, name: string, path?: string): string {
|
|
|
100
101
|
: `_Kept ${kept.kept} of ${kept.total} sections of ${name} for this step.${where}_`;
|
|
101
102
|
}
|
|
102
103
|
|
|
103
|
-
/** Picks what an agent's prompt carries of its knowledge files. */
|
|
104
|
+
/** Picks what an agent's prompt carries of its knowledge files, and of the shared scripts. */
|
|
104
105
|
export interface KnowledgePicker {
|
|
105
106
|
select(task: string, files: KnowledgeInput, options?: { signal?: AbortSignal; paths?: KnowledgePaths }): Promise<KnowledgeSelection>;
|
|
107
|
+
/** Which shared scripts fit a step; shared words when absent. */
|
|
108
|
+
scripts?: ScriptPicker;
|
|
106
109
|
}
|
|
107
110
|
|
|
108
111
|
const SLICES: readonly KnowledgeSlice[] = ["knowledge", "standards", "decisions"];
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared scripts for a step. Jev reads each script's name and purpose against
|
|
3
|
+
* the step (one yes/no per script) and the ones that do part of it are put in
|
|
4
|
+
* the agent's prompt with how to run them, so the agent runs the script rather
|
|
5
|
+
* than working the job out again. A library longer than Jev is asked about is
|
|
6
|
+
* narrowed by shared words first. Off, failed or out of time, shared words
|
|
7
|
+
* decide (`scripts/pick.ts`).
|
|
8
|
+
*/
|
|
9
|
+
import type { Classifier } from "./classifier.ts";
|
|
10
|
+
import { noul, yesOf, type SystemOneRequest } from "./client.ts";
|
|
11
|
+
import { clip } from "./limits.ts";
|
|
12
|
+
import { score, tokenize } from "../knowledge/selector.ts";
|
|
13
|
+
import { keywordScripts, MAX_SUGGESTED, type ScriptPicker } from "../scripts/pick.ts";
|
|
14
|
+
import { nameWords, type ScriptEntry } from "../scripts/library.ts";
|
|
15
|
+
|
|
16
|
+
/** Scripts Jev is asked about for one step. */
|
|
17
|
+
export const MAX_CANDIDATES = 40;
|
|
18
|
+
/** The step waits this long for Jev, then goes with shared words. */
|
|
19
|
+
export const SCRIPTS_BUDGET_MS = 1500;
|
|
20
|
+
|
|
21
|
+
export interface ScriptCall {
|
|
22
|
+
request: SystemOneRequest;
|
|
23
|
+
/** The script each question key stands for. */
|
|
24
|
+
keys: Map<string, ScriptEntry>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The scripts to ask about: all of them, or the `MAX_CANDIDATES` that share the most words with the step. */
|
|
28
|
+
export function candidateScripts(step: string, entries: readonly ScriptEntry[]): ScriptEntry[] {
|
|
29
|
+
if (entries.length <= MAX_CANDIDATES) return [...entries];
|
|
30
|
+
const tokens = tokenize(step);
|
|
31
|
+
return entries
|
|
32
|
+
.map((entry, index) => ({ entry, index, hits: score(`${nameWords(entry.name)} ${entry.purpose}`, tokens) }))
|
|
33
|
+
.sort((a, b) => b.hits - a.hits || a.index - b.index)
|
|
34
|
+
.slice(0, MAX_CANDIDATES)
|
|
35
|
+
.map((scored) => scored.entry);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One request that asks, for each script, whether it does part of the step. */
|
|
39
|
+
export function scriptRequest(step: string, entries: readonly ScriptEntry[]): ScriptCall {
|
|
40
|
+
const scripts: Record<string, string> = {};
|
|
41
|
+
const questions: SystemOneRequest["questions"] = {};
|
|
42
|
+
const keys = new Map<string, ScriptEntry>();
|
|
43
|
+
entries.forEach((entry, position) => {
|
|
44
|
+
const key = `k${position + 1}`;
|
|
45
|
+
scripts[key] = clip(`${entry.name}: ${entry.purpose}`, 300);
|
|
46
|
+
questions[key] = noul(`Does the script \`scripts.${key}\` (its file name and purpose) do all or part of \`task\`, so an agent doing the task should run it instead of doing that work by hand? Yes when running it is a step the task needs; no when it is about something else or only shares words with it.`);
|
|
47
|
+
keys.set(key, entry);
|
|
48
|
+
});
|
|
49
|
+
return { request: { state: { task: clip(step, 3000), scripts }, questions }, keys };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A picker on Jev: the scripts it judges to do part of the step, most likely
|
|
54
|
+
* first. Shared words decide when it is off or cannot judge.
|
|
55
|
+
*/
|
|
56
|
+
export function scriptPicker(classifier: Classifier, log?: (text: string) => void): ScriptPicker {
|
|
57
|
+
return {
|
|
58
|
+
async pick(step, entries, options = {}) {
|
|
59
|
+
if (entries.length === 0) return [];
|
|
60
|
+
if (!step.trim() || !classifier.enabled("scripts")) return keywordScripts(step, entries);
|
|
61
|
+
const call = scriptRequest(step, candidateScripts(step, entries));
|
|
62
|
+
const result = await classifier.ask("scripts", call.request, { ...(options.signal ? { signal: options.signal } : {}), timeoutMs: Math.min(classifier.config.timeoutMs, SCRIPTS_BUDGET_MS) });
|
|
63
|
+
if (!result) return keywordScripts(step, entries);
|
|
64
|
+
const judged = [...call.keys].map(([key, entry]) => ({ entry, yes: yesOf(result.answers, key) }));
|
|
65
|
+
// An answer to none of the questions says nothing: shared words decide.
|
|
66
|
+
if (judged.every((entry) => entry.yes === undefined)) return keywordScripts(step, entries);
|
|
67
|
+
const picked = judged
|
|
68
|
+
.filter((entry) => (entry.yes ?? 0) >= classifier.config.thresholds.scriptRelevantAt)
|
|
69
|
+
.sort((a, b) => b.yes! - a.yes!)
|
|
70
|
+
.slice(0, MAX_SUGGESTED)
|
|
71
|
+
.map((entry) => entry.entry);
|
|
72
|
+
log?.(`scripts: ${picked.length > 0 ? picked.map((entry) => entry.name).join(", ") : "none"} fit the step (of ${entries.length}) · ${result.ms} ms`);
|
|
73
|
+
return picked;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -8,6 +8,8 @@ import { EditLog } from "../state/changes.ts";
|
|
|
8
8
|
import { formatMinutes, REPORT_GRACE_MS } from "../state/budget.ts";
|
|
9
9
|
import { runPiAgent, spawnPiProcess, type ProcessRunner, type RelayAsk } from "./pi-runner.ts";
|
|
10
10
|
import { ASK_ENV } from "../ask/relay.ts";
|
|
11
|
+
import { STEP_LIMIT_MS } from "../state/step-limits.ts";
|
|
12
|
+
import type { EffortRouter, RunProfileLike } from "../classifier/effort.ts";
|
|
11
13
|
import { ASK_TOOL } from "../ask/types.ts";
|
|
12
14
|
import { agentOfRun, grantOption } from "../excalidraw/sessions.ts";
|
|
13
15
|
|
|
@@ -16,13 +18,15 @@ export interface AgentContext {
|
|
|
16
18
|
standards?: string;
|
|
17
19
|
knowledge?: string;
|
|
18
20
|
decisions?: string;
|
|
21
|
+
/** The shared scripts that fit the step. */
|
|
22
|
+
scripts?: string;
|
|
19
23
|
/** Per-agent custom instructions from the global config. */
|
|
20
24
|
instructions?: string;
|
|
21
25
|
workflowContext?: string;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
/** Live fields a caller (the file desk) may set on a running agent. */
|
|
25
|
-
export type LivePatch = Pick<AgentRun, "note" | "noteKind" | "waitingFor">;
|
|
29
|
+
export type LivePatch = Pick<AgentRun, "note" | "noteKind" | "waitingFor" | "model" | "thinking" | "route" | "routedFrom">;
|
|
26
30
|
|
|
27
31
|
/** Control over one running attempt, handed to `onStart`. */
|
|
28
32
|
export interface AgentHandle {
|
|
@@ -35,6 +39,8 @@ export interface AgentRequest {
|
|
|
35
39
|
taskId: string;
|
|
36
40
|
domain: Domain;
|
|
37
41
|
role: Role;
|
|
42
|
+
effort?: EffortRouter;
|
|
43
|
+
effortProfile?: RunProfileLike;
|
|
38
44
|
/** Concrete instruction sent as the subagent's task message. */
|
|
39
45
|
instruction: string;
|
|
40
46
|
/** Prompt layers selected for this run. */
|
|
@@ -78,6 +84,10 @@ export interface AgentRequest {
|
|
|
78
84
|
* (the engine asking the user) grants more, and the same agent carries on.
|
|
79
85
|
*/
|
|
80
86
|
export interface AgentTime {
|
|
87
|
+
/** Active time shared across attempts and model fallbacks. */
|
|
88
|
+
usedMs?: number;
|
|
89
|
+
/** Include reporting in the hard allowance. */
|
|
90
|
+
strict?: boolean;
|
|
81
91
|
/** When the delegation's time runs out (epoch ms); moved out when more is granted, shared by retries and re-runs. */
|
|
82
92
|
endsAt: number;
|
|
83
93
|
/** The allotment, as the agent and the lobby see it. */
|
|
@@ -108,6 +118,11 @@ export function carryOnMessage(extraMs: number): string {
|
|
|
108
118
|
/** Least time an attempt starts with; a retry with less than this left is not started. */
|
|
109
119
|
const MIN_ATTEMPT_MS = 60_000;
|
|
110
120
|
|
|
121
|
+
export function boundedAgentTime(timeoutMs: number): AgentTime {
|
|
122
|
+
const allotMs = Math.min(STEP_LIMIT_MS, timeoutMs);
|
|
123
|
+
return { endsAt: Date.now() + allotMs, allotMs, strict: true };
|
|
124
|
+
}
|
|
125
|
+
|
|
111
126
|
/** The watchdog fields of an AgentRequest, taken from the workflow config. */
|
|
112
127
|
export function watchdogOptions(workflow: {
|
|
113
128
|
maxAgentRetries: number;
|
|
@@ -137,7 +152,19 @@ function retryable(run: AgentRun): boolean {
|
|
|
137
152
|
* and a slow agent cannot double its time budget.
|
|
138
153
|
*/
|
|
139
154
|
export async function runAgent(request: AgentRequest, run: ProcessRunner = spawnPiProcess): Promise<AgentRun> {
|
|
155
|
+
request.time ??= boundedAgentTime(request.timeoutMs);
|
|
156
|
+
if (!request.time.strict) {
|
|
157
|
+
request.time.endsAt = Math.min(request.time.endsAt, Date.now() + Math.min(STEP_LIMIT_MS, request.timeoutMs));
|
|
158
|
+
request.time.allotMs = Math.min(request.time.allotMs, STEP_LIMIT_MS, request.timeoutMs);
|
|
159
|
+
request.time.strict = true;
|
|
160
|
+
}
|
|
161
|
+
request.time.usedMs ??= Math.max(0, request.time.allotMs - (request.time.endsAt - Date.now()));
|
|
140
162
|
const startedAt = new Date().toISOString();
|
|
163
|
+
if (request.time.endsAt <= Date.now()) {
|
|
164
|
+
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 };
|
|
165
|
+
request.onUpdate?.(stopped);
|
|
166
|
+
return stopped;
|
|
167
|
+
}
|
|
141
168
|
const attempts = Math.max(1, (request.retries ?? 0) + 1);
|
|
142
169
|
// A failed attempt may have edited files before the retry: the run owns every edit.
|
|
143
170
|
const edits = new EditLog(request.cwd);
|
|
@@ -193,21 +220,36 @@ function toolsFor(request: AgentRequest): readonly string[] | undefined {
|
|
|
193
220
|
async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt: number, startedAt: string, edits: EditLog, quota: { used: number }): Promise<AgentRun> {
|
|
194
221
|
const base = baseRun(request, `${request.taskId}:${request.domain}:${request.role}:${randomUUID()}`, startedAt, attempt);
|
|
195
222
|
request.onUpdate?.(base);
|
|
196
|
-
const
|
|
223
|
+
const time = request.time!;
|
|
224
|
+
const attemptStart = Date.now();
|
|
225
|
+
let pausedMs = 0;
|
|
226
|
+
let asking = 0;
|
|
227
|
+
let askStarted = 0;
|
|
228
|
+
let expired = false;
|
|
229
|
+
const elapsed = () => Math.max(0, Date.now() - attemptStart - pausedMs - (asking ? Date.now() - askStarted : 0));
|
|
230
|
+
const live = createLiveRun(base, { ...request, onUpdate: (update) => request.onUpdate?.({ ...update, activeMs: (time.usedMs ?? 0) + elapsed(), endsAt: time.endsAt }) }, edits);
|
|
197
231
|
|
|
198
232
|
const controller = new AbortController();
|
|
199
233
|
activeControllers.add(controller);
|
|
200
234
|
const signal = request.signal ? AbortSignal.any([request.signal, controller.signal]) : controller.signal;
|
|
235
|
+
let deadline: ReturnType<typeof setTimeout>;
|
|
236
|
+
const arm = () => {
|
|
237
|
+
clearTimeout(deadline);
|
|
238
|
+
deadline = setTimeout(() => { expired = true; controller.abort(new Error("Assignment time limit reached")); }, Math.max(0, time.endsAt - Date.now()));
|
|
239
|
+
};
|
|
240
|
+
arm();
|
|
201
241
|
try {
|
|
202
242
|
const systemPrompt = compilePrompt({ domain: request.domain, role: request.role, ...request.context });
|
|
203
|
-
const
|
|
204
|
-
const
|
|
205
|
-
const wrapUp =
|
|
243
|
+
const left = Math.max(1, time.endsAt - Date.now());
|
|
244
|
+
const reportMs = Math.min(REPORT_GRACE_MS, Math.floor(left / 4));
|
|
245
|
+
const wrapUp = Math.floor(left * (request.wrapUpAt || 0.75));
|
|
206
246
|
const onTimeUp = time?.onTimeUp;
|
|
207
247
|
const result = await runPiAgent({
|
|
208
248
|
cwd: request.cwd,
|
|
209
249
|
agent: { runId: base.runId, taskId: request.taskId, domain: request.domain, role: request.role },
|
|
210
250
|
delegationQuota: quota,
|
|
251
|
+
effort: request.effort,
|
|
252
|
+
effortProfile: request.effortProfile,
|
|
211
253
|
onChildRun: (child) => {
|
|
212
254
|
for (const path of child.edited ?? []) edits.note("edit", { path });
|
|
213
255
|
request.onUpdate?.(child);
|
|
@@ -218,21 +260,28 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
|
|
|
218
260
|
tools: toolsFor(request),
|
|
219
261
|
model: request.model,
|
|
220
262
|
thinking: request.thinking,
|
|
221
|
-
timeoutMs:
|
|
263
|
+
timeoutMs: left,
|
|
222
264
|
...(time ? {
|
|
223
265
|
time: {
|
|
224
|
-
|
|
266
|
+
hardLimit: true,
|
|
267
|
+
upAtMs: Math.max(1, left - reportMs),
|
|
225
268
|
headsUpAtMs: left >= 4 * 60_000 ? Math.floor(left * 0.75) : 0,
|
|
226
269
|
headsUpMessage: headsUpMessage(left - Math.floor(left * 0.75), time.allotMs),
|
|
227
|
-
upMessage:
|
|
228
|
-
graceMs:
|
|
270
|
+
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." : ""}`,
|
|
271
|
+
graceMs: reportMs,
|
|
229
272
|
...(onTimeUp ? {
|
|
230
273
|
onTimeUp: async (report: string) => {
|
|
231
|
-
|
|
274
|
+
clearTimeout(deadline);
|
|
275
|
+
const waiting = Date.now();
|
|
276
|
+
let extraMs: number;
|
|
277
|
+
try { extraMs = await onTimeUp(live.current(), report); }
|
|
278
|
+
finally { pausedMs += Date.now() - waiting; }
|
|
279
|
+
time.endsAt += Date.now() - waiting;
|
|
232
280
|
if (extraMs > 0) {
|
|
233
281
|
time.endsAt = Date.now() + extraMs;
|
|
234
282
|
live.annotate({ note: `given ${formatMinutes(extraMs)} more`, noteKind: "info" });
|
|
235
283
|
}
|
|
284
|
+
arm();
|
|
236
285
|
return { extraMs, message: carryOnMessage(extraMs) };
|
|
237
286
|
},
|
|
238
287
|
} : {}),
|
|
@@ -245,30 +294,42 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
|
|
|
245
294
|
stallTimeoutMs: request.stallTimeoutMs,
|
|
246
295
|
toolStallTimeoutMs: request.toolStallTimeoutMs,
|
|
247
296
|
wrapUpAtMs: wrapUp,
|
|
248
|
-
onEvent:
|
|
297
|
+
onEvent: (event) => {
|
|
298
|
+
if (event.type === "asking") { if (!asking) askStarted = Date.now(); asking += 1; clearTimeout(deadline); }
|
|
299
|
+
if (event.type === "answered") { asking = 0; pausedMs += event.waitedMs; }
|
|
300
|
+
live.onEvent(event);
|
|
301
|
+
if (event.type === "answered" && asking === 0) arm();
|
|
302
|
+
},
|
|
249
303
|
onStart: (handle) => request.onStart?.({ runId: base.runId, steer: handle.steer, annotate: live.annotate }),
|
|
250
304
|
}, run);
|
|
251
305
|
const current = live.current();
|
|
252
306
|
const final: AgentRun = {
|
|
253
307
|
...base,
|
|
254
|
-
status: result.status,
|
|
308
|
+
status: expired ? "timeout" : result.status,
|
|
255
309
|
output: result.output,
|
|
256
|
-
error: result.error ? describeError(result.error, current, result.stalled === true) : undefined,
|
|
310
|
+
error: expired ? describeError("hit the shared assignment time limit", current, false) : result.error ? describeError(result.error, current, result.stalled === true) : undefined,
|
|
257
311
|
usage: result.usage,
|
|
258
312
|
finishedAt: new Date().toISOString(),
|
|
313
|
+
activeMs: (time.usedMs ?? 0) + elapsed(),
|
|
259
314
|
turns: current.turns ?? (result.usage.turns || undefined),
|
|
260
315
|
tools: current.tools,
|
|
261
316
|
model: result.model ?? current.model,
|
|
262
317
|
...(result.stalled ? { stalled: true } : {}),
|
|
263
318
|
...(result.wrappedUp ? { wrappedUp: true } : {}),
|
|
264
|
-
...(result.timeUp ? { timeUp: true } : {}),
|
|
319
|
+
...(result.timeUp || expired ? { timeUp: true } : {}),
|
|
265
320
|
...(result.extendedMs ? { extendedMs: result.extendedMs } : {}),
|
|
266
321
|
note: undefined,
|
|
267
322
|
noteKind: undefined,
|
|
268
323
|
};
|
|
269
324
|
request.onUpdate?.(final);
|
|
270
325
|
return final;
|
|
326
|
+
} catch (error) {
|
|
327
|
+
const final: AgentRun = { ...live.current(), status: expired ? "timeout" : signal.aborted ? "cancelled" : "failed", error: (error as Error).message, activeMs: (time.usedMs ?? 0) + elapsed(), edited: edits.list(), finishedAt: new Date().toISOString(), note: undefined, noteKind: undefined, ...(expired ? { timeUp: true } : {}) };
|
|
328
|
+
request.onUpdate?.(final);
|
|
329
|
+
return final;
|
|
271
330
|
} finally {
|
|
331
|
+
clearTimeout(deadline!);
|
|
332
|
+
time.usedMs = (time.usedMs ?? 0) + elapsed();
|
|
272
333
|
activeControllers.delete(controller);
|
|
273
334
|
}
|
|
274
335
|
}
|
|
@@ -6,6 +6,8 @@ import { join } from "node:path";
|
|
|
6
6
|
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import type { AgentRun } from "../schemas/findings.ts";
|
|
8
8
|
import type { Domain, Role } from "../schemas/agent.ts";
|
|
9
|
+
import { effortFor } from "../classifier/instance.ts";
|
|
10
|
+
import { fellShort, profileLabel, routeLabel } from "../classifier/effort.ts";
|
|
9
11
|
import { agentProfile } from "../schemas/configuration.ts";
|
|
10
12
|
import { dataRoot, detectProjectRoot, loadConfig } from "../state/project.ts";
|
|
11
13
|
import { EditLog } from "../state/changes.ts";
|
|
@@ -108,17 +110,26 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
|
|
|
108
110
|
|
|
109
111
|
const childRun = async (brief: string, signal: AbortSignal): Promise<AgentRun> => {
|
|
110
112
|
const childId = randomUUID();
|
|
111
|
-
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 } : {}) };
|
|
113
|
+
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", endsAt: deadlineAt, output: "", attempts: 1, startedAt: new Date().toISOString(), ...(options.thinking ? { thinking: options.thinking } : {}) };
|
|
112
114
|
const edits = new EditLog(options.cwd);
|
|
113
115
|
const live = createLiveRun(base, { onUpdate: update, timeoutMs: options.timeoutMs }, edits);
|
|
114
116
|
update(base);
|
|
115
117
|
try {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
const
|
|
118
|
+
const configured = options.effortProfile ?? { model: options.model, thinking: options.thinking ?? "medium" };
|
|
119
|
+
const effort = options.effort ?? effortFor();
|
|
120
|
+
const route = await effort.childRoute?.(brief, configured, { context: options.task, signal }).catch(() => undefined);
|
|
121
|
+
const attempt = async (model: string | undefined, thinking: string) => {
|
|
122
|
+
const left = deadlineAt - Date.now();
|
|
123
|
+
signal.throwIfAborted();
|
|
124
|
+
if (left <= 0) throw new Error("The parent's time is up.");
|
|
125
|
+
const routed = route && model === route.model && thinking === route.thinking;
|
|
126
|
+
live.annotate({ model, thinking, route: routed ? routeLabel(route) : undefined, routedFrom: routed ? profileLabel(route.from) : undefined, note: routed ? routeLabel(route) : undefined, noteKind: "info" });
|
|
127
|
+
return runPiAgent({
|
|
119
128
|
...options,
|
|
120
129
|
agent: { ...agent, runId: childId, depth: 2 },
|
|
121
130
|
task: brief,
|
|
131
|
+
model,
|
|
132
|
+
thinking,
|
|
122
133
|
tools: [...new Set([...tools.filter((tool) => tool !== DELEGATE_TOOL), ...(desk ? DESK_TOOLS : [])])],
|
|
123
134
|
mcpTools,
|
|
124
135
|
timeoutMs: left,
|
|
@@ -131,11 +142,18 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
|
|
|
131
142
|
env: { ...options.env, ...(desk ? desk.env(childId) : {}) },
|
|
132
143
|
onStart: (handle) => desk?.attach(childId, { runId: childId, steer: handle.steer, annotate: live.annotate }),
|
|
133
144
|
}, run);
|
|
134
|
-
|
|
145
|
+
};
|
|
146
|
+
let result = await attempt(route?.model ?? configured.model, route?.thinking ?? configured.thinking);
|
|
147
|
+
let retried = false;
|
|
148
|
+
if (route && (route.level === "simple" || route.level === "trivial") && fellShort(result) && !signal.aborted && deadlineAt - Date.now() >= 60_000) {
|
|
149
|
+
retried = true;
|
|
150
|
+
result = await attempt(configured.model, configured.thinking);
|
|
151
|
+
}
|
|
152
|
+
const final: AgentRun = { ...live.current(), ...result, thinking: retried ? configured.thinking : route?.thinking ?? configured.thinking, attempts: retried ? 2 : 1, ...(route && !retried ? { route: routeLabel(route), routedFrom: profileLabel(route.from) } : {}), edited: edits.list(), finishedAt: new Date().toISOString(), note: undefined, noteKind: undefined };
|
|
135
153
|
update(final);
|
|
136
154
|
return final;
|
|
137
155
|
} catch (error) {
|
|
138
|
-
const final: AgentRun = { ...live.current(), edited: edits.list(), status: signal.aborted ? "cancelled" : "failed", error: (error as Error).message, finishedAt: new Date().toISOString() };
|
|
156
|
+
const final: AgentRun = { ...live.current(), edited: edits.list(), status: Date.now() >= deadlineAt ? "timeout" : signal.aborted ? "cancelled" : "failed", error: (error as Error).message, finishedAt: new Date().toISOString(), note: undefined, noteKind: undefined };
|
|
139
157
|
update(final);
|
|
140
158
|
return final;
|
|
141
159
|
} finally {
|
|
@@ -173,7 +191,7 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
|
|
|
173
191
|
return {
|
|
174
192
|
tools: desk ? [...new Set([...tools, ...DESK_TOOLS])] : tools,
|
|
175
193
|
env: { ...options.env, ...(desk ? desk.env(parentDeskId) : {}), [AGENT_ENV]: JSON.stringify({ ...policy, tools: desk ? [...new Set([...tools, ...DESK_TOOLS])] : tools }), [DELEGATION_ENV]: JSON.stringify({ address: server.address, token, timeoutMs: options.timeoutMs }) },
|
|
176
|
-
note: `${canDelegate(agent, quota.used) ? `
|
|
194
|
+
note: `${canDelegate(agent, quota.used) ? `Work directly when you can finish the brief yourself. Use ${DELEGATE_TOOL} only for useful, separable work with disjoint files, at most five children total; children share your remaining time and cannot delegate further. Integrate and verify their work yourself. ` : ""}Use codemode for concise parallel calls, inspect failures, and return only useful evidence.${desk ? " Before edit/write, claim_file; handover_file when finished. Release files before delegating or waiting." : ""}`,
|
|
177
195
|
onStart: (handle: Parameters<NonNullable<PiRunOptions["onStart"]>>[0]) => {
|
|
178
196
|
desk?.attach(parentDeskId, { runId: id, steer: handle.steer, annotate: () => {} });
|
|
179
197
|
options.onStart?.(handle);
|
|
@@ -11,6 +11,7 @@ import { shortDuration } from "../text.ts";
|
|
|
11
11
|
import { readRelayRequest } from "../ask/relay.ts";
|
|
12
12
|
import type { AskQuestion, AskResult } from "../ask/types.ts";
|
|
13
13
|
import { grantEnv, grantNote, type Grant } from "../excalidraw/sessions.ts";
|
|
14
|
+
import type { EffortRouter, RunProfileLike } from "../classifier/effort.ts";
|
|
14
15
|
|
|
15
16
|
/** Live control over one running subagent: queue a steering message for its next turn. */
|
|
16
17
|
export interface RunHandle {
|
|
@@ -24,6 +25,8 @@ export interface RunHandle {
|
|
|
24
25
|
export type RelayAsk = (questions: AskQuestion[], signal: AbortSignal) => Promise<AskResult>;
|
|
25
26
|
|
|
26
27
|
export interface PiRunOptions {
|
|
28
|
+
effort?: EffortRouter;
|
|
29
|
+
effortProfile?: RunProfileLike;
|
|
27
30
|
agent?: { runId: string; taskId: string; domain: Domain; role: Role; depth?: number };
|
|
28
31
|
mcpTools?: string[];
|
|
29
32
|
delegationQuota?: { used: number };
|
|
@@ -67,6 +70,8 @@ export interface PiRunOptions {
|
|
|
67
70
|
* the decision is pending, and move out with any time granted.
|
|
68
71
|
*/
|
|
69
72
|
export interface TimeLimit {
|
|
73
|
+
/** Reporting time is included in the allowance, including extensions. */
|
|
74
|
+
hardLimit?: boolean;
|
|
70
75
|
upAtMs: number;
|
|
71
76
|
headsUpAtMs?: number;
|
|
72
77
|
headsUpMessage?: string;
|
|
@@ -584,11 +589,12 @@ export function spawnPiProcess(args: string[], options: ProcessRunOptions): Prom
|
|
|
584
589
|
turnDone = false;
|
|
585
590
|
clockStart = Date.now();
|
|
586
591
|
lastOutput = clockStart;
|
|
587
|
-
upAtMs = extraMs;
|
|
592
|
+
upAtMs = time?.hardLimit ? Math.max(1, extraMs - Math.min(time.graceMs, Math.floor(extraMs / 4))) : extraMs;
|
|
588
593
|
// The user just named the time it has: no heads-up quoting the old allotment.
|
|
589
594
|
headsUpAtMs = 0;
|
|
590
|
-
|
|
591
|
-
|
|
595
|
+
const nextLimit = extraMs + (time?.hardLimit ? 0 : time?.graceMs ?? 0);
|
|
596
|
+
deadlineAt = Date.now() + nextLimit;
|
|
597
|
+
deadline = setTimeout(onDeadline, nextLimit);
|
|
592
598
|
input.send({ type: "prompt", message: message ?? "You have more time: carry on from where you left off and finish." });
|
|
593
599
|
options.onEvent?.({ type: "extended", ms: extraMs });
|
|
594
600
|
}, () => {
|