@a-t-h-i/bot-lobby 0.2.0 → 0.4.0
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 +138 -32
- package/package.json +1 -1
- package/prompts/backend.md +46 -1
- package/prompts/designer.md +94 -15
- package/prompts/master.md +58 -1
- package/prompts/qa.md +35 -2
- package/prompts/researcher.md +6 -0
- package/prompts/reviewer.md +16 -0
- package/prompts/scout.md +11 -2
- package/prompts/worker.md +35 -2
- package/src/desk/client-extension.ts +101 -0
- package/src/desk/desk.ts +249 -0
- package/src/desk/ipc.ts +178 -0
- package/src/desk/session.ts +214 -0
- package/src/execution/agent-runner.ts +160 -14
- package/src/execution/pi-runner.ts +351 -65
- package/src/index.ts +4 -0
- package/src/master/master.ts +21 -17
- package/src/master/research.ts +8 -7
- package/src/pi/activity.ts +45 -0
- package/src/pi/commands.ts +28 -3
- package/src/pi/expressions.ts +84 -16
- package/src/pi/kaomoji.ts +227 -0
- package/src/pi/mascot-art.ts +125 -35
- package/src/pi/model-support.ts +101 -0
- package/src/pi/run-summary.ts +170 -0
- package/src/pi/settings-ui.ts +158 -60
- package/src/pi/tools.ts +50 -7
- package/src/pi/ui.ts +82 -13
- package/src/pi/zen-large.ts +158 -20
- package/src/pi/zen-metrics.ts +47 -7
- package/src/pi/zen.ts +42 -14
- package/src/roles/reviewer.ts +24 -4
- package/src/roles/worker.ts +7 -1
- package/src/schemas/configuration.ts +112 -18
- package/src/schemas/findings.ts +20 -0
- package/src/schemas/task.ts +26 -1
- package/src/state/project.ts +9 -0
- package/src/text.ts +9 -0
- package/src/workflow/workflow.ts +127 -10
|
@@ -1,13 +1,24 @@
|
|
|
1
|
+
import type { Domain, Role } from "./agent.ts";
|
|
2
|
+
|
|
1
3
|
export type ModelRef = "inherit" | string;
|
|
2
4
|
|
|
3
5
|
/** Thinking levels accepted by the pi CLI (`--thinking`). */
|
|
4
6
|
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
5
7
|
export type ThinkingLevelName = (typeof THINKING_LEVELS)[number];
|
|
6
8
|
|
|
7
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* A model value meaning "not configured". The master keeps the live session;
|
|
11
|
+
* a subagent falls back to the session's model until settings pin one.
|
|
12
|
+
* Thinking never inherits: every agent runs at the level its settings name.
|
|
13
|
+
*/
|
|
8
14
|
const INHERIT = "inherit";
|
|
9
15
|
export const INHERIT_MODEL = INHERIT;
|
|
16
|
+
/** Legacy thinking sentinel; configs that still carry it migrate to `DEFAULT_THINKING`. */
|
|
10
17
|
export const INHERIT_THINKING = INHERIT;
|
|
18
|
+
/** Level used for a missing, legacy `inherit` or unknown thinking value. */
|
|
19
|
+
export const DEFAULT_THINKING: ThinkingLevelName = "medium";
|
|
20
|
+
/** Scouts are reconnaissance: always fast, never configurable. */
|
|
21
|
+
export const SCOUT_THINKING: ThinkingLevelName = "low";
|
|
11
22
|
|
|
12
23
|
export function isThinkingLevel(value: string): value is ThinkingLevelName {
|
|
13
24
|
return (THINKING_LEVELS as readonly string[]).includes(value);
|
|
@@ -18,8 +29,20 @@ export interface AgentModelConfig {
|
|
|
18
29
|
thinking: string;
|
|
19
30
|
/** Free-form instructions layered on top of this agent's built-in prompt. */
|
|
20
31
|
instructions?: string;
|
|
32
|
+
/** Time limit per run; falls back to `workflow.agentTimeoutMs`. */
|
|
33
|
+
timeoutMs?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Scouts pick a model and a time limit only; their thinking is fixed at `SCOUT_THINKING`. */
|
|
37
|
+
export interface ScoutConfig {
|
|
38
|
+
model: ModelRef;
|
|
39
|
+
timeoutMs: number;
|
|
21
40
|
}
|
|
22
41
|
|
|
42
|
+
/** Subagent kinds with their own settings entry. */
|
|
43
|
+
export const SUBAGENT_KINDS = ["designer", "backend", "qa", "scout", "researcher"] as const;
|
|
44
|
+
export type SubagentKind = (typeof SUBAGENT_KINDS)[number];
|
|
45
|
+
|
|
23
46
|
export interface WorkflowConfig {
|
|
24
47
|
maxReviewIterations: number;
|
|
25
48
|
maxParallelScouts: number;
|
|
@@ -27,8 +50,16 @@ export interface WorkflowConfig {
|
|
|
27
50
|
requireApprovalForDependencies: boolean;
|
|
28
51
|
requireApprovalForArchitectureChanges: boolean;
|
|
29
52
|
agentTimeoutMs: number;
|
|
30
|
-
/** Bounded retries for transient agent failures (crash/
|
|
53
|
+
/** Bounded retries for transient agent failures (crash/stall), §59. A spent deadline never retries. */
|
|
31
54
|
maxAgentRetries: number;
|
|
55
|
+
/** Kill a subagent after this long without any output; 0 disables. */
|
|
56
|
+
stallTimeoutMs: number;
|
|
57
|
+
/** Silence allowed while a single tool call runs (tests, builds); 0 disables. */
|
|
58
|
+
toolStallTimeoutMs: number;
|
|
59
|
+
/** Fraction of the time limit at which an agent is asked to wrap up and report; 0 disables. */
|
|
60
|
+
wrapUpAt: number;
|
|
61
|
+
/** Workers that may run at once when the Master delegates several domains together. */
|
|
62
|
+
maxParallelWorkers: number;
|
|
32
63
|
}
|
|
33
64
|
|
|
34
65
|
export interface KnowledgeConfig {
|
|
@@ -41,6 +72,8 @@ export interface KnowledgeConfig {
|
|
|
41
72
|
export interface BotLobbyConfig {
|
|
42
73
|
master: AgentModelConfig;
|
|
43
74
|
agents: Record<"designer" | "backend" | "qa", AgentModelConfig>;
|
|
75
|
+
scout: ScoutConfig;
|
|
76
|
+
researcher: AgentModelConfig;
|
|
44
77
|
workflow: WorkflowConfig;
|
|
45
78
|
knowledge: KnowledgeConfig;
|
|
46
79
|
}
|
|
@@ -48,10 +81,12 @@ export interface BotLobbyConfig {
|
|
|
48
81
|
export const DEFAULT_CONFIG: BotLobbyConfig = {
|
|
49
82
|
master: { model: INHERIT_MODEL, thinking: "high", instructions: "" },
|
|
50
83
|
agents: {
|
|
51
|
-
designer: { model: INHERIT_MODEL, thinking:
|
|
52
|
-
backend: { model: INHERIT_MODEL, thinking:
|
|
53
|
-
qa: { model: INHERIT_MODEL, thinking:
|
|
84
|
+
designer: { model: INHERIT_MODEL, thinking: DEFAULT_THINKING, instructions: "", timeoutMs: 15 * 60 * 1000 },
|
|
85
|
+
backend: { model: INHERIT_MODEL, thinking: DEFAULT_THINKING, instructions: "", timeoutMs: 15 * 60 * 1000 },
|
|
86
|
+
qa: { model: INHERIT_MODEL, thinking: DEFAULT_THINKING, instructions: "", timeoutMs: 15 * 60 * 1000 },
|
|
54
87
|
},
|
|
88
|
+
scout: { model: INHERIT_MODEL, timeoutMs: 8 * 60 * 1000 },
|
|
89
|
+
researcher: { model: INHERIT_MODEL, thinking: "low", instructions: "", timeoutMs: 10 * 60 * 1000 },
|
|
55
90
|
workflow: {
|
|
56
91
|
maxReviewIterations: 2,
|
|
57
92
|
maxParallelScouts: 3,
|
|
@@ -60,6 +95,10 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
|
|
|
60
95
|
requireApprovalForArchitectureChanges: true,
|
|
61
96
|
agentTimeoutMs: 15 * 60 * 1000,
|
|
62
97
|
maxAgentRetries: 1,
|
|
98
|
+
stallTimeoutMs: 5 * 60 * 1000,
|
|
99
|
+
toolStallTimeoutMs: 10 * 60 * 1000,
|
|
100
|
+
wrapUpAt: 0.75,
|
|
101
|
+
maxParallelWorkers: 3,
|
|
63
102
|
},
|
|
64
103
|
knowledge: {
|
|
65
104
|
compactionThreshold: 20000,
|
|
@@ -69,23 +108,25 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
|
|
|
69
108
|
},
|
|
70
109
|
};
|
|
71
110
|
|
|
72
|
-
|
|
111
|
+
function positive(value: unknown): number | undefined {
|
|
112
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Merge one agent's override over its default; legacy `inherit` or unknown thinking falls back to the default level. */
|
|
73
116
|
function normalizeAgent(base: AgentModelConfig, override: Partial<AgentModelConfig> | undefined): AgentModelConfig {
|
|
74
117
|
const merged = { ...base, ...(override ?? {}) };
|
|
75
|
-
const thinking =
|
|
76
|
-
|
|
118
|
+
const thinking = isThinkingLevel(merged.thinking) ? merged.thinking : base.thinking;
|
|
119
|
+
const timeoutMs = positive(merged.timeoutMs) ?? base.timeoutMs;
|
|
120
|
+
return { ...merged, thinking, ...(timeoutMs ? { timeoutMs } : {}) };
|
|
77
121
|
}
|
|
78
122
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
]),
|
|
87
|
-
) as BotLobbyConfig["agents"];
|
|
88
|
-
return { ...config, agents };
|
|
123
|
+
/** Scouts keep a model and a time limit; any thinking value in the file is dropped. */
|
|
124
|
+
function normalizeScout(override: Partial<ScoutConfig> | undefined): ScoutConfig {
|
|
125
|
+
const base = DEFAULT_CONFIG.scout;
|
|
126
|
+
return {
|
|
127
|
+
model: typeof override?.model === "string" && override.model.trim() ? override.model : base.model,
|
|
128
|
+
timeoutMs: positive(override?.timeoutMs) ?? base.timeoutMs,
|
|
129
|
+
};
|
|
89
130
|
}
|
|
90
131
|
|
|
91
132
|
/** Deep-merge user config over defaults, keeping unknown keys out. */
|
|
@@ -101,7 +142,60 @@ export function resolveConfig(partial: unknown): BotLobbyConfig {
|
|
|
101
142
|
backend: normalizeAgent(DEFAULT_CONFIG.agents.backend, srcAgents.backend),
|
|
102
143
|
qa: normalizeAgent(DEFAULT_CONFIG.agents.qa, srcAgents.qa),
|
|
103
144
|
},
|
|
145
|
+
scout: normalizeScout(src.scout as Partial<ScoutConfig> | undefined),
|
|
146
|
+
researcher: normalizeAgent(DEFAULT_CONFIG.researcher, src.researcher as Partial<AgentModelConfig> | undefined),
|
|
104
147
|
workflow,
|
|
105
148
|
knowledge,
|
|
106
149
|
};
|
|
107
150
|
}
|
|
151
|
+
|
|
152
|
+
/** True when a config file still carries a thinking value for scouts, which is ignored. */
|
|
153
|
+
export function hasScoutThinking(partial: unknown): boolean {
|
|
154
|
+
const scout = (partial as { scout?: Record<string, unknown> } | undefined)?.scout;
|
|
155
|
+
return Boolean(scout && typeof scout === "object" && "thinking" in scout);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** What one subagent run uses: model (undefined = not configured), thinking and time limit. */
|
|
159
|
+
export interface AgentProfile {
|
|
160
|
+
kind: SubagentKind;
|
|
161
|
+
model?: string;
|
|
162
|
+
thinking: string;
|
|
163
|
+
timeoutMs: number;
|
|
164
|
+
instructions?: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The settings entry a domain/role run draws from. */
|
|
168
|
+
export function profileKind(domain: Domain, role: Role): SubagentKind {
|
|
169
|
+
if (role === "scout") return "scout";
|
|
170
|
+
if (role === "researcher") return "researcher";
|
|
171
|
+
return domain;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function modelOf(ref: ModelRef): string | undefined {
|
|
175
|
+
return ref === INHERIT_MODEL || !ref.trim() ? undefined : ref;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Resolve model, thinking and time limit for one run from settings alone.
|
|
180
|
+
* Workers and the QA gate use their domain's entry, scouts and researchers
|
|
181
|
+
* their own; custom instructions always come from the domain, so a designer
|
|
182
|
+
* scout still carries the designer's house rules.
|
|
183
|
+
*/
|
|
184
|
+
export function agentProfile(config: BotLobbyConfig, domain: Domain, role: Role): AgentProfile {
|
|
185
|
+
const kind = profileKind(domain, role);
|
|
186
|
+
const instructions = config.agents[domain].instructions;
|
|
187
|
+
const fallback = config.workflow.agentTimeoutMs;
|
|
188
|
+
if (kind === "scout") {
|
|
189
|
+
return { kind, model: modelOf(config.scout.model), thinking: SCOUT_THINKING, timeoutMs: config.scout.timeoutMs || fallback, instructions };
|
|
190
|
+
}
|
|
191
|
+
const entry = kind === "researcher" ? config.researcher : config.agents[kind];
|
|
192
|
+
return { kind, model: modelOf(entry.model), thinking: entry.thinking, timeoutMs: entry.timeoutMs ?? fallback, instructions };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Resolves the model, thinking and time limit one subagent run uses. */
|
|
196
|
+
export type ProfileResolver = (domain: Domain, role: Role) => AgentProfile;
|
|
197
|
+
|
|
198
|
+
/** The resolver a request carries, or plain settings when none was supplied (tests, headless use). */
|
|
199
|
+
export function profileFor(config: BotLobbyConfig, resolver: ProfileResolver | undefined, domain: Domain, role: Role): AgentProfile {
|
|
200
|
+
return resolver ? resolver(domain, role) : agentProfile(config, domain, role);
|
|
201
|
+
}
|
package/src/schemas/findings.ts
CHANGED
|
@@ -67,6 +67,8 @@ export interface ReviewResult {
|
|
|
67
67
|
requiredChanges: string[];
|
|
68
68
|
optionalImprovements: string[];
|
|
69
69
|
pushback?: Pushback;
|
|
70
|
+
/** Set when the engine downgraded an unsupported PASS. */
|
|
71
|
+
downgraded?: string;
|
|
70
72
|
raw: string;
|
|
71
73
|
}
|
|
72
74
|
|
|
@@ -107,4 +109,22 @@ export interface AgentRun {
|
|
|
107
109
|
usage?: { input: number; output: number; cost: number; turns: number };
|
|
108
110
|
startedAt: string;
|
|
109
111
|
finishedAt?: string;
|
|
112
|
+
/** Short target of the activity in flight: a file, command head or pattern. */
|
|
113
|
+
detail?: string;
|
|
114
|
+
/** Assistant turns and tool calls so far. */
|
|
115
|
+
turns?: number;
|
|
116
|
+
tools?: number;
|
|
117
|
+
/** Epoch ms of the last streamed output; drives the "quiet" warning. */
|
|
118
|
+
lastEventAt?: number;
|
|
119
|
+
/** Transient status worth surfacing: retrying, compacting, wrapping up, waiting on a file. */
|
|
120
|
+
note?: string;
|
|
121
|
+
noteKind?: "info" | "warning";
|
|
122
|
+
/** Model that actually served the run. */
|
|
123
|
+
model?: string;
|
|
124
|
+
/** Killed by the stall watchdog after going silent. */
|
|
125
|
+
stalled?: boolean;
|
|
126
|
+
/** Asked to wrap up before its deadline; the report may be partial. */
|
|
127
|
+
wrappedUp?: boolean;
|
|
128
|
+
/** File this worker is queued for at the file desk. */
|
|
129
|
+
waitingFor?: string;
|
|
110
130
|
}
|
package/src/schemas/task.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Domain } from "./agent.ts";
|
|
1
|
+
import type { Domain, Role } from "./agent.ts";
|
|
2
2
|
|
|
3
3
|
export const TASK_STATES = [
|
|
4
4
|
"created",
|
|
@@ -70,6 +70,29 @@ export interface WorkerRunRecord {
|
|
|
70
70
|
/** Upper bound on persisted worker records; plans cap at 50 steps. */
|
|
71
71
|
export const MAX_WORKER_RECORDS = 64;
|
|
72
72
|
|
|
73
|
+
/** One finished subagent run of any role, kept for `/bot-lobby runs`. */
|
|
74
|
+
export interface RunLogEntry {
|
|
75
|
+
runId: string;
|
|
76
|
+
domain: Domain;
|
|
77
|
+
role: Role;
|
|
78
|
+
status: "running" | "success" | "failed" | "cancelled" | "timeout";
|
|
79
|
+
startedAt: string;
|
|
80
|
+
finishedAt?: string;
|
|
81
|
+
model?: string;
|
|
82
|
+
turns?: number;
|
|
83
|
+
tools?: number;
|
|
84
|
+
input?: number;
|
|
85
|
+
output?: number;
|
|
86
|
+
cost?: number;
|
|
87
|
+
attempts?: number;
|
|
88
|
+
stalled?: boolean;
|
|
89
|
+
wrappedUp?: boolean;
|
|
90
|
+
error?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Upper bound on persisted run-log entries. */
|
|
94
|
+
export const MAX_RUN_LOG = 64;
|
|
95
|
+
|
|
73
96
|
export interface Task {
|
|
74
97
|
id: string;
|
|
75
98
|
title: string;
|
|
@@ -89,6 +112,8 @@ export interface Task {
|
|
|
89
112
|
approvals: Approval[];
|
|
90
113
|
/** Worker delegations in start order; absent on tasks created before tracking. */
|
|
91
114
|
workerRuns?: WorkerRunRecord[];
|
|
115
|
+
/** Recent finished runs of every role, newest last. */
|
|
116
|
+
runLog?: RunLogEntry[];
|
|
92
117
|
createdAt: string;
|
|
93
118
|
updatedAt: string;
|
|
94
119
|
/** The pi session (ctx.sessionManager id) that owns this task; absent on legacy tasks. */
|
package/src/state/project.ts
CHANGED
|
@@ -80,6 +80,15 @@ function configSourcePath(): string {
|
|
|
80
80
|
return candidates.find((path) => existsSync(path)) ?? candidates[0]!;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/** The config file as written, unresolved; undefined when missing or unreadable. */
|
|
84
|
+
export function readRawConfig(): unknown {
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(readFileSync(configSourcePath(), "utf8"));
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
83
92
|
/** Load the global config; fall back to defaults on any read/parse error. */
|
|
84
93
|
export function loadConfig(): BotLobbyConfig {
|
|
85
94
|
try {
|
package/src/text.ts
CHANGED
|
@@ -49,3 +49,12 @@ export function shortTitle(request: string, maxWords = 3): string {
|
|
|
49
49
|
const content = words.filter((word) => !FILLER_WORDS.has(fillerKey(word)));
|
|
50
50
|
return (content.length > 0 ? content : words).slice(0, maxWords).join(" ");
|
|
51
51
|
}
|
|
52
|
+
|
|
53
|
+
/** Compact duration such as "45s", "3m" or "2m 05s"; a non-finite input reads "0s". */
|
|
54
|
+
export function shortDuration(ms: number): string {
|
|
55
|
+
const seconds = Number.isFinite(ms) ? Math.max(0, Math.round(ms / 1000)) : 0;
|
|
56
|
+
const minutes = Math.floor(seconds / 60);
|
|
57
|
+
if (minutes === 0) return `${seconds}s`;
|
|
58
|
+
const rest = seconds % 60;
|
|
59
|
+
return rest === 0 ? `${minutes}m` : `${minutes}m ${String(rest).padStart(2, "0")}s`;
|
|
60
|
+
}
|
package/src/workflow/workflow.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import type { BotLobbyConfig } from "../schemas/configuration.ts";
|
|
2
|
+
import type { BotLobbyConfig, ProfileResolver } from "../schemas/configuration.ts";
|
|
3
3
|
import type { AgentRun, Pushback, ResearchResult, ReviewResult } from "../schemas/findings.ts";
|
|
4
4
|
import {
|
|
5
|
+
MAX_RUN_LOG,
|
|
5
6
|
MAX_WORKER_RECORDS,
|
|
6
7
|
TASK_STATES,
|
|
7
8
|
TERMINAL_STATES,
|
|
@@ -21,6 +22,10 @@ import { compactKnowledgeFile, overThreshold } from "../knowledge/compactor.ts";
|
|
|
21
22
|
import { knowledgeDir, type KnowledgeAgent } from "../knowledge/paths.ts";
|
|
22
23
|
import { writeScratchpad } from "../state/persistence.ts";
|
|
23
24
|
import { spawnPiProcess, type ProcessRunner } from "../execution/pi-runner.ts";
|
|
25
|
+
import { mapConcurrent } from "../execution/agent-runner.ts";
|
|
26
|
+
import { parseWorkerResult } from "../roles/worker.ts";
|
|
27
|
+
import { autoNote, DESK_TOOLS, DeskSession } from "../desk/session.ts";
|
|
28
|
+
import type { Handover } from "../desk/desk.ts";
|
|
24
29
|
import { readRepositoryDiff } from "../execution/git.ts";
|
|
25
30
|
import {
|
|
26
31
|
loadScoutResults,
|
|
@@ -39,6 +44,7 @@ import { detectSharedFiles, summarizeOutcomes } from "../master/synthesis.ts";
|
|
|
39
44
|
import { truncate } from "../text.ts";
|
|
40
45
|
import { assertNoPendingApprovals, pendingApprovals, requestApproval, resolveApproval } from "./approvals.ts";
|
|
41
46
|
import { pingApproval } from "../pi/notify.ts";
|
|
47
|
+
import { describeRun, runLogEntry } from "../pi/run-summary.ts";
|
|
42
48
|
import { nextStates } from "./transitions.ts";
|
|
43
49
|
|
|
44
50
|
export const ORCHESTRATE_ACTIONS = [
|
|
@@ -79,6 +85,8 @@ export interface OrchestrateParams {
|
|
|
79
85
|
domain?: string;
|
|
80
86
|
/** implement: the concrete instruction for the worker. */
|
|
81
87
|
task?: string;
|
|
88
|
+
/** implement: several domains at once, run in parallel through the file desk. */
|
|
89
|
+
assignments?: Array<{ domain: string; task: string }>;
|
|
82
90
|
/** knowledge: which persistent file the text belongs to. */
|
|
83
91
|
kind?: KnowledgeKind;
|
|
84
92
|
/** compact: the knowledge file being rewritten. */
|
|
@@ -97,6 +105,8 @@ export interface WorkflowDeps {
|
|
|
97
105
|
/** The pi session driving this workflow; task ownership is skipped when absent (tests, headless use). */
|
|
98
106
|
sessionId?: string;
|
|
99
107
|
config: BotLobbyConfig;
|
|
108
|
+
/** Per-run model/thinking/time limit, clamped to each model; plain settings when absent. */
|
|
109
|
+
profile?: ProfileResolver;
|
|
100
110
|
signal?: AbortSignal;
|
|
101
111
|
onUpdate?: (run: AgentRun) => void;
|
|
102
112
|
ask: (question: string) => Promise<string | undefined>;
|
|
@@ -110,6 +120,8 @@ export interface WorkflowResult {
|
|
|
110
120
|
taskId: string;
|
|
111
121
|
state: TaskState;
|
|
112
122
|
message: string;
|
|
123
|
+
/** Subagent runs that finished during this action, oldest first. */
|
|
124
|
+
runs?: AgentRun[];
|
|
113
125
|
}
|
|
114
126
|
|
|
115
127
|
export type ApprovalChoice = "approve" | "amend" | "decline";
|
|
@@ -250,6 +262,7 @@ async function handleScout(task: Task, params: OrchestrateParams, deps: Workflow
|
|
|
250
262
|
dataRoots: readDataRoots(deps.root, deps.configDir),
|
|
251
263
|
taskDir: taskDirFor(deps.root, deps.configDir, task.id),
|
|
252
264
|
config: deps.config,
|
|
265
|
+
profile: deps.profile,
|
|
253
266
|
signal: deps.signal,
|
|
254
267
|
onUpdate: deps.onUpdate,
|
|
255
268
|
},
|
|
@@ -344,6 +357,7 @@ function researchRequestFor(
|
|
|
344
357
|
domain,
|
|
345
358
|
instruction,
|
|
346
359
|
config: deps.config,
|
|
360
|
+
profile: deps.profile,
|
|
347
361
|
cwd: deps.cwd,
|
|
348
362
|
taskDir,
|
|
349
363
|
signal: deps.signal,
|
|
@@ -500,6 +514,7 @@ function workerRequest(deps: WorkflowDeps, task: Task, domain: Domain, instructi
|
|
|
500
514
|
cwd: deps.cwd,
|
|
501
515
|
dataRoots: readDataRoots(deps.root, deps.configDir),
|
|
502
516
|
config: deps.config,
|
|
517
|
+
profile: deps.profile,
|
|
503
518
|
signal: deps.signal,
|
|
504
519
|
onUpdate: deps.onUpdate,
|
|
505
520
|
};
|
|
@@ -518,15 +533,32 @@ function recordWorkerRun(task: Task, run: AgentRun): void {
|
|
|
518
533
|
task.workerRuns = [...(task.workerRuns ?? []), record].slice(-MAX_WORKER_RECORDS);
|
|
519
534
|
}
|
|
520
535
|
|
|
521
|
-
|
|
522
|
-
|
|
536
|
+
interface Assignment {
|
|
537
|
+
domain: Domain;
|
|
538
|
+
instruction: string;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** The delegation as a list: `assignments` for a parallel batch, otherwise the single domain/task. */
|
|
542
|
+
function parseAssignments(params: OrchestrateParams): Assignment[] {
|
|
543
|
+
if (params.assignments && params.assignments.length > 0) {
|
|
544
|
+
const list = params.assignments.map((entry) => {
|
|
545
|
+
const instruction = entry.task?.trim();
|
|
546
|
+
if (!instruction) throw new Error("every assignment needs a task (what to implement)");
|
|
547
|
+
return { domain: parseDomain(entry.domain, "implement"), instruction };
|
|
548
|
+
});
|
|
549
|
+
const domains = list.map((entry) => entry.domain);
|
|
550
|
+
if (new Set(domains).size !== domains.length) throw new Error("parallel assignments need distinct domains (one worker per domain)");
|
|
551
|
+
return list;
|
|
552
|
+
}
|
|
523
553
|
const domain = parseDomain(params.domain, "implement");
|
|
524
554
|
const instruction = params.task?.trim();
|
|
525
555
|
if (!instruction) throw new Error("implement requires task (what to implement)");
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
556
|
+
return [{ domain, instruction }];
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Record one worker's outcome on the task and return its report for the Master. */
|
|
560
|
+
function absorbWorkerOutcome(task: Task, deps: WorkflowDeps, outcome: WorkerOutcome): string {
|
|
561
|
+
const domain = outcome.result.domain;
|
|
530
562
|
recordWorkerRun(task, outcome.run);
|
|
531
563
|
const approvals = recordWorkerApprovals(task, outcome, deps.config);
|
|
532
564
|
const pushback = recordPushback(task, outcome);
|
|
@@ -535,6 +567,67 @@ async function handleImplement(task: Task, params: OrchestrateParams, deps: Work
|
|
|
535
567
|
return workerReport(outcome, approvals, pushback);
|
|
536
568
|
}
|
|
537
569
|
|
|
570
|
+
async function handleImplement(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
|
|
571
|
+
requireState(task, ["planning", "implementing", "reviewing"]);
|
|
572
|
+
const assignments = parseAssignments(params);
|
|
573
|
+
for (const { domain } of assignments) assertNoPendingApprovals(task, domain);
|
|
574
|
+
for (const { domain } of assignments) if (!task.domains.includes(domain)) task.domains.push(domain);
|
|
575
|
+
if (task.state !== "implementing") transition(task, "implementing");
|
|
576
|
+
if (assignments.length === 1) {
|
|
577
|
+
const { domain, instruction } = assignments[0]!;
|
|
578
|
+
const outcome = await runWorker(workerRequest(deps, task, domain, instruction), deps.runProcess ?? spawnPiProcess);
|
|
579
|
+
return absorbWorkerOutcome(task, deps, outcome);
|
|
580
|
+
}
|
|
581
|
+
return runParallelWorkers(task, deps, assignments);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Several domains at once. The workers share one file desk: each claims a file
|
|
586
|
+
* before editing it, queues for a busy one, and hands it over with a note; a
|
|
587
|
+
* worker that finishes hands over whatever it still holds automatically.
|
|
588
|
+
*/
|
|
589
|
+
async function runParallelWorkers(task: Task, deps: WorkflowDeps, assignments: Assignment[]): Promise<string> {
|
|
590
|
+
const session = new DeskSession({ cwd: deps.cwd });
|
|
591
|
+
await session.open();
|
|
592
|
+
let outcomes: WorkerOutcome[];
|
|
593
|
+
let unenforced: Domain[];
|
|
594
|
+
try {
|
|
595
|
+
outcomes = await mapConcurrent(assignments, deps.config.workflow.maxParallelWorkers, ({ domain, instruction }) => {
|
|
596
|
+
const request = workerRequest(deps, task, domain, instruction);
|
|
597
|
+
request.agent = {
|
|
598
|
+
env: session.env(domain),
|
|
599
|
+
extraTools: DESK_TOOLS,
|
|
600
|
+
onStart: (handle) => session.attach(domain, handle),
|
|
601
|
+
onAttemptEnd: (run) => {
|
|
602
|
+
const result = parseWorkerResult(domain, run.output);
|
|
603
|
+
session.release(domain, (path, next) => autoNote(domain, path, next, result.filesChanged, result.completed));
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
return runWorker(request, deps.runProcess ?? spawnPiProcess);
|
|
607
|
+
});
|
|
608
|
+
unenforced = assignments.map((entry) => entry.domain).filter((domain) => !session.greetedBy(domain));
|
|
609
|
+
} finally {
|
|
610
|
+
await session.close();
|
|
611
|
+
}
|
|
612
|
+
const reports = outcomes.map((outcome) => absorbWorkerOutcome(task, deps, outcome));
|
|
613
|
+
return [
|
|
614
|
+
`Parallel batch: ${assignments.map((entry) => entry.domain).join(", ")}.`,
|
|
615
|
+
...reports,
|
|
616
|
+
handoverLog(session.handovers()),
|
|
617
|
+
unenforced.length > 0
|
|
618
|
+
? `File checkout was not enforced for ${unenforced.join(", ")} (bot-lobby did not load in those workers); inspect the diff for overlapping edits.`
|
|
619
|
+
: "",
|
|
620
|
+
]
|
|
621
|
+
.filter((line) => line.length > 0)
|
|
622
|
+
.join("\n\n");
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function handoverLog(handovers: readonly Handover[]): string {
|
|
626
|
+
if (handovers.length === 0) return "";
|
|
627
|
+
const lines = handovers.map((entry) => `- ${entry.path}: ${entry.from} → ${entry.to}${entry.auto ? " (on finish)" : ""} — ${truncate(entry.note, 200)}`);
|
|
628
|
+
return `File handovers:\n${lines.join("\n")}`;
|
|
629
|
+
}
|
|
630
|
+
|
|
538
631
|
function handleResolveApproval(task: Task, params: OrchestrateParams): string {
|
|
539
632
|
const id = params.approvalId?.trim();
|
|
540
633
|
const decision = params.decision;
|
|
@@ -597,6 +690,7 @@ function qaRequest(deps: WorkflowDeps, task: Task, diff: string, instruction?: s
|
|
|
597
690
|
cwd: deps.cwd,
|
|
598
691
|
dataRoots: readDataRoots(deps.root, deps.configDir),
|
|
599
692
|
config: deps.config,
|
|
693
|
+
profile: deps.profile,
|
|
600
694
|
signal: deps.signal,
|
|
601
695
|
onUpdate: deps.onUpdate,
|
|
602
696
|
};
|
|
@@ -784,12 +878,35 @@ export async function runWorkflowAction(params: OrchestrateParams, deps: Workflo
|
|
|
784
878
|
if (!handler) {
|
|
785
879
|
return { ok: false, taskId: task.id, state: task.state, message: `Unknown action "${params.action}".` };
|
|
786
880
|
}
|
|
881
|
+
const finished = new Map<string, AgentRun>();
|
|
882
|
+
const tracked: WorkflowDeps = {
|
|
883
|
+
...deps,
|
|
884
|
+
onUpdate: (run) => {
|
|
885
|
+
if (run.status !== "running") finished.set(run.runId, run);
|
|
886
|
+
deps.onUpdate?.(run);
|
|
887
|
+
},
|
|
888
|
+
};
|
|
787
889
|
try {
|
|
788
|
-
const message = await handler(task, params,
|
|
890
|
+
const message = await handler(task, params, tracked);
|
|
891
|
+
const runs = recordRunLog(task, finished);
|
|
789
892
|
saveTask(deps.root, deps.configDir, task);
|
|
790
|
-
return { ok: true, taskId: task.id, state: task.state, message };
|
|
893
|
+
return { ok: true, taskId: task.id, state: task.state, message: `${message}${runsFooter(runs)}`, runs };
|
|
791
894
|
} catch (error) {
|
|
895
|
+
const runs = recordRunLog(task, finished);
|
|
792
896
|
saveTask(deps.root, deps.configDir, task);
|
|
793
|
-
return { ok: false, taskId: task.id, state: task.state, message: `Rejected: ${(error as Error).message}
|
|
897
|
+
return { ok: false, taskId: task.id, state: task.state, message: `Rejected: ${(error as Error).message}`, runs };
|
|
794
898
|
}
|
|
795
899
|
}
|
|
900
|
+
|
|
901
|
+
/** Append this action's finished runs to the task's bounded run log. */
|
|
902
|
+
function recordRunLog(task: Task, finished: ReadonlyMap<string, AgentRun>): AgentRun[] {
|
|
903
|
+
const runs = [...finished.values()];
|
|
904
|
+
if (runs.length > 0) task.runLog = [...(task.runLog ?? []), ...runs.map(runLogEntry)].slice(-MAX_RUN_LOG);
|
|
905
|
+
return runs;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** One line per run so the Master sees timing, model and any partial-report flag. */
|
|
909
|
+
function runsFooter(runs: readonly AgentRun[]): string {
|
|
910
|
+
if (runs.length === 0) return "";
|
|
911
|
+
return `\n\nRuns:\n${runs.map((run) => `- ${describeRun(run)}`).join("\n")}`;
|
|
912
|
+
}
|