@a-t-h-i/bot-lobby 0.6.1 → 0.6.2
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 +177 -834
- package/package.json +1 -1
- package/prompts/master.md +17 -0
- package/prompts/panel.md +3 -1
- package/prompts/planner.md +14 -0
- package/prompts/quickfix.md +3 -1
- package/prompts/scout.md +3 -0
- package/prompts/worker.md +3 -0
- package/src/classifier/answers.ts +110 -0
- package/src/classifier/classifier.ts +171 -0
- package/src/classifier/client.ts +239 -0
- package/src/classifier/effort.ts +143 -0
- package/src/classifier/files.ts +427 -0
- package/src/classifier/hosts.ts +157 -0
- package/src/classifier/instance.ts +98 -0
- package/src/classifier/limits.ts +37 -0
- package/src/classifier/seats.ts +102 -0
- package/src/classifier/tools.ts +58 -0
- package/src/classifier/triage.ts +203 -0
- package/src/execution/agent-runner.ts +5 -0
- package/src/index.ts +6 -0
- package/src/lobby/planner.ts +305 -27
- package/src/lobby/quickfix.ts +120 -8
- package/src/lobby/runtime.ts +12 -1
- package/src/lobby/tabs/metrics.ts +28 -1
- package/src/lobby/tabs/plan.ts +27 -4
- package/src/lobby/tabs/quickfix.ts +5 -1
- package/src/lobby/view.ts +28 -4
- package/src/master/master.ts +95 -29
- package/src/pi/commands.ts +4 -2
- package/src/pi/events.ts +11 -2
- package/src/pi/run-summary.ts +3 -1
- package/src/pi/settings-ui.ts +138 -2
- package/src/pi/start-task.ts +4 -0
- package/src/pi/tools.ts +7 -2
- package/src/schemas/configuration.ts +125 -1
- package/src/schemas/findings.ts +4 -0
- package/src/schemas/task.ts +22 -0
- package/src/state/metrics.ts +74 -1
- package/src/workflow/workflow.ts +49 -1
|
@@ -55,6 +55,9 @@ export interface AgentRequest {
|
|
|
55
55
|
onStart?: (handle: AgentHandle) => void;
|
|
56
56
|
/** Called once per attempt after it ends, before any retry. */
|
|
57
57
|
onAttemptEnd?: (run: AgentRun) => void;
|
|
58
|
+
/** The classifier routed this run down from this configured profile (receipts and metrics show it). */
|
|
59
|
+
routedFrom?: string;
|
|
60
|
+
route?: string;
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
/** The watchdog fields of an AgentRequest, taken from the workflow config. */
|
|
@@ -115,6 +118,8 @@ function baseRun(request: AgentRequest, runId: string, startedAt: string, attemp
|
|
|
115
118
|
attempts,
|
|
116
119
|
startedAt,
|
|
117
120
|
...(request.thinking ? { thinking: request.thinking } : {}),
|
|
121
|
+
...(request.routedFrom ? { routedFrom: request.routedFrom } : {}),
|
|
122
|
+
...(request.route ? { route: request.route } : {}),
|
|
118
123
|
...(attempts > 1 ? { note: `retry ${attempts - 1} of ${(request.retries ?? 0)}`, noteKind: "warning" as const } : {}),
|
|
119
124
|
};
|
|
120
125
|
}
|
package/src/index.ts
CHANGED
|
@@ -8,8 +8,12 @@ import { isSubagentProcess } from "./pi/quiet.ts";
|
|
|
8
8
|
import { registerDeskClient } from "./desk/client-extension.ts";
|
|
9
9
|
import { registerLobbyEvents } from "./lobby/runtime.ts";
|
|
10
10
|
import { registerOwner } from "./pi/owner.ts";
|
|
11
|
+
import { registerClassifier } from "./classifier/instance.ts";
|
|
12
|
+
import { registerClassifierTools } from "./classifier/tools.ts";
|
|
11
13
|
|
|
12
14
|
export default function (pi: ExtensionAPI): void {
|
|
15
|
+
// First, so every session_start handler below finds the classifier bound to this session's keys.
|
|
16
|
+
registerClassifier(pi, CONFIG_DIR_NAME);
|
|
13
17
|
registerLifecycle(pi, CONFIG_DIR_NAME);
|
|
14
18
|
// After the lifecycle, so the lobby opens over a task the widget state already knows.
|
|
15
19
|
registerLobbyEvents(pi, CONFIG_DIR_NAME);
|
|
@@ -19,4 +23,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
19
23
|
registerOrchestrateTool(pi, CONFIG_DIR_NAME);
|
|
20
24
|
// Parallel workers share files through the master's file desk.
|
|
21
25
|
if (isSubagentProcess()) registerDeskClient(pi);
|
|
26
|
+
// Subagents look files up with the classifier (the engine allows the tool only while file hints are on).
|
|
27
|
+
if (isSubagentProcess()) registerClassifierTools(pi);
|
|
22
28
|
}
|
package/src/lobby/planner.ts
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
* Each round runs the seats in parallel (read-only; RESEARCH may also use the
|
|
11
11
|
* web tools), then the oracle over their output. Rounds replay the whole
|
|
12
12
|
* conversation, so one can be stopped or retried without losing the session.
|
|
13
|
+
* A round limit bounds the grilling: the last round skips the seats and the
|
|
14
|
+
* oracle decides whatever is still open, and later replies only revise.
|
|
15
|
+
* With the classifier on, each round seats only the members whose domain the
|
|
16
|
+
* idea or the latest answers touch (a key press pins a seat on or off), and
|
|
17
|
+
* questions whose recommended option the conversation already makes clearly
|
|
18
|
+
* right are answered without asking.
|
|
13
19
|
* The agreed plan is saved as a pending task.
|
|
14
20
|
*/
|
|
15
21
|
import { loadPrompt } from "../prompts/loader.ts";
|
|
@@ -23,6 +29,11 @@ import { truncate } from "../text.ts";
|
|
|
23
29
|
import type { LobbyFeed } from "./feed.ts";
|
|
24
30
|
import type { QuickFixProfile } from "./quickfix.ts";
|
|
25
31
|
import { MAX_QUESTIONS, type AskResult } from "./ask.ts";
|
|
32
|
+
import type { Classifier } from "../classifier/classifier.ts";
|
|
33
|
+
import { chooseSeats } from "../classifier/seats.ts";
|
|
34
|
+
import { autoAnswer, type AutoAnswer } from "../classifier/answers.ts";
|
|
35
|
+
import type { FileHinter } from "../classifier/files.ts";
|
|
36
|
+
import { fellShort, profileLabel, type EffortRouter, type EffortScore } from "../classifier/effort.ts";
|
|
26
37
|
|
|
27
38
|
/** Seats and the oracle read the repository to ask informed questions; they never edit. */
|
|
28
39
|
export const PLANNER_TOOLS: readonly string[] = ["read", "grep", "find", "ls"];
|
|
@@ -65,6 +76,8 @@ export interface PlannerMessage {
|
|
|
65
76
|
at: number;
|
|
66
77
|
/** The round's questions, attributed, when the panel asked any. */
|
|
67
78
|
questions?: PanelQuestion[];
|
|
79
|
+
/** Questions the classifier answered with their recommended option instead of asking. */
|
|
80
|
+
decided?: AutoAnswer[];
|
|
68
81
|
}
|
|
69
82
|
|
|
70
83
|
/** The oracle's reply: its verdict, title, own questions and the draft plan. */
|
|
@@ -118,6 +131,46 @@ export interface PlannerDeps {
|
|
|
118
131
|
onChange?: () => void;
|
|
119
132
|
/** Called when a round ends (answered, failed or stopped), so the lobby can put the questions to the user. */
|
|
120
133
|
onRound?: (session: PlanningSession) => void;
|
|
134
|
+
/** Rounds before the oracle finalizes alone (0 or absent = unlimited); read as each round starts. */
|
|
135
|
+
maxRounds?: () => number;
|
|
136
|
+
/** Chooses the round's seats and answers obvious questions when it is on; absent, every seat sits and you answer everything. */
|
|
137
|
+
classifier?: Classifier;
|
|
138
|
+
/** Likely files for the round (idea and latest answers), and the lookup tool, while file hints are on. */
|
|
139
|
+
hints?: FileHinter;
|
|
140
|
+
/** Lowers a seat's thinking (or model) when the classifier judges the idea simple or trivial; the oracle is never routed. */
|
|
141
|
+
effort?: EffortRouter;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** What the user's turn says when the classifier settled every question of a round. */
|
|
145
|
+
export const CLASSIFIER_CONTINUE = "(No answers needed from me this round: the classifier settled the questions above. Continue.)";
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* How a round runs under the limit: `normal` grilling with the seats, the
|
|
149
|
+
* `final` round (the oracle alone, deciding what is open), or `revise` once
|
|
150
|
+
* past the limit (the oracle alone, revising for the user's latest message).
|
|
151
|
+
*/
|
|
152
|
+
export type RoundMode = "normal" | "final" | "revise";
|
|
153
|
+
|
|
154
|
+
export function roundMode(round: number, limit: number): RoundMode {
|
|
155
|
+
if (limit <= 0 || round < limit) return "normal";
|
|
156
|
+
return round === limit ? "final" : "revise";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** What the oracle is told to do at the end of its task, by round mode. */
|
|
160
|
+
export function oracleClosing(mode: RoundMode, round: number, limit: number): string {
|
|
161
|
+
if (mode === "final") {
|
|
162
|
+
return `Final round (${round} of ${limit}): no seat runs this round and nothing more is asked. Fold the user's answers into the plan, decide every point still open with its recommended option and list each under ### Assumptions, and set Status READY. Omit the Questions section. Reply in the required output format.`;
|
|
163
|
+
}
|
|
164
|
+
if (mode === "revise") {
|
|
165
|
+
return `The planning round limit (${limit}) is reached: revise the plan for the user's latest message and comments. Ask nothing; decide anything open with its recommended option under ### Assumptions, and keep Status READY. Omit the Questions section. Reply in the required output format.`;
|
|
166
|
+
}
|
|
167
|
+
const bound = limit > 0 ? `Round ${round} of ${limit}; in round ${limit} you settle whatever is still open alone, so ask the decisive questions now. ` : "";
|
|
168
|
+
return `${bound}Continue: fold the panel's notes and the user's answers into the plan, ask what no seat owns, or declare the plan READY. Reply in the required output format.`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** `round 2 of 5`, for a seat's task; empty without a limit. */
|
|
172
|
+
function roundNote(round: number, limit: number): string {
|
|
173
|
+
return limit > 0 ? ` (round ${round} of ${limit})` : "";
|
|
121
174
|
}
|
|
122
175
|
|
|
123
176
|
/** Split a reply into its top-level `## Section` bodies, keyed by lower-case title. */
|
|
@@ -158,6 +211,36 @@ export function parseOption(text: string): PanelOption {
|
|
|
158
211
|
return split ? { label: split[1]!.trim(), description: split[2]!.trim() } : { label: flat, description: "" };
|
|
159
212
|
}
|
|
160
213
|
|
|
214
|
+
/** The option the asker recommends: the one marked `(Recommended)`, else the first. */
|
|
215
|
+
export function recommendedOption(question: AskedQuestion): PanelOption | undefined {
|
|
216
|
+
return question.options.find((option) => /\(recommended\)/i.test(option.label)) ?? question.options[0];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** An option's label without its `(Recommended)` marker. */
|
|
220
|
+
export function optionLabel(option: PanelOption): string {
|
|
221
|
+
return option.label.replace(/\s*\(recommended\)\s*/i, " ").trim();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Record questions nobody will ask any more as assumptions in the plan, each
|
|
226
|
+
* decided with its recommended option: under the plan's `### Assumptions`
|
|
227
|
+
* section when it has one, otherwise in a new one at the end.
|
|
228
|
+
*/
|
|
229
|
+
export function appendAssumptions(plan: string, questions: readonly PanelQuestion[], why: string): string {
|
|
230
|
+
if (questions.length === 0) return plan;
|
|
231
|
+
const lines = questions.map((question) => {
|
|
232
|
+
const pick = recommendedOption(question);
|
|
233
|
+
return `- [${question.from}] ${question.text} → ${pick ? optionLabel(pick) : "the oracle's call"} (${why})`;
|
|
234
|
+
});
|
|
235
|
+
const rows = plan.split("\n");
|
|
236
|
+
const heading = rows.findIndex((row) => /^#{2,3}\s+assumptions\s*$/i.test(row.trim()));
|
|
237
|
+
if (heading < 0) return `${plan.trimEnd()}\n\n### Assumptions\n${lines.join("\n")}`;
|
|
238
|
+
let end = rows.findIndex((row, index) => index > heading && /^#{1,3}\s/.test(row));
|
|
239
|
+
if (end < 0) end = rows.length;
|
|
240
|
+
while (end > heading + 1 && rows[end - 1]!.trim() === "") end -= 1;
|
|
241
|
+
return [...rows.slice(0, end), ...lines, ...rows.slice(end)].join("\n");
|
|
242
|
+
}
|
|
243
|
+
|
|
161
244
|
/** Inline `a) … b) …` choices, when a question carries its options in its own text. */
|
|
162
245
|
function inlineOptions(question: AskedQuestion): AskedQuestion {
|
|
163
246
|
if (question.options.length > 0) return question;
|
|
@@ -251,6 +334,7 @@ export function plannerTranscript(messages: readonly PlannerMessage[], seed?: Pl
|
|
|
251
334
|
for (const message of messages) {
|
|
252
335
|
const body = message.questions && message.questions.length > 0 ? message.questions.map(questionLine).join("\n") : message.text.trim();
|
|
253
336
|
lines.push(message.role === "you" ? "### User" : "### Panel", "", body, "");
|
|
337
|
+
if (message.decided && message.decided.length > 0) lines.push(decidedBlock(message.decided), "");
|
|
254
338
|
}
|
|
255
339
|
if (draft) lines.push("## The oracle's current draft plan", "", truncate(draft, 8000), "");
|
|
256
340
|
lines.push(closing);
|
|
@@ -263,6 +347,14 @@ export interface MemberOutcome {
|
|
|
263
347
|
error?: string;
|
|
264
348
|
}
|
|
265
349
|
|
|
350
|
+
/** Questions the classifier answered, as every seat and the oracle read them. */
|
|
351
|
+
export function decidedBlock(decided: readonly AutoAnswer[]): string {
|
|
352
|
+
return [
|
|
353
|
+
"Decided by the classifier (each is the recommended option, which the conversation already makes clearly right; list them under Assumptions, the user can overrule them):",
|
|
354
|
+
...decided.map((entry) => `- [${entry.from}] ${entry.question} → ${entry.answer} (${entry.probability.toFixed(2)})`),
|
|
355
|
+
].join("\n");
|
|
356
|
+
}
|
|
357
|
+
|
|
266
358
|
/** The seats' output for the oracle: each seat's status, questions and notes, or its failure. */
|
|
267
359
|
export function panelSection(outcomes: readonly MemberOutcome[]): string {
|
|
268
360
|
if (outcomes.length === 0) return "## Panel this round\n\nNo domain seats this round; you are planning alone.";
|
|
@@ -293,7 +385,12 @@ export function commentBlock(comments: readonly LineComment[]): string {
|
|
|
293
385
|
}
|
|
294
386
|
|
|
295
387
|
/** What the panel said in a round, as the conversation shows it. */
|
|
296
|
-
export function plannerSays(ready: boolean, questions: readonly PanelQuestion[]): string {
|
|
388
|
+
export function plannerSays(ready: boolean, questions: readonly PanelQuestion[], mode: RoundMode = "normal"): string {
|
|
389
|
+
if (mode !== "normal") {
|
|
390
|
+
return ready
|
|
391
|
+
? "The round limit is reached: the oracle decided what was still open (see Assumptions). Save the plan, or comment on a line to revise it."
|
|
392
|
+
: "The round limit is reached, but there is no draft yet. Reply or retry to let the oracle write one.";
|
|
393
|
+
}
|
|
297
394
|
if (ready) return "The panel agrees the plan is clear. Save it as a pending task, or keep refining.";
|
|
298
395
|
if (questions.length === 0) return "No open questions this round. Save the draft, or add detail.";
|
|
299
396
|
return questions.map(questionLine).join("\n");
|
|
@@ -343,7 +440,19 @@ export class PlanningSession {
|
|
|
343
440
|
answered: AskResult[] = [];
|
|
344
441
|
/** Comments on draft lines, sent with the user's next turn. */
|
|
345
442
|
lineComments: LineComment[] = [];
|
|
443
|
+
/** How the latest round ran under the round limit. */
|
|
444
|
+
mode: RoundMode = "normal";
|
|
445
|
+
/** Seats pinned on with a key press: they sit every round, whatever the classifier says. */
|
|
446
|
+
readonly pins = new Set<PanelMember>();
|
|
447
|
+
/** Seats the classifier left out of the latest round, with how likely it judged them needed. */
|
|
448
|
+
satOut = new Map<PanelMember, number>();
|
|
449
|
+
/** How each seat left the last round it sat. */
|
|
450
|
+
private readonly lastStatus = new Map<PanelMember, "ready" | "open">();
|
|
451
|
+
/** The last round was started by the classifier settling every question; the next one waits for the user. */
|
|
452
|
+
private autoContinued = false;
|
|
346
453
|
private controller?: AbortController;
|
|
454
|
+
/** Round attempts, retries included, so each attempt's feed steps stay apart. */
|
|
455
|
+
private attempts = 0;
|
|
347
456
|
private readonly deps: PlannerDeps;
|
|
348
457
|
private readonly memberNotes = new Map<PanelMember, string[]>();
|
|
349
458
|
|
|
@@ -361,16 +470,40 @@ export class PlanningSession {
|
|
|
361
470
|
return this.status === "thinking";
|
|
362
471
|
}
|
|
363
472
|
|
|
364
|
-
/**
|
|
473
|
+
/** Rounds before the oracle finalizes alone; 0 = unlimited. */
|
|
474
|
+
get limit(): number {
|
|
475
|
+
return Math.max(0, this.deps.maxRounds?.() ?? 0);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** How the next round will run. */
|
|
479
|
+
get nextMode(): RoundMode {
|
|
480
|
+
return roundMode(this.turns + 1, this.limit);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Seat or unseat a member for the next round; returns whether it now sits.
|
|
485
|
+
* Seating one pins it (it sits every round, whatever the classifier says);
|
|
486
|
+
* unseating keeps it off until seated again.
|
|
487
|
+
*/
|
|
365
488
|
toggle(member: PanelMember): boolean {
|
|
366
|
-
if (this.seats.has(member))
|
|
367
|
-
|
|
489
|
+
if (this.seats.has(member)) {
|
|
490
|
+
this.seats.delete(member);
|
|
491
|
+
this.pins.delete(member);
|
|
492
|
+
} else {
|
|
493
|
+
this.seats.add(member);
|
|
494
|
+
this.pins.add(member);
|
|
495
|
+
}
|
|
368
496
|
this.deps.onChange?.();
|
|
369
497
|
return this.seats.has(member);
|
|
370
498
|
}
|
|
371
499
|
|
|
372
500
|
/** Add the user's message (the idea, or answers), with any line comments, and run a round. */
|
|
373
501
|
async send(text: string): Promise<void> {
|
|
502
|
+
this.autoContinued = false;
|
|
503
|
+
await this.post(text);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private async post(text: string): Promise<void> {
|
|
374
507
|
const body = [text.trim(), commentBlock(this.lineComments)].filter(Boolean).join("\n\n");
|
|
375
508
|
if (!body) return;
|
|
376
509
|
if (this.busy) throw new Error("the panel is still thinking");
|
|
@@ -417,6 +550,8 @@ export class PlanningSession {
|
|
|
417
550
|
async retry(): Promise<void> {
|
|
418
551
|
if (!this.retryable) return;
|
|
419
552
|
if (this.messages.at(-1)?.role === "planner") this.messages = this.messages.slice(0, -1);
|
|
553
|
+
// A retry replays the same round; it does not use up another one.
|
|
554
|
+
this.turns = Math.max(0, this.turns - 1);
|
|
420
555
|
await this.turn();
|
|
421
556
|
}
|
|
422
557
|
|
|
@@ -439,7 +574,7 @@ export class PlanningSession {
|
|
|
439
574
|
}
|
|
440
575
|
|
|
441
576
|
private stepKey(who: string): string {
|
|
442
|
-
return `plan-${who}-${this.
|
|
577
|
+
return `plan-${who}-${this.attempts}`;
|
|
443
578
|
}
|
|
444
579
|
|
|
445
580
|
/** Stream one agent's steps and thoughts into its state and the lobby feed. */
|
|
@@ -454,7 +589,7 @@ export class PlanningSession {
|
|
|
454
589
|
this.deps.onChange?.();
|
|
455
590
|
}
|
|
456
591
|
|
|
457
|
-
private async run(label: string, kind: "planner" | "panel", profile: QuickFixProfile, request: { task: string; systemPrompt: string; tools: readonly string[] }, signal: AbortSignal, setStep: (step: string) => void): Promise<RunOutcome> {
|
|
592
|
+
private async run(label: string, kind: "planner" | "panel", profile: QuickFixProfile, request: { task: string; systemPrompt: string; tools: readonly string[] }, signal: AbortSignal, setStep: (step: string) => void, routedFrom?: string): Promise<RunOutcome> {
|
|
458
593
|
const startedAt = Date.now();
|
|
459
594
|
let outcome: RunOutcome;
|
|
460
595
|
try {
|
|
@@ -477,23 +612,55 @@ export class PlanningSession {
|
|
|
477
612
|
outcome = { status: "failed", output: "", error: (error as Error).message, usage: { input: 0, output: 0, cost: 0, turns: 0 } };
|
|
478
613
|
}
|
|
479
614
|
this.deps.feed?.end(this.stepKey(label), outcome.status !== "success");
|
|
480
|
-
|
|
615
|
+
const metric = planningMetric(kind, label, this.turns, startedAt, outcome.status, profile, outcome.model, outcome.usage);
|
|
616
|
+
appendMetrics(this.deps.root, this.deps.configDir, [routedFrom ? { ...metric, routedFrom } : metric]);
|
|
481
617
|
return outcome;
|
|
482
618
|
}
|
|
483
619
|
|
|
484
|
-
|
|
620
|
+
/** The read-only tools a seat (or the oracle) gets, plus the file lookup while hints are on. */
|
|
621
|
+
private tools(base: readonly string[]): readonly string[] {
|
|
622
|
+
const extra = this.deps.hints?.tools() ?? [];
|
|
623
|
+
return extra.length > 0 ? [...base, ...extra] : base;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** The round's Likely files, ranked once against the idea and the latest answers. */
|
|
627
|
+
private async likely(signal: AbortSignal): Promise<string> {
|
|
628
|
+
if (!this.deps.hints) return "";
|
|
629
|
+
try {
|
|
630
|
+
return await this.deps.hints.block(`${this.idea()}\n\n${this.latest()}`, signal);
|
|
631
|
+
} catch {
|
|
632
|
+
return "";
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** How hard the round's idea is, scored once and shared by every seat. */
|
|
637
|
+
private async scoreRound(signal: AbortSignal): Promise<EffortScore | undefined> {
|
|
638
|
+
if (!this.deps.effort) return undefined;
|
|
639
|
+
try {
|
|
640
|
+
return await this.deps.effort.score(`Plan this with the user, asking only what changes how it is built:\n${this.idea()}\n\n${this.latest()}`, { signal });
|
|
641
|
+
} catch {
|
|
642
|
+
return undefined;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
private async runMember(state: MemberState, transcript: string, signal: AbortSignal, effort?: EffortScore): Promise<MemberOutcome> {
|
|
485
647
|
const member = state.member;
|
|
486
648
|
const label = MEMBER_LABELS[member];
|
|
487
649
|
const profile = this.deps.memberProfile?.(member) ?? this.deps.profile();
|
|
488
|
-
const
|
|
489
|
-
task: `${transcript}\n\nYou are ${label} on the planning panel: ask your seat's open questions, or declare READY.`,
|
|
650
|
+
const request = {
|
|
651
|
+
task: `${transcript}\n\nYou are ${label} on the planning panel${roundNote(this.turns, this.limit)}: ask your seat's open questions, or declare READY.`,
|
|
490
652
|
systemPrompt: memberPrompt(member, profile.instructions),
|
|
491
|
-
tools: member === "researcher" ? RESEARCH_PANEL_TOOLS : PLANNER_TOOLS,
|
|
492
|
-
}
|
|
653
|
+
tools: this.tools(member === "researcher" ? RESEARCH_PANEL_TOOLS : PLANNER_TOOLS),
|
|
654
|
+
};
|
|
655
|
+
const route = effort ? this.deps.effort?.plan(effort, { ...(profile.model ? { model: profile.model } : {}), thinking: profile.thinking }) : undefined;
|
|
656
|
+
let outcome = await this.run(label, "panel", route ? { ...profile, model: route.model, thinking: route.thinking } : profile, request, signal, (step) => (state.step = step), route ? profileLabel(route.from) : undefined);
|
|
657
|
+
// A routed seat that fails asks again on its configured model and thinking.
|
|
658
|
+
if (route && fellShort(outcome) && !signal.aborted) outcome = await this.run(label, "panel", profile, request, signal, (step) => (state.step = step));
|
|
493
659
|
if (outcome.status === "success") {
|
|
494
660
|
state.reply = parseMemberReply(outcome.output);
|
|
495
661
|
state.status = "done";
|
|
496
662
|
this.memberNotes.set(member, state.reply.notes);
|
|
663
|
+
this.lastStatus.set(member, state.reply.status === "ready" ? "ready" : "open");
|
|
497
664
|
} else {
|
|
498
665
|
state.status = "failed";
|
|
499
666
|
state.error = outcome.error ?? outcome.status;
|
|
@@ -510,25 +677,38 @@ export class PlanningSession {
|
|
|
510
677
|
this.status = "thinking";
|
|
511
678
|
this.error = undefined;
|
|
512
679
|
this.turns += 1;
|
|
680
|
+
this.attempts += 1;
|
|
513
681
|
this.answered = [];
|
|
514
|
-
|
|
515
|
-
|
|
682
|
+
const limit = this.limit;
|
|
683
|
+
const mode = roundMode(this.turns, limit);
|
|
684
|
+
this.mode = mode;
|
|
685
|
+
// At and past the limit the oracle works alone: the seats have had their rounds.
|
|
686
|
+
const eligible = mode === "normal" ? PANEL_MEMBERS.filter((member) => this.seats.has(member)) : [];
|
|
687
|
+
this.members = [];
|
|
688
|
+
this.satOut = new Map();
|
|
689
|
+
this.step = "reading the conversation";
|
|
516
690
|
this.deps.onChange?.();
|
|
517
691
|
try {
|
|
518
|
-
const
|
|
519
|
-
|
|
692
|
+
const [seated, likely, effort] = await Promise.all([this.chooseSeats(eligible, controller.signal), this.likely(controller.signal), eligible.length > 0 ? this.scoreRound(controller.signal) : undefined]);
|
|
693
|
+
if (controller.signal.aborted) throw new Error("stopped");
|
|
694
|
+
this.members = seated.map((member) => ({ member, status: "thinking", step: "reading the conversation" }));
|
|
695
|
+
this.step = this.members.length > 0 ? "waiting for the panel" : "reading the conversation";
|
|
696
|
+
this.deps.onChange?.();
|
|
697
|
+
const transcript = [plannerTranscript(this.messages, this.seed, this.reply?.plan, ""), likely].filter(Boolean).join("\n\n");
|
|
698
|
+
const outcomes = await Promise.all(this.members.map((state) => this.runMember(state, transcript, controller.signal, effort)));
|
|
520
699
|
if (controller.signal.aborted) throw new Error("stopped");
|
|
521
700
|
this.notes = PANEL_MEMBERS.flatMap((member) => (this.memberNotes.get(member) ?? []).map((text) => ({ from: MEMBER_LABELS[member], text })));
|
|
522
701
|
this.step = "writing the plan";
|
|
523
702
|
this.deps.onChange?.();
|
|
524
703
|
const profile = this.deps.profile();
|
|
525
704
|
const lead = await this.run(ORACLE_LABEL, "planner", profile, {
|
|
526
|
-
task: `${
|
|
705
|
+
task: `${transcript}\n\n${panelSection(outcomes)}\n\n${oracleClosing(mode, this.turns, limit)}`,
|
|
527
706
|
systemPrompt: plannerPrompt(profile.instructions),
|
|
528
|
-
tools: PLANNER_TOOLS,
|
|
707
|
+
tools: this.tools(PLANNER_TOOLS),
|
|
529
708
|
}, controller.signal, (step) => (this.step = step));
|
|
530
709
|
if (controller.signal.aborted) throw new Error("stopped");
|
|
531
|
-
this.finishRound(outcomes, lead);
|
|
710
|
+
this.finishRound(outcomes, lead, mode);
|
|
711
|
+
if (mode === "normal" && this.questions.length > 0) await this.answerObvious(controller.signal);
|
|
532
712
|
} catch (error) {
|
|
533
713
|
this.error = (error as Error).message;
|
|
534
714
|
} finally {
|
|
@@ -540,26 +720,124 @@ export class PlanningSession {
|
|
|
540
720
|
this.deps.onRound?.(this);
|
|
541
721
|
}
|
|
542
722
|
// Comments left on draft lines during the round go to the panel now, unless questions wait (they ride with the answers).
|
|
543
|
-
if (!this.error && !this.busy && this.questions.length === 0 && this.lineComments.length > 0)
|
|
723
|
+
if (!this.error && !this.busy && this.questions.length === 0 && this.lineComments.length > 0) {
|
|
724
|
+
await this.send("");
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
// The classifier settled every question: the panel folds those answers in without waiting for you, once in a row.
|
|
728
|
+
const settled = this.messages.at(-1)?.decided?.length ?? 0;
|
|
729
|
+
if (!this.error && !this.busy && settled > 0 && this.questions.length === 0 && this.reply?.status !== "ready" && !this.autoContinued) {
|
|
730
|
+
this.autoContinued = true;
|
|
731
|
+
await this.post(CLASSIFIER_CONTINUE);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** The idea as first described, with the source issue. */
|
|
736
|
+
private idea(): string {
|
|
737
|
+
const first = this.messages.find((message) => message.role === "you")?.text ?? "";
|
|
738
|
+
return this.seed ? `Issue #${this.seed.issue.number}: ${this.seed.issue.title}\n\n${this.seed.body}\n\n${first}`.trim() : first;
|
|
544
739
|
}
|
|
545
740
|
|
|
546
|
-
/** The
|
|
547
|
-
private
|
|
741
|
+
/** The user's newest message, with any answers the classifier gave in the round before it. */
|
|
742
|
+
private latest(): string {
|
|
743
|
+
let index = this.messages.length - 1;
|
|
744
|
+
while (index >= 0 && this.messages[index]!.role !== "you") index -= 1;
|
|
745
|
+
if (index < 0) return "";
|
|
746
|
+
const decided = this.messages[index - 1]?.decided;
|
|
747
|
+
return [decided && decided.length > 0 ? decidedBlock(decided) : "", this.messages[index]!.text].filter(Boolean).join("\n\n");
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* The seats that sit this round: every eligible seat, unless the classifier
|
|
752
|
+
* judges some unpinned ones not needed. Pinned seats always sit; a failure
|
|
753
|
+
* or a classifier that is off seats everyone.
|
|
754
|
+
*/
|
|
755
|
+
private async chooseSeats(eligible: readonly PanelMember[], signal: AbortSignal): Promise<PanelMember[]> {
|
|
756
|
+
const jev = this.deps.classifier;
|
|
757
|
+
const open = eligible.filter((member) => !this.pins.has(member));
|
|
758
|
+
if (!jev || open.length === 0 || !jev.enabled("seats")) return [...eligible];
|
|
759
|
+
this.step = "choosing seats";
|
|
760
|
+
this.deps.onChange?.();
|
|
761
|
+
const decision = await chooseSeats(jev, {
|
|
762
|
+
round: this.turns,
|
|
763
|
+
idea: this.idea(),
|
|
764
|
+
latest: this.latest(),
|
|
765
|
+
...(this.reply?.plan ? { draft: this.reply.plan } : {}),
|
|
766
|
+
candidates: open.map((member) => ({
|
|
767
|
+
member,
|
|
768
|
+
label: MEMBER_LABELS[member],
|
|
769
|
+
owns: MEMBER_SEATS[member],
|
|
770
|
+
...(this.lastStatus.has(member) ? { lastStatus: this.lastStatus.get(member)! } : {}),
|
|
771
|
+
notes: this.memberNotes.get(member) ?? [],
|
|
772
|
+
})),
|
|
773
|
+
}, signal);
|
|
774
|
+
if (!decision) return [...eligible];
|
|
775
|
+
const seated = eligible.filter((member) => this.pins.has(member) || decision.seat.has(member));
|
|
776
|
+
for (const member of open) if (!decision.seat.has(member)) this.satOut.set(member, decision.probabilities.get(member) ?? 0);
|
|
777
|
+
const out = [...this.satOut].map(([member, probability]) => `${MEMBER_LABELS[member]} sat out (${probability.toFixed(2)})`);
|
|
778
|
+
this.deps.feed?.log("CLASSIFIER", [`seated ${seated.length > 0 ? seated.map((member) => MEMBER_LABELS[member]).join(", ") : "nobody (the oracle plans alone)"}`, ...out, `${decision.ms} ms`].join(" · "), "info");
|
|
779
|
+
return seated;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Answer the round's questions the conversation already settles: the
|
|
784
|
+
* classifier's pick must be the recommended option, confident and clearly
|
|
785
|
+
* ahead. They leave the questionnaire and are recorded on the round's
|
|
786
|
+
* message, so every seat and the oracle read them as decided.
|
|
787
|
+
*/
|
|
788
|
+
private async answerObvious(signal: AbortSignal): Promise<void> {
|
|
789
|
+
const jev = this.deps.classifier;
|
|
790
|
+
if (!jev?.enabled("answers")) return;
|
|
791
|
+
this.step = "checking for obvious answers";
|
|
792
|
+
this.deps.onChange?.();
|
|
793
|
+
const candidates = this.questions.map((question, index) => {
|
|
794
|
+
const recommended = recommendedOption(question);
|
|
795
|
+
return { index, from: question.from, text: question.text, options: question.options.map((option) => ({ label: optionLabel(option), description: option.description })), recommended: recommended ? optionLabel(recommended) : "" };
|
|
796
|
+
});
|
|
797
|
+
const decided = await autoAnswer(jev, candidates, { request: this.idea(), conversation: plannerTranscript(this.messages.slice(0, -1), this.seed, undefined, ""), ...(this.reply?.plan ? { draft: this.reply.plan } : {}) }, signal);
|
|
798
|
+
if (!decided || decided.length === 0 || signal.aborted) return;
|
|
799
|
+
const settled = new Set(decided.map((entry) => entry.index));
|
|
800
|
+
this.questions = this.questions.filter((_question, index) => !settled.has(index));
|
|
801
|
+
const last = this.messages.at(-1);
|
|
802
|
+
if (last?.role === "planner") {
|
|
803
|
+
const { questions: _asked, ...rest } = last;
|
|
804
|
+
const text = this.questions.length > 0 ? plannerSays(false, this.questions) : "The classifier settled this round's questions with the recommended options; the panel folds them in next.";
|
|
805
|
+
this.messages = [...this.messages.slice(0, -1), { ...rest, text, decided, ...(this.questions.length > 0 ? { questions: this.questions } : {}) }];
|
|
806
|
+
}
|
|
807
|
+
this.deps.feed?.log("CLASSIFIER", `answered ${decided.map((entry) => `[${entry.from}] ${entry.answer} (${entry.probability.toFixed(2)})`).join(", ")}; ${this.questions.length} left for you`, "info");
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* The oracle's reply sets the round's questions (chosen from the seats' and
|
|
812
|
+
* its own), verdict and draft. At and past the round limit nothing more is
|
|
813
|
+
* asked: any question the oracle still wrote is decided with its
|
|
814
|
+
* recommended option and recorded in the plan's Assumptions, and a plan
|
|
815
|
+
* that exists is READY.
|
|
816
|
+
*/
|
|
817
|
+
private finishRound(outcomes: readonly MemberOutcome[], lead: RunOutcome, mode: RoundMode): void {
|
|
548
818
|
const reply = lead.status === "success" ? parsePlannerReply(lead.output) : undefined;
|
|
549
819
|
const seatQuestions = outcomes.flatMap((outcome) => (outcome.reply?.questions ?? []).map((question) => ({ ...question, from: MEMBER_LABELS[outcome.member] })));
|
|
550
|
-
|
|
820
|
+
let questions = roundQuestions(reply, seatQuestions);
|
|
551
821
|
const seatsReady = outcomes.every((outcome) => outcome.reply?.status === "ready");
|
|
552
|
-
|
|
822
|
+
let ready = Boolean(reply && reply.status === "ready" && seatsReady);
|
|
553
823
|
if (reply) {
|
|
554
824
|
// A round without a plan keeps the previous draft.
|
|
555
|
-
|
|
556
|
-
|
|
825
|
+
let plan = reply.plan ?? this.reply?.plan;
|
|
826
|
+
if (mode !== "normal") {
|
|
827
|
+
if (plan && questions.length > 0) {
|
|
828
|
+
plan = appendAssumptions(plan, questions, "decided at the round limit");
|
|
829
|
+
this.deps.feed?.log(ORACLE_LABEL, `round limit: decided ${questions.length} open question${questions.length === 1 ? "" : "s"} with the recommended option`, "info");
|
|
830
|
+
}
|
|
831
|
+
questions = [];
|
|
832
|
+
ready = Boolean(plan);
|
|
833
|
+
}
|
|
834
|
+
this.reply = { ...reply, status: ready ? "ready" : "grilling", ...(mode !== "normal" ? { questions: [] } : {}), ...(plan ? { plan } : {}) };
|
|
557
835
|
} else {
|
|
558
836
|
this.error = `the oracle's part of the round failed — ${lead.error ?? lead.status}`;
|
|
559
837
|
}
|
|
560
838
|
if (!reply && seatQuestions.length === 0) return;
|
|
561
839
|
this.questions = ready ? [] : questions;
|
|
562
|
-
this.messages = [...this.messages, { role: "planner", text: plannerSays(ready, this.questions), at: Date.now(), ...(this.questions.length > 0 ? { questions: this.questions } : {}) }];
|
|
840
|
+
this.messages = [...this.messages, { role: "planner", text: plannerSays(ready, this.questions, mode), at: Date.now(), ...(this.questions.length > 0 ? { questions: this.questions } : {}) }];
|
|
563
841
|
}
|
|
564
842
|
}
|
|
565
843
|
|