@a-t-h-i/bot-lobby 0.6.3 → 0.6.4
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 +102 -11
- package/package.json +1 -4
- package/prompts/master.md +47 -1
- package/prompts/researcher.md +8 -2
- package/prompts/reviewer.md +42 -0
- package/prompts/worker.md +7 -0
- package/src/ask/dialog.ts +167 -0
- package/src/ask/image.ts +202 -0
- package/src/ask/png.ts +179 -0
- package/src/ask/relay.ts +89 -0
- package/src/ask/state.ts +160 -0
- package/src/ask/tool.ts +126 -0
- package/src/ask/types.ts +55 -0
- package/src/ask/view.ts +159 -0
- package/src/execution/agent-runner.ts +103 -11
- package/src/execution/git.ts +111 -14
- package/src/execution/pi-runner.ts +164 -11
- package/src/index.ts +6 -0
- package/src/lobby/ask.ts +10 -120
- package/src/lobby/layout.ts +27 -8
- package/src/lobby/markdown.ts +36 -6
- package/src/lobby/planner.ts +1 -1
- package/src/lobby/quickfix.ts +21 -0
- package/src/lobby/runtime.ts +14 -36
- package/src/lobby/tabs/home.ts +112 -48
- package/src/lobby/tabs/issues.ts +4 -3
- package/src/lobby/tabs/plan.ts +4 -4
- package/src/lobby/tabs/quickfix.ts +7 -1
- package/src/lobby/tabs/tasks.ts +14 -4
- package/src/lobby/theme.ts +30 -0
- package/src/lobby/view.ts +27 -5
- package/src/master/decisions.ts +1 -1
- package/src/master/master.ts +41 -4
- package/src/master/research.ts +5 -2
- package/src/pi/commands.ts +94 -10
- package/src/pi/events.ts +47 -10
- package/src/pi/quiet.ts +22 -4
- package/src/pi/start-task.ts +8 -2
- package/src/pi/tools.ts +26 -8
- package/src/pi/ui.ts +6 -1
- package/src/pi/zen-metrics.ts +13 -3
- package/src/pi/zen.ts +16 -9
- package/src/roles/reviewer.ts +23 -4
- package/src/roles/worker.ts +18 -0
- package/src/schemas/configuration.ts +4 -0
- package/src/schemas/findings.ts +14 -0
- package/src/schemas/task.ts +21 -0
- package/src/state/budget.ts +274 -0
- package/src/state/changes.ts +231 -0
- package/src/text.ts +28 -2
- package/src/web/extract.ts +332 -0
- package/src/web/fetch.ts +232 -0
- package/src/web/html.ts +183 -0
- package/src/web/read.ts +113 -0
- package/src/web/search.ts +202 -0
- package/src/web/tools.ts +279 -0
- package/src/workflow/workflow.ts +592 -42
package/src/ask/tool.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ask_user_question`: the model asks instead of guessing, with typed options
|
|
3
|
+
* the user picks from (or answers in their own words). Registered by
|
|
4
|
+
* bot-lobby in every pi session it loads in, the lobby's or not, so pi needs
|
|
5
|
+
* no separate questionnaire extension. A subagent has no terminal: it gets
|
|
6
|
+
* the tool only when the master lets it ask (the designer), and its questions
|
|
7
|
+
* are relayed through the master (see relay.ts).
|
|
8
|
+
*/
|
|
9
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Container, Text } from "@earendil-works/pi-tui";
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
import { isSubagentProcess } from "../pi/quiet.ts";
|
|
13
|
+
import { askUser, type Asker } from "./dialog.ts";
|
|
14
|
+
import { isImagePath } from "./image.ts";
|
|
15
|
+
import { relayAsker, relayEnabled } from "./relay.ts";
|
|
16
|
+
import { ASK_TOOL, MAX_HEADER, MAX_LABEL, MAX_OPTIONS, MAX_QUESTIONS, MIN_OPTIONS, RESERVED, type AskQuestion, type AskResult } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
export { ASK_TOOL };
|
|
19
|
+
|
|
20
|
+
const OptionSchema = Type.Object({
|
|
21
|
+
label: Type.String({ maxLength: MAX_LABEL, description: `The option as the user sees and picks it: 1-5 words, at most ${MAX_LABEL} characters.` }),
|
|
22
|
+
description: Type.Optional(Type.String({ description: "What choosing it means: its trade-offs or consequences. Markdown." })),
|
|
23
|
+
preview: Type.Optional(Type.String({ description: "Markdown shown beside the options while this one is focused: a mockup, a code snippet, a diagram, a config. Only when seeing it helps the user compare." })),
|
|
24
|
+
image: Type.Optional(Type.String({ description: "Path to a PNG (or JPEG, GIF, WebP) shown with the preview: a screenshot or a rendered mockup of this option. Drawn as the image in terminals that can, as coloured blocks (PNG) elsewhere." })),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const QuestionSchema = Type.Object({
|
|
28
|
+
question: Type.String({ description: "The whole question, clear and specific, ending with a question mark. Markdown." }),
|
|
29
|
+
header: Type.String({ maxLength: MAX_HEADER, description: `A short chip naming the question (at most ${MAX_HEADER} characters), e.g. "Auth" or "Layout".` }),
|
|
30
|
+
options: Type.Array(OptionSchema, { minItems: MIN_OPTIONS, maxItems: MAX_OPTIONS, description: `${MIN_OPTIONS}-${MAX_OPTIONS} distinct options. Put the one you recommend first and end its label with "(Recommended)". The user can always answer in their own words instead.` }),
|
|
31
|
+
multiSelect: Type.Optional(Type.Boolean({ description: "True when several answers can apply together." })),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const AskParams = Type.Object({
|
|
35
|
+
questions: Type.Array(QuestionSchema, { minItems: 1, maxItems: MAX_QUESTIONS, description: `1-${MAX_QUESTIONS} questions asked together.` }),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const DESCRIPTION = [
|
|
39
|
+
"Ask the user one to four questions with options to pick from, when the answer would change what you do and you would otherwise guess.",
|
|
40
|
+
"Each question has 2-4 options (the one you recommend first, its label ending in \"(Recommended)\"); the user can pick one (or several with multiSelect), or answer in their own words.",
|
|
41
|
+
"Questions, descriptions and previews are Markdown. Give options a `preview` when the user needs to see them to choose: a UI mockup, a layout sketch, a code snippet, a config; the focused option's preview shows beside the list. An option can also carry an `image` file (a screenshot, a rendered mockup).",
|
|
42
|
+
"Do not use it for yes/no confirmations of what you were already told to do, or for questions the conversation already answers.",
|
|
43
|
+
].join(" ");
|
|
44
|
+
|
|
45
|
+
const RELAYED = "Your questions reach the user through the oracle, and your clock stops while they answer. Ask only what is theirs to decide (a visual direction, a layout, a trade-off they care about), all at once; decide the rest yourself.";
|
|
46
|
+
|
|
47
|
+
/** Why a set of questions cannot be asked as given, or undefined when it can. */
|
|
48
|
+
export function invalidQuestions(questions: readonly AskQuestion[]): string | undefined {
|
|
49
|
+
const asked = new Set<string>();
|
|
50
|
+
for (const [index, question] of questions.entries()) {
|
|
51
|
+
const at = `question ${index + 1}`;
|
|
52
|
+
if (!question.question.trim()) return `${at} is empty`;
|
|
53
|
+
if (asked.has(question.question.trim().toLowerCase())) return `${at} repeats an earlier question`;
|
|
54
|
+
asked.add(question.question.trim().toLowerCase());
|
|
55
|
+
const labels = new Set<string>();
|
|
56
|
+
for (const option of question.options) {
|
|
57
|
+
const label = option.label.trim().toLowerCase();
|
|
58
|
+
if (!label) return `${at} has an option without a label`;
|
|
59
|
+
if (RESERVED.has(label)) return `${at}: "${option.label}" is kept for the user's own answer; leave it out`;
|
|
60
|
+
if (labels.has(label)) return `${at} has two options labelled "${option.label}"`;
|
|
61
|
+
if (option.image?.trim() && !isImagePath(option.image)) return `${at}: the image for "${option.label}" must be a .png, .jpg, .gif or .webp file`;
|
|
62
|
+
labels.add(label);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** What the model reads back: each question with its answer, or that it was skipped. */
|
|
69
|
+
export function answerSummary(questions: readonly AskQuestion[], result: AskResult): string {
|
|
70
|
+
if (result.cancelled && result.answers.length === 0) {
|
|
71
|
+
// A relay that could not ask anyone (auto mode) says why.
|
|
72
|
+
if (result.globalNote) return result.globalNote;
|
|
73
|
+
return "The user put the questions away without answering. Do not ask the same again right away: go on with your best judgement and say what you assumed, or ask something narrower.";
|
|
74
|
+
}
|
|
75
|
+
const lines = questions.map((question, index) => {
|
|
76
|
+
const answer = result.answers.find((entry) => entry.questionIndex === index);
|
|
77
|
+
const head = `${index + 1}. [${question.header}] ${question.question.replace(/\s+/g, " ").trim()}`;
|
|
78
|
+
if (!answer) return `${head}\n → (not answered)`;
|
|
79
|
+
const text = answer.kind === "multi" ? (answer.selected ?? []).join(", ") : answer.answer ?? "";
|
|
80
|
+
return `${head}\n → ${text}${answer.kind === "custom" ? " (in the user's own words)" : ""}${answer.notes ? `\n note: ${answer.notes}` : ""}`;
|
|
81
|
+
});
|
|
82
|
+
return [
|
|
83
|
+
result.cancelled ? "The user answered some questions, then put the rest away:" : "The user answered:",
|
|
84
|
+
...lines,
|
|
85
|
+
...(result.globalNote ? ["", `Note: ${result.globalNote}`] : []),
|
|
86
|
+
].join("\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Register `ask_user_question` in this pi session; in a subagent only when
|
|
91
|
+
* the master relays its questions. `ask` is swappable for tests.
|
|
92
|
+
*/
|
|
93
|
+
export function registerAskTool(pi: ExtensionAPI, ask?: Asker): void {
|
|
94
|
+
const subagent = isSubagentProcess();
|
|
95
|
+
if (subagent && !relayEnabled()) return;
|
|
96
|
+
const asker = ask ?? (subagent ? relayAsker : askUser);
|
|
97
|
+
pi.registerTool({
|
|
98
|
+
name: ASK_TOOL,
|
|
99
|
+
label: "Ask",
|
|
100
|
+
description: subagent ? `${DESCRIPTION} ${RELAYED}` : DESCRIPTION,
|
|
101
|
+
promptSnippet: "Ask the user structured questions with options (and previews) instead of guessing",
|
|
102
|
+
promptGuidelines: [
|
|
103
|
+
"Use ask_user_question when a real decision is the user's and the answer changes your work; batch related questions (at most four) into one call.",
|
|
104
|
+
],
|
|
105
|
+
parameters: AskParams,
|
|
106
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
107
|
+
const questions = (params as { questions: AskQuestion[] }).questions;
|
|
108
|
+
const invalid = invalidQuestions(questions);
|
|
109
|
+
if (invalid) throw new Error(`${invalid}.`);
|
|
110
|
+
const result = await asker(questions, ctx, signal);
|
|
111
|
+
return { content: [{ type: "text", text: answerSummary(questions, result) }], details: result };
|
|
112
|
+
},
|
|
113
|
+
renderCall(args, theme) {
|
|
114
|
+
const questions = (args as { questions?: AskQuestion[] }).questions ?? [];
|
|
115
|
+
const heads = questions.map((question) => question.header).filter(Boolean).join(", ");
|
|
116
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("ask"))} ${theme.fg("accent", `${questions.length} question${questions.length === 1 ? "" : "s"}`)}${heads ? ` ${theme.fg("muted", heads)}` : ""}`, 0, 0);
|
|
117
|
+
},
|
|
118
|
+
renderResult(result, _options, theme) {
|
|
119
|
+
const details = result.details as AskResult | undefined;
|
|
120
|
+
if (!details) return new Container();
|
|
121
|
+
if (details.cancelled && details.answers.length === 0) return new Text(theme.fg("warning", "put away without answering"), 0, 0);
|
|
122
|
+
const lines = details.answers.map((answer) => `${theme.fg("success", "✓")} ${theme.fg("muted", answer.question.replace(/\s+/g, " ").slice(0, 60))} ${theme.fg("dim", "→")} ${theme.fg("accent", answer.kind === "multi" ? (answer.selected ?? []).join(", ") : answer.answer ?? "")}`);
|
|
123
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
package/src/ask/types.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The questionnaire's shape: what a model (or the planning panel) asks, and
|
|
3
|
+
* what comes back. The same shape `ask_user_question` has had in pi, so a
|
|
4
|
+
* model's habits carry over: 1-4 questions, 2-4 options each, a short header,
|
|
5
|
+
* multi-select, and a per-option preview (Markdown: a mockup, a snippet, a
|
|
6
|
+
* diagram) shown beside the options.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The questionnaire tool's name, in this session and in the agents that may ask. */
|
|
10
|
+
export const ASK_TOOL = "ask_user_question";
|
|
11
|
+
|
|
12
|
+
export const MAX_QUESTIONS = 4;
|
|
13
|
+
export const MIN_OPTIONS = 2;
|
|
14
|
+
export const MAX_OPTIONS = 4;
|
|
15
|
+
export const MAX_HEADER = 16;
|
|
16
|
+
export const MAX_LABEL = 60;
|
|
17
|
+
|
|
18
|
+
/** The row under every question's options where the user writes their own answer. */
|
|
19
|
+
export const OWN_ANSWER = "Type something.";
|
|
20
|
+
|
|
21
|
+
/** Labels the questionnaire keeps for its own rows. */
|
|
22
|
+
export const RESERVED = new Set(["other", OWN_ANSWER.toLowerCase(), "next"]);
|
|
23
|
+
|
|
24
|
+
export interface AskOption {
|
|
25
|
+
label: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
/** Markdown shown beside the options while this one is focused. */
|
|
28
|
+
preview?: string;
|
|
29
|
+
/** An image file (PNG, JPEG, GIF or WebP) shown with the preview: a screenshot, a rendered mockup. */
|
|
30
|
+
image?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AskQuestion {
|
|
34
|
+
question: string;
|
|
35
|
+
/** A short chip naming the question (a topic, or who asks it). */
|
|
36
|
+
header: string;
|
|
37
|
+
options: AskOption[];
|
|
38
|
+
multiSelect?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface AskAnswer {
|
|
42
|
+
questionIndex: number;
|
|
43
|
+
question: string;
|
|
44
|
+
/** A picked option, the user's own words, or several picked options. */
|
|
45
|
+
kind: "option" | "custom" | "multi";
|
|
46
|
+
answer: string | null;
|
|
47
|
+
selected?: string[];
|
|
48
|
+
notes?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AskResult {
|
|
52
|
+
answers: AskAnswer[];
|
|
53
|
+
cancelled: boolean;
|
|
54
|
+
globalNote?: string;
|
|
55
|
+
}
|
package/src/ask/view.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The questionnaire drawn: a chip per question, the question in Markdown, its
|
|
3
|
+
* options (each with its description in Markdown), the row for your own
|
|
4
|
+
* answer, and — when options carry one — the focused option's preview in a
|
|
5
|
+
* box beside them (or under them in a narrow terminal). Pure: state in, lines
|
|
6
|
+
* out, exactly `width` columns and at most `height` rows.
|
|
7
|
+
*/
|
|
8
|
+
import { basename } from "node:path";
|
|
9
|
+
import { textWidth } from "../width.ts";
|
|
10
|
+
import { beside, bold, box, fill, fit, markdownLines, paint, wrap, type LobbyTheme } from "../lobby/layout.ts";
|
|
11
|
+
import { imageLines, type ImagePreview } from "./image.ts";
|
|
12
|
+
import { isAnswered, type AskState } from "./state.ts";
|
|
13
|
+
import { OWN_ANSWER, type AskQuestion } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
/** Terminals at least this wide show the preview beside the options. */
|
|
16
|
+
export const SIDE_BY_SIDE_MIN = 96;
|
|
17
|
+
/** Rows the preview box keeps at least, and at most. */
|
|
18
|
+
const PREVIEW_MIN = 6;
|
|
19
|
+
const PREVIEW_MAX = 40;
|
|
20
|
+
/** A preview box needs this many inner rows to draw an image in; smaller ones name the file. */
|
|
21
|
+
const IMAGE_MIN_ROWS = 6;
|
|
22
|
+
/** Option descriptions sit under their label, past the marker and number. */
|
|
23
|
+
const DESCRIPTION_INDENT = 7;
|
|
24
|
+
|
|
25
|
+
const RECOMMENDED = /\s*\(recommended\)\s*$/i;
|
|
26
|
+
|
|
27
|
+
function chips(state: AskState, theme?: LobbyTheme): string {
|
|
28
|
+
if (state.questions.length === 1) return paint(theme, "muted", state.questions[0]!.header);
|
|
29
|
+
return state.questions
|
|
30
|
+
.map((question, index) => {
|
|
31
|
+
const label = ` ${index + 1} ${question.header}${isAnswered(state, index) ? " ✓" : ""} `;
|
|
32
|
+
if (index === state.tab) return bold(theme, paint(theme, "accent", `[${label.trim()}]`));
|
|
33
|
+
return paint(theme, isAnswered(state, index) ? "success" : "muted", label.trim());
|
|
34
|
+
})
|
|
35
|
+
.join(paint(theme, "dim", " · "));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function optionLabel(label: string, focused: boolean, theme?: LobbyTheme): string {
|
|
39
|
+
const recommended = RECOMMENDED.test(label);
|
|
40
|
+
const plain = label.replace(RECOMMENDED, "");
|
|
41
|
+
const text = focused ? bold(theme, plain) : plain;
|
|
42
|
+
return recommended ? `${text} ${paint(theme, "success", "(Recommended)")}` : text;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The options and the own-answer row, each option with its description beneath. */
|
|
46
|
+
function optionLines(state: AskState, question: AskQuestion, width: number, theme?: LobbyTheme): string[] {
|
|
47
|
+
const cursor = state.cursor[state.tab] ?? 0;
|
|
48
|
+
const picked = state.picked[state.tab] ?? [];
|
|
49
|
+
const lines: string[] = [];
|
|
50
|
+
question.options.forEach((option, index) => {
|
|
51
|
+
const focused = cursor === index && !state.editing;
|
|
52
|
+
const pointer = focused ? paint(theme, "accent", "›") : " ";
|
|
53
|
+
const mark = question.multiSelect ? (picked.includes(index) ? "☑" : "☐") : picked.includes(index) ? "●" : "○";
|
|
54
|
+
const lead = `${pointer} ${paint(theme, picked.includes(index) ? "accent" : "dim", mark)} ${paint(theme, "dim", `${index + 1}.`)} `;
|
|
55
|
+
const labelLines = wrap(optionLabel(option.label, focused, theme), Math.max(1, width - textWidth(lead)));
|
|
56
|
+
lines.push(`${lead}${labelLines[0] ?? ""}`, ...labelLines.slice(1).map((line) => `${" ".repeat(textWidth(lead))}${line}`));
|
|
57
|
+
if (option.description?.trim()) {
|
|
58
|
+
const indent = " ".repeat(DESCRIPTION_INDENT);
|
|
59
|
+
for (const line of markdownLines(option.description.trim(), Math.max(1, width - DESCRIPTION_INDENT), theme)) lines.push(line ? `${indent}${paint(theme, "muted", line)}` : "");
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
const ownFocused = cursor === question.options.length;
|
|
63
|
+
const pointer = ownFocused || state.editing ? paint(theme, "accent", "›") : " ";
|
|
64
|
+
const own = state.typed[state.tab];
|
|
65
|
+
const lead = `${pointer} ${paint(theme, own ? "accent" : "dim", "✎")} `;
|
|
66
|
+
const room = Math.max(1, width - textWidth(lead));
|
|
67
|
+
if (state.editing) {
|
|
68
|
+
const draft = wrap(`${state.draft}▏`, room);
|
|
69
|
+
lines.push(`${lead}${paint(theme, "accent", draft[0] ?? "")}`, ...draft.slice(1).map((line) => `${" ".repeat(textWidth(lead))}${paint(theme, "accent", line)}`));
|
|
70
|
+
} else {
|
|
71
|
+
lines.push(`${lead}${own ? paint(theme, "accent", `“${own}”`) : paint(theme, ownFocused ? "text" : "dim", OWN_ANSWER)}`);
|
|
72
|
+
}
|
|
73
|
+
return lines;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface Preview {
|
|
77
|
+
label: string;
|
|
78
|
+
text: string;
|
|
79
|
+
image?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The preview to show: the focused option's, when any option in the question has one (Markdown or an image). */
|
|
83
|
+
function previewOf(state: AskState, question: AskQuestion): Preview | undefined {
|
|
84
|
+
if (!question.options.some((option) => option.preview?.trim() || option.image?.trim())) return undefined;
|
|
85
|
+
const cursor = state.cursor[state.tab] ?? 0;
|
|
86
|
+
const option = question.options[cursor];
|
|
87
|
+
if (!option) return { label: OWN_ANSWER, text: "" };
|
|
88
|
+
return { label: option.label.replace(RECOMMENDED, ""), text: option.preview?.trim() ?? "", ...(option.image?.trim() ? { image: option.image.trim() } : {}) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The image part of a preview: drawn when the box has room, else named. It never takes the box's last rows from the text entirely. */
|
|
92
|
+
function imageBlock(preview: Preview, inner: number, innerRows: number, textRows: number, frame: AskFrame, theme?: LobbyTheme): string[] {
|
|
93
|
+
if (!preview.image) return [];
|
|
94
|
+
const loaded = frame.images?.get(preview.image);
|
|
95
|
+
const textRoom = textRows > 0 ? Math.min(textRows + 1, Math.max(2, Math.floor(innerRows / 3))) : 0;
|
|
96
|
+
const rows = innerRows - textRoom;
|
|
97
|
+
if (!loaded) return [paint(theme, "muted", `Image: ${preview.image}`)];
|
|
98
|
+
if (rows < IMAGE_MIN_ROWS) return [paint(theme, "muted", `Image: ${basename(preview.image)} (the terminal is too small to draw it)`)];
|
|
99
|
+
return imageLines(loaded, inner, rows, theme);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The preview in a box at most `rows` tall; an image keeps within `fits` rows, which the terminal surely shows. */
|
|
103
|
+
function previewBox(preview: Preview, width: number, rows: number, theme: LobbyTheme | undefined, frame: AskFrame, fits = rows): string[] {
|
|
104
|
+
const inner = Math.max(1, width - 4);
|
|
105
|
+
const text = preview.text ? markdownLines(preview.text, inner, theme) : [];
|
|
106
|
+
const image = imageBlock(preview, inner, Math.min(rows, fits) - 2, text.length, frame, theme);
|
|
107
|
+
const joined = [...image, ...(image.length > 0 && text.length > 0 ? [""] : []), ...text];
|
|
108
|
+
const body = joined.length > 0 ? joined : [paint(theme, "dim", "No preview for this one.")];
|
|
109
|
+
const height = Math.max(3, Math.min(rows, body.length + 2));
|
|
110
|
+
const more = body.length > height - 2 ? `${body.length - (height - 2)} more lines` : "";
|
|
111
|
+
return box(width, height, body, { title: `Preview · ${preview.label}`, ...(more ? { right: more } : {}), theme });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function hints(state: AskState, question: AskQuestion, width: number, theme?: LobbyTheme): string[] {
|
|
115
|
+
const parts = state.editing
|
|
116
|
+
? ["enter keep it", "esc back to the options"]
|
|
117
|
+
: [
|
|
118
|
+
"↑↓ move",
|
|
119
|
+
question.multiSelect ? "space pick · enter next" : "enter choose",
|
|
120
|
+
`1-${question.options.length} pick`,
|
|
121
|
+
...(state.questions.length > 1 ? ["←→ questions"] : []),
|
|
122
|
+
"esc put away",
|
|
123
|
+
];
|
|
124
|
+
return wrap(paint(theme, "dim", parts.join(" · ")), width);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Around the questions: who is asking (an agent the oracle relays for), and the option images, read beforehand. */
|
|
128
|
+
export interface AskFrame {
|
|
129
|
+
from?: string;
|
|
130
|
+
images?: ReadonlyMap<string, ImagePreview>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The whole questionnaire in a box, `width` wide and at most `height` rows. */
|
|
134
|
+
export function renderAsk(state: AskState, width: number, height: number, theme?: LobbyTheme, frame: AskFrame = {}): string[] {
|
|
135
|
+
const question = state.questions[state.tab];
|
|
136
|
+
if (!question || width < 20 || height < 6) return [];
|
|
137
|
+
const inner = width - 4;
|
|
138
|
+
const head = [chips(state, theme), "", ...markdownLines(question.question, inner, theme), ""];
|
|
139
|
+
const foot = ["", ...hints(state, question, inner, theme)];
|
|
140
|
+
const preview = previewOf(state, question);
|
|
141
|
+
let body: string[];
|
|
142
|
+
if (preview && inner >= SIDE_BY_SIDE_MIN) {
|
|
143
|
+
const left = Math.floor(inner * 0.42);
|
|
144
|
+
const options = optionLines(state, question, left, theme);
|
|
145
|
+
const rows = Math.max(PREVIEW_MIN, Math.min(PREVIEW_MAX, height - 2 - head.length - foot.length));
|
|
146
|
+
const side = previewBox(preview, inner - left - 2, Math.max(rows, Math.min(options.length, PREVIEW_MAX)), theme, frame, rows);
|
|
147
|
+
body = beside([fill(options, Math.max(options.length, side.length), left), side], " ");
|
|
148
|
+
} else {
|
|
149
|
+
const options = optionLines(state, question, inner, theme);
|
|
150
|
+
const room = height - 2 - head.length - foot.length - options.length - 1;
|
|
151
|
+
body = preview && room >= 3 ? [...options, "", ...previewBox(preview, inner, Math.min(PREVIEW_MAX, room), theme, frame)] : options;
|
|
152
|
+
}
|
|
153
|
+
const content = [...head, ...body, ...foot];
|
|
154
|
+
// Too tall for the terminal: the question and hints stay, the middle is cut.
|
|
155
|
+
const fitted = content.length > height - 2 ? [...content.slice(0, height - 2 - foot.length), ...foot] : content;
|
|
156
|
+
const count = state.questions.length > 1 ? `Question ${state.tab + 1} of ${state.questions.length}` : "Question";
|
|
157
|
+
const title = frame.from ? `${frame.from} asks · ${count}` : count;
|
|
158
|
+
return box(width, fitted.length + 2, fitted.map((line) => fit(line, inner)), { title, focused: true, theme });
|
|
159
|
+
}
|
|
@@ -4,7 +4,11 @@ import { roleSpec } from "../roles/registry.ts";
|
|
|
4
4
|
import { compilePrompt } from "../prompts/compiler.ts";
|
|
5
5
|
import { activityDetail, activityWord, describeToolCall } from "../pi/activity.ts";
|
|
6
6
|
import { shortDuration, truncate } from "../text.ts";
|
|
7
|
-
import {
|
|
7
|
+
import { EditLog } from "../state/changes.ts";
|
|
8
|
+
import { formatMinutes, REPORT_GRACE_MS } from "../state/budget.ts";
|
|
9
|
+
import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner, type RelayAsk } from "./pi-runner.ts";
|
|
10
|
+
import { ASK_ENV } from "../ask/relay.ts";
|
|
11
|
+
import { ASK_TOOL } from "../ask/types.ts";
|
|
8
12
|
|
|
9
13
|
export interface AgentContext {
|
|
10
14
|
task: string;
|
|
@@ -58,8 +62,49 @@ export interface AgentRequest {
|
|
|
58
62
|
/** The classifier routed this run down from this configured profile (receipts and metrics show it). */
|
|
59
63
|
routedFrom?: string;
|
|
60
64
|
route?: string;
|
|
65
|
+
/** Under a task time budget: the run's allotted time, which replaces `timeoutMs`. */
|
|
66
|
+
time?: AgentTime;
|
|
67
|
+
/** Lets the agent ask the user (ask_user_question, relayed through the master); its clocks wait for the answers. */
|
|
68
|
+
onAsk?: RelayAsk;
|
|
61
69
|
}
|
|
62
70
|
|
|
71
|
+
/**
|
|
72
|
+
* A run under a task time budget. The agent is told its minutes up front,
|
|
73
|
+
* warned at 75%, and asked to stop and report when they run out. A worker
|
|
74
|
+
* then reports where it left off and how much more it needs; `onTimeUp`
|
|
75
|
+
* (the engine asking the user) grants more, and the same agent carries on.
|
|
76
|
+
*/
|
|
77
|
+
export interface AgentTime {
|
|
78
|
+
/** When the delegation's time runs out (epoch ms); moved out when more is granted, shared by retries and re-runs. */
|
|
79
|
+
endsAt: number;
|
|
80
|
+
/** The allotment, as the agent and the lobby see it. */
|
|
81
|
+
allotMs: number;
|
|
82
|
+
/** What the agent is told about its time, in its context. */
|
|
83
|
+
note?: string;
|
|
84
|
+
/** Workers only: decide on more time once it has reported where it left off; resolve with ms, 0 to stop it. */
|
|
85
|
+
onTimeUp?: (run: AgentRun, report: string) => Promise<number>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The warning at 75% of an allotment. */
|
|
89
|
+
export function headsUpMessage(leftMs: number, allotMs: number): string {
|
|
90
|
+
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.`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 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. */
|
|
94
|
+
export function timeUpMessage(allotMs: number, canAskForMore: boolean): string {
|
|
95
|
+
return canAskForMore
|
|
96
|
+
? `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.`
|
|
97
|
+
: `bot-lobby: your ${formatMinutes(allotMs)} are up. Stop now and write your report in the required format with what you have.`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The follow-up that sends an agent on once the user gave it more time. */
|
|
101
|
+
export function carryOnMessage(extraMs: number): string {
|
|
102
|
+
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).`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Least time an attempt starts with; a retry with less than this left is not started. */
|
|
106
|
+
const MIN_ATTEMPT_MS = 60_000;
|
|
107
|
+
|
|
63
108
|
/** The watchdog fields of an AgentRequest, taken from the workflow config. */
|
|
64
109
|
export function watchdogOptions(workflow: {
|
|
65
110
|
maxAgentRetries: number;
|
|
@@ -91,13 +136,18 @@ function retryable(run: AgentRun): boolean {
|
|
|
91
136
|
export async function runAgent(request: AgentRequest, run: ProcessRunner = spawnPiProcess): Promise<AgentRun> {
|
|
92
137
|
const startedAt = new Date().toISOString();
|
|
93
138
|
const attempts = Math.max(1, (request.retries ?? 0) + 1);
|
|
139
|
+
// A failed attempt may have edited files before the retry: the run owns every edit.
|
|
140
|
+
const edits = new EditLog(request.cwd);
|
|
94
141
|
let last: AgentRun | undefined;
|
|
95
142
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
96
|
-
last = await runAgentOnce(request, run, attempt, startedAt);
|
|
143
|
+
last = await runAgentOnce(request, run, attempt, startedAt, edits);
|
|
97
144
|
request.onAttemptEnd?.(last);
|
|
98
145
|
if (!retryable(last)) break;
|
|
146
|
+
// Under a budget a retry only uses what is left of the allotment.
|
|
147
|
+
if (request.time && request.time.endsAt - Date.now() < MIN_ATTEMPT_MS) break;
|
|
99
148
|
}
|
|
100
|
-
|
|
149
|
+
const edited = edits.list();
|
|
150
|
+
return edited.length > 0 ? { ...last!, edited } : last!;
|
|
101
151
|
}
|
|
102
152
|
|
|
103
153
|
/** Abort every in-flight subagent (session shutdown, user cancel). */
|
|
@@ -120,31 +170,36 @@ function baseRun(request: AgentRequest, runId: string, startedAt: string, attemp
|
|
|
120
170
|
...(request.thinking ? { thinking: request.thinking } : {}),
|
|
121
171
|
...(request.routedFrom ? { routedFrom: request.routedFrom } : {}),
|
|
122
172
|
...(request.route ? { route: request.route } : {}),
|
|
173
|
+
...(request.time ? { allotMs: request.time.allotMs, endsAt: request.time.endsAt } : {}),
|
|
123
174
|
...(attempts > 1 ? { note: `retry ${attempts - 1} of ${(request.retries ?? 0)}`, noteKind: "warning" as const } : {}),
|
|
124
175
|
};
|
|
125
176
|
}
|
|
126
177
|
|
|
127
178
|
function toolsFor(request: AgentRequest): readonly string[] | undefined {
|
|
128
179
|
const tools = roleSpec(request.role).tools;
|
|
129
|
-
|
|
130
|
-
|
|
180
|
+
const extra = [...(request.extraTools ?? []), ...(request.onAsk ? [ASK_TOOL] : [])];
|
|
181
|
+
if (extra.length === 0) return tools;
|
|
182
|
+
return [...(tools ?? []), ...extra];
|
|
131
183
|
}
|
|
132
184
|
|
|
133
185
|
/**
|
|
134
186
|
* Run one domain/role agent in an isolated pi process. The role's tool
|
|
135
187
|
* allowlist comes from its spec, so read-only roles cannot modify anything.
|
|
136
188
|
*/
|
|
137
|
-
async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt: number, startedAt: string): Promise<AgentRun> {
|
|
189
|
+
async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt: number, startedAt: string, edits: EditLog): Promise<AgentRun> {
|
|
138
190
|
const base = baseRun(request, `${request.taskId}:${request.domain}:${request.role}:${Date.now().toString(36)}`, startedAt, attempt);
|
|
139
191
|
request.onUpdate?.(base);
|
|
140
|
-
const live = createLiveRun(base, request);
|
|
192
|
+
const live = createLiveRun(base, request, edits);
|
|
141
193
|
|
|
142
194
|
const controller = new AbortController();
|
|
143
195
|
activeControllers.add(controller);
|
|
144
196
|
const signal = request.signal ? AbortSignal.any([request.signal, controller.signal]) : controller.signal;
|
|
145
197
|
try {
|
|
146
198
|
const systemPrompt = compilePrompt({ domain: request.domain, role: request.role, ...request.context });
|
|
147
|
-
const
|
|
199
|
+
const time = request.time;
|
|
200
|
+
const left = time ? Math.max(MIN_ATTEMPT_MS, time.endsAt - Date.now()) : 0;
|
|
201
|
+
const wrapUp = time ? 0 : request.wrapUpAt && request.wrapUpAt > 0 && request.wrapUpAt < 1 ? Math.floor(request.timeoutMs * request.wrapUpAt) : 0;
|
|
202
|
+
const onTimeUp = time?.onTimeUp;
|
|
148
203
|
const result = await runPiAgent({
|
|
149
204
|
cwd: request.cwd,
|
|
150
205
|
task: request.instruction,
|
|
@@ -152,9 +207,29 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
|
|
|
152
207
|
tools: toolsFor(request),
|
|
153
208
|
model: request.model,
|
|
154
209
|
thinking: request.thinking,
|
|
155
|
-
timeoutMs: request.timeoutMs,
|
|
210
|
+
timeoutMs: time ? left + REPORT_GRACE_MS : request.timeoutMs,
|
|
211
|
+
...(time ? {
|
|
212
|
+
time: {
|
|
213
|
+
upAtMs: left,
|
|
214
|
+
headsUpAtMs: left >= 4 * 60_000 ? Math.floor(left * 0.75) : 0,
|
|
215
|
+
headsUpMessage: headsUpMessage(left - Math.floor(left * 0.75), time.allotMs),
|
|
216
|
+
upMessage: timeUpMessage(time.allotMs, Boolean(onTimeUp)),
|
|
217
|
+
graceMs: REPORT_GRACE_MS,
|
|
218
|
+
...(onTimeUp ? {
|
|
219
|
+
onTimeUp: async (report: string) => {
|
|
220
|
+
const extraMs = await onTimeUp(live.current(), report);
|
|
221
|
+
if (extraMs > 0) {
|
|
222
|
+
time.endsAt = Date.now() + extraMs;
|
|
223
|
+
live.annotate({ note: `given ${formatMinutes(extraMs)} more`, noteKind: "info" });
|
|
224
|
+
}
|
|
225
|
+
return { extraMs, message: carryOnMessage(extraMs) };
|
|
226
|
+
},
|
|
227
|
+
} : {}),
|
|
228
|
+
},
|
|
229
|
+
} : {}),
|
|
156
230
|
signal,
|
|
157
|
-
|
|
231
|
+
...(request.onAsk ? { onAsk: request.onAsk } : {}),
|
|
232
|
+
env: request.onAsk ? { ...request.env, [ASK_ENV]: "1" } : request.env,
|
|
158
233
|
stallTimeoutMs: request.stallTimeoutMs,
|
|
159
234
|
toolStallTimeoutMs: request.toolStallTimeoutMs,
|
|
160
235
|
wrapUpAtMs: wrapUp,
|
|
@@ -174,6 +249,8 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
|
|
|
174
249
|
model: result.model ?? current.model,
|
|
175
250
|
...(result.stalled ? { stalled: true } : {}),
|
|
176
251
|
...(result.wrappedUp ? { wrappedUp: true } : {}),
|
|
252
|
+
...(result.timeUp ? { timeUp: true } : {}),
|
|
253
|
+
...(result.extendedMs ? { extendedMs: result.extendedMs } : {}),
|
|
177
254
|
note: undefined,
|
|
178
255
|
noteKind: undefined,
|
|
179
256
|
};
|
|
@@ -198,7 +275,7 @@ const THROTTLE_MS = 2000;
|
|
|
198
275
|
* `onUpdate`: activity changes, retries and notes immediately, counters and
|
|
199
276
|
* heartbeats at most every couple of seconds.
|
|
200
277
|
*/
|
|
201
|
-
function createLiveRun(base: AgentRun, request: AgentRequest) {
|
|
278
|
+
function createLiveRun(base: AgentRun, request: AgentRequest, edits: EditLog) {
|
|
202
279
|
let state: AgentRun = base;
|
|
203
280
|
let lastEmit = 0;
|
|
204
281
|
const emit = (patch: Partial<AgentRun>, force: boolean) => {
|
|
@@ -212,6 +289,7 @@ function createLiveRun(base: AgentRun, request: AgentRequest) {
|
|
|
212
289
|
const onEvent = (event: PiStreamEvent) => {
|
|
213
290
|
switch (event.type) {
|
|
214
291
|
case "tool_execution_start": {
|
|
292
|
+
edits.note(event.toolName, event.args);
|
|
215
293
|
const activity = activityWord(event.toolName);
|
|
216
294
|
const detail = activityDetail(event.toolName, event.args);
|
|
217
295
|
const step = describeToolCall(event.toolName, event.args);
|
|
@@ -240,6 +318,20 @@ function createLiveRun(base: AgentRun, request: AgentRequest) {
|
|
|
240
318
|
case "wrap_up":
|
|
241
319
|
emit({ note: `asked to wrap up (${shortDuration(request.timeoutMs)} limit)`, noteKind: "warning", wrappedUp: true }, true);
|
|
242
320
|
return;
|
|
321
|
+
case "time_up":
|
|
322
|
+
emit({ note: request.time?.onTimeUp ? "out of time: reporting where it left off" : "out of time: reporting", noteKind: "warning" }, true);
|
|
323
|
+
return;
|
|
324
|
+
case "extended":
|
|
325
|
+
emit({ endsAt: request.time?.endsAt, extendedMs: (state.extendedMs ?? 0) + event.ms }, true);
|
|
326
|
+
return;
|
|
327
|
+
case "asking":
|
|
328
|
+
emit({ note: `waiting on you: ${event.questions} question${event.questions === 1 ? "" : "s"}`, noteKind: "info" }, true);
|
|
329
|
+
return;
|
|
330
|
+
case "answered":
|
|
331
|
+
// Its time waited with it.
|
|
332
|
+
if (request.time) request.time.endsAt += event.waitedMs;
|
|
333
|
+
emit({ note: "you answered; carrying on", noteKind: "info", ...(request.time ? { endsAt: request.time.endsAt } : {}) }, true);
|
|
334
|
+
return;
|
|
243
335
|
case "usage": {
|
|
244
336
|
const usage = state.usage ?? { input: 0, output: 0, cost: 0, turns: 0 };
|
|
245
337
|
const next = { input: usage.input + event.input, output: usage.output + event.output, cost: usage.cost + event.cost, turns: usage.turns + 1 };
|