pi-crew 0.1.18 → 0.1.20
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/CHANGELOG.md +16 -0
- package/README.md +2 -2
- package/docs/usage.md +1 -1
- package/package.json +1 -1
- package/schema.json +1 -1
- package/src/extension/register.ts +204 -13
- package/src/extension/team-tool.ts +24 -8
- package/src/runtime/child-pi.ts +5 -2
- package/src/runtime/concurrency.ts +42 -0
- package/src/runtime/pi-args.ts +4 -2
- package/src/runtime/progress-event-coalescer.ts +43 -0
- package/src/runtime/subagent-manager.ts +202 -0
- package/src/runtime/task-runner.ts +14 -5
- package/src/runtime/team-runner.ts +175 -5
- package/src/state/types.ts +4 -0
- package/src/ui/powerbar-publisher.ts +7 -6
- package/teams/implementation.team.md +5 -2
- package/workflows/implementation.workflow.md +33 -42
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.20
|
|
4
|
+
|
|
5
|
+
- Reworked the implementation workflow into an adaptive planner-led orchestration flow that decides the number, roles, and phases of subagents from the task instead of using a fixed fanout template.
|
|
6
|
+
- Added dynamic adaptive task injection, persisted adaptive task metadata, and resume reconstruction for planner-selected subagent steps.
|
|
7
|
+
- Block implementation runs when the planner does not produce a valid adaptive plan, including missing/unreadable planner artifacts and malformed/oversized plans.
|
|
8
|
+
- Added tests for adaptive plan parsing, dynamic batch fanout, invalid-plan blocking, writer-role support, and adaptive resume recovery.
|
|
9
|
+
- Hardened subagent/runtime fixes from post-0.1.19 review: env-isolated depth tests, foreground failure status updates, generic tool conflict aliases, and max_turns propagation.
|
|
10
|
+
|
|
11
|
+
## 0.1.19
|
|
12
|
+
|
|
13
|
+
- Added Claude-style `Agent`, `get_subagent_result`, and `steer_subagent` tools backed by pi-crew's durable worker runtime, plus conflict-safe `crew_agent`, `crew_agent_result`, and `crew_agent_steer` aliases.
|
|
14
|
+
- Added a durable subagent manager with background queueing, completion notifications, result joins, session-bound cleanup, and direct single-agent runs via `team run agent=...`.
|
|
15
|
+
- Disabled risky auto-opening of the right sidebar by default, added foreground completion notifications, and reduced duplicate widget/sidebar UI.
|
|
16
|
+
- Added progress coalescing and workflow concurrency helpers to keep foreground sessions responsive during busy worker output.
|
|
17
|
+
- Fixed live-session runs being classified as scaffold when workers are enabled and hardened session switch/shutdown cleanup for foreground child processes.
|
|
18
|
+
|
|
3
19
|
## 0.1.18
|
|
4
20
|
|
|
5
21
|
- Added a built-in `parallel-research` team/workflow for map-reduce style source audits with dynamic `Source/pi-*` fanout and parallel explorer shards.
|
package/README.md
CHANGED
|
@@ -174,7 +174,7 @@ Supported config:
|
|
|
174
174
|
"dashboardPlacement": "right",
|
|
175
175
|
"dashboardWidth": 56,
|
|
176
176
|
"dashboardLiveRefreshMs": 1000,
|
|
177
|
-
"autoOpenDashboard":
|
|
177
|
+
"autoOpenDashboard": false,
|
|
178
178
|
"autoOpenDashboardForForegroundRuns": true,
|
|
179
179
|
"showModel": true,
|
|
180
180
|
"showTokens": true,
|
|
@@ -190,7 +190,7 @@ Safety notes:
|
|
|
190
190
|
UI notes:
|
|
191
191
|
|
|
192
192
|
- `widgetPlacement`/`widgetMaxLines` keep the persistent active-run widget compact.
|
|
193
|
-
- `dashboardPlacement: "right"` is the default
|
|
193
|
+
- `dashboardPlacement: "right"` is the default for `/team-dashboard`; automatic overlay opening is opt-in because Pi custom overlays can be modal/focus-capturing in some terminals.
|
|
194
194
|
- `autoOpenDashboard`/`autoOpenDashboardForForegroundRuns` control whether the live sidebar opens automatically.
|
|
195
195
|
- `dashboardLiveRefreshMs` controls the live sidebar refresh cadence.
|
|
196
196
|
- `showModel`, `showTokens`, and `showTools` show worker model attempts, token usage, and tool activity in dashboard agent rows.
|
package/docs/usage.md
CHANGED
|
@@ -36,7 +36,7 @@ Supported fields:
|
|
|
36
36
|
"dashboardPlacement": "right",
|
|
37
37
|
"dashboardWidth": 56,
|
|
38
38
|
"dashboardLiveRefreshMs": 1000,
|
|
39
|
-
"autoOpenDashboard":
|
|
39
|
+
"autoOpenDashboard": false,
|
|
40
40
|
"autoOpenDashboardForForegroundRuns": true,
|
|
41
41
|
"showModel": true,
|
|
42
42
|
"showTokens": true,
|
package/package.json
CHANGED
package/schema.json
CHANGED
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
"dashboardPlacement": { "type": "string", "enum": ["center", "right"], "default": "right", "description": "Place /team-dashboard as a centered overlay or right-side panel." },
|
|
91
91
|
"dashboardWidth": { "type": "integer", "minimum": 32, "maximum": 120, "default": 56 },
|
|
92
92
|
"dashboardLiveRefreshMs": { "type": "integer", "minimum": 250, "maximum": 60000, "default": 1000 },
|
|
93
|
-
"autoOpenDashboard": { "type": "boolean", "default":
|
|
93
|
+
"autoOpenDashboard": { "type": "boolean", "default": false, "description": "Opt in to automatically opening the live right sidebar for foreground runs when UI is available. Disabled by default because Pi overlays are modal in some terminals." },
|
|
94
94
|
"autoOpenDashboardForForegroundRuns": { "type": "boolean", "default": true },
|
|
95
95
|
"showModel": { "type": "boolean", "default": true, "description": "Show worker model attempts in dashboard agent rows." },
|
|
96
96
|
"showTokens": { "type": "boolean", "description": "Show token usage in dashboard agent rows." },
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
1
2
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ToolDefinition } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { Type } from "typebox";
|
|
2
4
|
import { loadConfig } from "../config/config.ts";
|
|
3
5
|
import { registerAutonomousPolicy } from "./autonomous-policy.ts";
|
|
4
6
|
import { TeamToolParams, type TeamToolParamsValue } from "../schema/team-tool-schema.ts";
|
|
@@ -14,9 +16,10 @@ import { registerPiCrewRpc, type PiCrewRpcHandle } from "./cross-extension-rpc.t
|
|
|
14
16
|
import { stopCrewWidget, updateCrewWidget, type CrewWidgetState } from "../ui/crew-widget.ts";
|
|
15
17
|
import { clearPiCrewPowerbar, registerPiCrewPowerbarSegments, updatePiCrewPowerbar } from "../ui/powerbar-publisher.ts";
|
|
16
18
|
import { DurableTextViewer, DurableTranscriptViewer } from "../ui/transcript-viewer.ts";
|
|
17
|
-
import { loadRunManifestById } from "../state/state-store.ts";
|
|
19
|
+
import { loadRunManifestById, updateRunStatus } from "../state/state-store.ts";
|
|
18
20
|
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
19
21
|
import { terminateActiveChildPiProcesses } from "../runtime/child-pi.ts";
|
|
22
|
+
import { SubagentManager, type SubagentRecord, type SubagentSpawnOptions } from "../runtime/subagent-manager.ts";
|
|
20
23
|
|
|
21
24
|
function parseRunArgs(args: string): TeamToolParamsValue {
|
|
22
25
|
const tokens = args.match(/"[^"]*"|'[^']*'|\S+/g)?.map((token) => token.replace(/^['"]|['"]$/g, "")) ?? [];
|
|
@@ -100,6 +103,55 @@ function setNestedConfig(config: Record<string, unknown>, key: string, value: un
|
|
|
100
103
|
target[parts[parts.length - 1]!] = value;
|
|
101
104
|
}
|
|
102
105
|
|
|
106
|
+
function sendFollowUp(pi: ExtensionAPI, content: string): void {
|
|
107
|
+
const sender = (pi as unknown as { sendMessage?: (message: unknown, options?: unknown) => void }).sendMessage;
|
|
108
|
+
if (typeof sender !== "function") return;
|
|
109
|
+
sender.call(pi, { customType: "pi-crew-subagent-notification", content, display: true }, { deliverAs: "followUp", triggerTurn: true });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function formatSubagentRecord(record: SubagentRecord): string {
|
|
113
|
+
const duration = record.completedAt ? `${Math.round((record.completedAt - record.startedAt) / 1000)}s` : "running";
|
|
114
|
+
return [
|
|
115
|
+
`Agent: ${record.id}`,
|
|
116
|
+
`Type: ${record.type}`,
|
|
117
|
+
`Status: ${record.status}`,
|
|
118
|
+
record.runId ? `Run: ${record.runId}` : undefined,
|
|
119
|
+
`Description: ${record.description}`,
|
|
120
|
+
record.model ? `Model: ${record.model}` : undefined,
|
|
121
|
+
`Duration: ${duration}`,
|
|
122
|
+
record.error ? `Error: ${record.error}` : undefined,
|
|
123
|
+
].filter((line): line is string => Boolean(line)).join("\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function readSubagentRunResult(ctx: ExtensionContext | ExtensionCommandContext, record: SubagentRecord): string | undefined {
|
|
127
|
+
if (!record.runId) return record.result;
|
|
128
|
+
const loaded = loadRunManifestById(ctx.cwd, record.runId);
|
|
129
|
+
const task = loaded?.tasks.find((item) => item.resultArtifact) ?? loaded?.tasks[0];
|
|
130
|
+
const path = task?.resultArtifact?.path;
|
|
131
|
+
if (!path) return undefined;
|
|
132
|
+
try {
|
|
133
|
+
return fs.readFileSync(path, "utf-8").trim();
|
|
134
|
+
} catch {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function subagentToolResult(text: string, details: Record<string, unknown> = {}, isError = false) {
|
|
140
|
+
return { content: [{ type: "text" as const, text }], details, isError };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function __test__subagentSpawnParams(params: Record<string, unknown>, ctx: Pick<ExtensionContext, "cwd">): SubagentSpawnOptions {
|
|
144
|
+
return {
|
|
145
|
+
cwd: ctx.cwd,
|
|
146
|
+
type: typeof params.subagent_type === "string" && params.subagent_type.trim() ? params.subagent_type.trim() : "executor",
|
|
147
|
+
description: typeof params.description === "string" && params.description.trim() ? params.description.trim() : "pi-crew subagent",
|
|
148
|
+
prompt: typeof params.prompt === "string" ? params.prompt : "",
|
|
149
|
+
background: params.run_in_background === true,
|
|
150
|
+
model: typeof params.model === "string" && params.model.trim() ? params.model.trim() : undefined,
|
|
151
|
+
maxTurns: typeof params.max_turns === "number" && Number.isFinite(params.max_turns) ? params.max_turns : undefined,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
103
155
|
export function registerPiTeams(pi: ExtensionAPI): void {
|
|
104
156
|
const globalStore = globalThis as Record<string, unknown>;
|
|
105
157
|
const runtimeCleanupStoreKey = "__piCrewRuntimeCleanup";
|
|
@@ -112,18 +164,37 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
112
164
|
let rpcHandle: PiCrewRpcHandle | undefined;
|
|
113
165
|
let cleanedUp = false;
|
|
114
166
|
const widgetState: CrewWidgetState = { frame: 0 };
|
|
167
|
+
const subagentManager = new SubagentManager(4, (record) => {
|
|
168
|
+
if (!record.background || record.resultConsumed) return;
|
|
169
|
+
if (record.status === "completed" || record.status === "failed" || record.status === "cancelled" || record.status === "error") {
|
|
170
|
+
sendFollowUp(pi, [`pi-crew subagent ${record.id} ${record.status}.`, record.runId ? `Run: ${record.runId}` : undefined, `Use get_subagent_result with agent_id=${record.id} for output.`].filter((line): line is string => Boolean(line)).join("\n"));
|
|
171
|
+
}
|
|
172
|
+
});
|
|
115
173
|
const foregroundControllers = new Set<AbortController>();
|
|
116
174
|
let liveSidebarRunId: string | undefined;
|
|
117
175
|
let liveSidebarTimer: ReturnType<typeof setInterval> | undefined;
|
|
118
176
|
const requestRender = (ctx: ExtensionContext): void => (ctx.ui as { requestRender?: () => void }).requestRender?.();
|
|
177
|
+
const stopSessionBoundSubagents = (): void => {
|
|
178
|
+
for (const controller of foregroundControllers) controller.abort();
|
|
179
|
+
foregroundControllers.clear();
|
|
180
|
+
subagentManager.abortAll();
|
|
181
|
+
terminateActiveChildPiProcesses();
|
|
182
|
+
if (liveSidebarTimer) clearInterval(liveSidebarTimer);
|
|
183
|
+
liveSidebarTimer = undefined;
|
|
184
|
+
liveSidebarRunId = undefined;
|
|
185
|
+
if (currentCtx) stopCrewWidget(currentCtx, widgetState, loadConfig(currentCtx.cwd).config.ui);
|
|
186
|
+
clearPiCrewPowerbar(pi.events);
|
|
187
|
+
};
|
|
119
188
|
const openLiveSidebar = (ctx: ExtensionContext, runId: string): void => {
|
|
120
189
|
const uiConfig = loadConfig(ctx.cwd).config.ui;
|
|
121
|
-
const autoOpen = uiConfig?.autoOpenDashboard
|
|
122
|
-
const foregroundAutoOpen = uiConfig?.autoOpenDashboardForForegroundRuns
|
|
190
|
+
const autoOpen = uiConfig?.autoOpenDashboard === true;
|
|
191
|
+
const foregroundAutoOpen = uiConfig?.autoOpenDashboardForForegroundRuns !== false;
|
|
123
192
|
if (!ctx.hasUI || !autoOpen || !foregroundAutoOpen || (uiConfig?.dashboardPlacement ?? "right") !== "right") return;
|
|
124
193
|
if (liveSidebarRunId === runId) return;
|
|
125
194
|
if (liveSidebarTimer) clearInterval(liveSidebarTimer);
|
|
126
195
|
liveSidebarRunId = runId;
|
|
196
|
+
ctx.ui.setWidget("pi-crew", undefined, { placement: uiConfig?.widgetPlacement ?? "aboveEditor" });
|
|
197
|
+
ctx.ui.setWidget("pi-crew-active", undefined, { placement: uiConfig?.widgetPlacement ?? "aboveEditor" });
|
|
127
198
|
const width = Math.min(90, Math.max(40, uiConfig?.dashboardWidth ?? 56));
|
|
128
199
|
liveSidebarTimer = setInterval(() => requestRender(ctx), uiConfig?.dashboardLiveRefreshMs ?? 1000);
|
|
129
200
|
liveSidebarTimer.unref?.();
|
|
@@ -134,19 +205,32 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
134
205
|
if (liveSidebarRunId === runId) liveSidebarRunId = undefined;
|
|
135
206
|
if (liveSidebarTimer) clearInterval(liveSidebarTimer);
|
|
136
207
|
liveSidebarTimer = undefined;
|
|
208
|
+
updateCrewWidget(ctx, widgetState, loadConfig(ctx.cwd).config.ui);
|
|
137
209
|
});
|
|
138
210
|
};
|
|
139
|
-
const startForegroundRun = (ctx: ExtensionContext, runner: (signal?: AbortSignal) => Promise<void
|
|
211
|
+
const startForegroundRun = (ctx: ExtensionContext, runner: (signal?: AbortSignal) => Promise<void>, runId?: string): void => {
|
|
140
212
|
const controller = new AbortController();
|
|
141
213
|
foregroundControllers.add(controller);
|
|
142
214
|
setImmediate(() => {
|
|
143
215
|
void runner(controller.signal)
|
|
144
216
|
.catch((error) => {
|
|
145
217
|
const message = error instanceof Error ? error.message : String(error);
|
|
218
|
+
if (runId) {
|
|
219
|
+
try {
|
|
220
|
+
const loaded = loadRunManifestById(ctx.cwd, runId);
|
|
221
|
+
if (loaded && loaded.manifest.status !== "completed" && loaded.manifest.status !== "failed" && loaded.manifest.status !== "cancelled" && loaded.manifest.status !== "blocked") updateRunStatus(loaded.manifest, "failed", message);
|
|
222
|
+
} catch {}
|
|
223
|
+
}
|
|
146
224
|
ctx.ui.notify(`pi-crew foreground run failed: ${message}`, "error");
|
|
147
225
|
})
|
|
148
226
|
.finally(() => {
|
|
149
227
|
foregroundControllers.delete(controller);
|
|
228
|
+
if (runId) {
|
|
229
|
+
const loaded = loadRunManifestById(ctx.cwd, runId);
|
|
230
|
+
const status = loaded?.manifest.status ?? "finished";
|
|
231
|
+
const level = status === "failed" || status === "blocked" ? "error" : status === "cancelled" ? "warning" : "info";
|
|
232
|
+
ctx.ui.notify(`pi-crew run ${runId} ${status}. Use /team-summary ${runId} or /team-status ${runId}.`, level as "info" | "warning" | "error");
|
|
233
|
+
}
|
|
150
234
|
if (currentCtx) {
|
|
151
235
|
const config = loadConfig(currentCtx.cwd).config.ui;
|
|
152
236
|
updateCrewWidget(currentCtx, widgetState, config);
|
|
@@ -160,14 +244,9 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
160
244
|
const cleanupRuntime = (): void => {
|
|
161
245
|
if (cleanedUp) return;
|
|
162
246
|
cleanedUp = true;
|
|
163
|
-
|
|
164
|
-
foregroundControllers.clear();
|
|
165
|
-
terminateActiveChildPiProcesses();
|
|
247
|
+
stopSessionBoundSubagents();
|
|
166
248
|
stopAsyncRunNotifier(notifierState);
|
|
167
249
|
stopCrewWidget(currentCtx, widgetState, currentCtx ? loadConfig(currentCtx.cwd).config.ui : undefined);
|
|
168
|
-
if (liveSidebarTimer) clearInterval(liveSidebarTimer);
|
|
169
|
-
liveSidebarTimer = undefined;
|
|
170
|
-
liveSidebarRunId = undefined;
|
|
171
250
|
clearPiCrewPowerbar(pi.events);
|
|
172
251
|
rpcHandle?.unsubscribe();
|
|
173
252
|
rpcHandle = undefined;
|
|
@@ -179,6 +258,8 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
179
258
|
pi.on("session_start", (_event, ctx) => {
|
|
180
259
|
cleanedUp = false;
|
|
181
260
|
currentCtx = ctx;
|
|
261
|
+
if (widgetState.interval) clearInterval(widgetState.interval);
|
|
262
|
+
widgetState.interval = undefined;
|
|
182
263
|
notifyActiveRuns(ctx);
|
|
183
264
|
const loadedConfig = loadConfig(ctx.cwd);
|
|
184
265
|
registerPiCrewPowerbarSegments(pi.events, loadedConfig.config.ui);
|
|
@@ -188,11 +269,19 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
188
269
|
widgetState.interval = setInterval(() => {
|
|
189
270
|
if (!currentCtx) return;
|
|
190
271
|
const config = loadConfig(currentCtx.cwd).config.ui;
|
|
191
|
-
|
|
272
|
+
if (liveSidebarRunId) {
|
|
273
|
+
currentCtx.ui.setWidget("pi-crew", undefined, { placement: config?.widgetPlacement ?? "aboveEditor" });
|
|
274
|
+
currentCtx.ui.setWidget("pi-crew-active", undefined, { placement: config?.widgetPlacement ?? "aboveEditor" });
|
|
275
|
+
} else {
|
|
276
|
+
updateCrewWidget(currentCtx, widgetState, config);
|
|
277
|
+
}
|
|
192
278
|
updatePiCrewPowerbar(pi.events, currentCtx.cwd, config);
|
|
193
279
|
}, 1000);
|
|
194
280
|
widgetState.interval.unref?.();
|
|
195
281
|
});
|
|
282
|
+
pi.on("session_before_switch", () => {
|
|
283
|
+
stopSessionBoundSubagents();
|
|
284
|
+
});
|
|
196
285
|
pi.on("session_shutdown", () => {
|
|
197
286
|
cleanupRuntime();
|
|
198
287
|
});
|
|
@@ -209,7 +298,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
209
298
|
const abort = (): void => controller.abort();
|
|
210
299
|
signal?.addEventListener("abort", abort, { once: true });
|
|
211
300
|
try {
|
|
212
|
-
const output = await handleTeamTool(params as TeamToolParamsValue, { ...ctx, signal: controller.signal, startForegroundRun: (runner) => startForegroundRun(ctx, runner), onRunStarted: (runId) => openLiveSidebar(ctx, runId) });
|
|
301
|
+
const output = await handleTeamTool(params as TeamToolParamsValue, { ...ctx, signal: controller.signal, startForegroundRun: (runner, runId) => startForegroundRun(ctx, runner, runId), onRunStarted: (runId) => openLiveSidebar(ctx, runId) });
|
|
213
302
|
const config = loadConfig(ctx.cwd).config.ui;
|
|
214
303
|
updateCrewWidget(ctx, widgetState, config);
|
|
215
304
|
updatePiCrewPowerbar(pi.events, ctx.cwd, config);
|
|
@@ -223,6 +312,108 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
223
312
|
|
|
224
313
|
pi.registerTool(tool);
|
|
225
314
|
|
|
315
|
+
const agentTool: ToolDefinition = {
|
|
316
|
+
name: "Agent",
|
|
317
|
+
label: "Agent",
|
|
318
|
+
description: "Launch a real pi-crew subagent. Uses pi-crew's durable child-process runtime by default; set run_in_background=true for parallel/background work, then use get_subagent_result.",
|
|
319
|
+
promptSnippet: "Use Agent to delegate focused work to a real pi-crew subagent. Use run_in_background=true for parallel work and get_subagent_result to join results.",
|
|
320
|
+
promptGuidelines: [
|
|
321
|
+
"Use Agent for independent exploration, review, verification, or implementation subtasks instead of doing all work in the parent turn.",
|
|
322
|
+
"For parallel work, launch multiple Agent calls with run_in_background=true, then call get_subagent_result for each result.",
|
|
323
|
+
"Available pi-crew subagent types include explorer, planner, analyst, executor, reviewer, verifier, writer, security-reviewer, and test-engineer.",
|
|
324
|
+
],
|
|
325
|
+
parameters: Type.Object({
|
|
326
|
+
prompt: Type.String({ description: "The task for the subagent to perform." }),
|
|
327
|
+
description: Type.String({ description: "Short 3-5 word task description." }),
|
|
328
|
+
subagent_type: Type.String({ description: "pi-crew agent name, e.g. explorer, planner, executor, reviewer, verifier, writer, security-reviewer, test-engineer." }),
|
|
329
|
+
model: Type.Optional(Type.String({ description: "Optional model override. If omitted, pi-crew uses Pi-configured model fallback." })),
|
|
330
|
+
max_turns: Type.Optional(Type.Number({ description: "Reserved for live-session subagents; child-process runtime may ignore this." })),
|
|
331
|
+
run_in_background: Type.Optional(Type.Boolean({ description: "Run in background and return an agent ID immediately." })),
|
|
332
|
+
}) as never,
|
|
333
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
334
|
+
const options = __test__subagentSpawnParams(params as Record<string, unknown>, ctx);
|
|
335
|
+
if (!options.prompt.trim()) return subagentToolResult("Agent requires prompt.", {}, true);
|
|
336
|
+
const runner = async (spawnOptions: SubagentSpawnOptions, childSignal?: AbortSignal) => handleTeamTool({
|
|
337
|
+
action: "run",
|
|
338
|
+
agent: spawnOptions.type,
|
|
339
|
+
goal: spawnOptions.prompt,
|
|
340
|
+
model: spawnOptions.model,
|
|
341
|
+
async: false,
|
|
342
|
+
config: spawnOptions.maxTurns ? { runtime: { maxTurns: spawnOptions.maxTurns } } : undefined,
|
|
343
|
+
}, spawnOptions.background ? { ...ctx, signal: childSignal, startForegroundRun: (run, runId) => startForegroundRun(ctx, run, runId), onRunStarted: (runId) => openLiveSidebar(ctx, runId) } : { ...ctx, signal: childSignal });
|
|
344
|
+
const record = subagentManager.spawn(options, runner, options.background ? undefined : signal);
|
|
345
|
+
if (options.background || record.status === "queued") {
|
|
346
|
+
return subagentToolResult([`Agent ${record.status === "queued" ? "queued" : "started"}.`, `Agent ID: ${record.id}`, `Type: ${record.type}`, `Description: ${record.description}`, "Use get_subagent_result to retrieve output. Do not duplicate this agent's work."].join("\n"), { agentId: record.id, status: record.status });
|
|
347
|
+
}
|
|
348
|
+
await record.promise;
|
|
349
|
+
const output = readSubagentRunResult(ctx, record) ?? record.result ?? "No output.";
|
|
350
|
+
return subagentToolResult([`Agent ${record.id} ${record.status}.`, "", output].join("\n"), { agentId: record.id, runId: record.runId, status: record.status }, record.status === "failed" || record.status === "error");
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const getSubagentResultTool: ToolDefinition = {
|
|
355
|
+
name: "get_subagent_result",
|
|
356
|
+
label: "Get Agent Result",
|
|
357
|
+
description: "Check status and retrieve results from a pi-crew background subagent.",
|
|
358
|
+
parameters: Type.Object({
|
|
359
|
+
agent_id: Type.String({ description: "Agent ID returned by Agent." }),
|
|
360
|
+
wait: Type.Optional(Type.Boolean({ description: "Wait for completion before returning." })),
|
|
361
|
+
verbose: Type.Optional(Type.Boolean({ description: "Include status metadata before output." })),
|
|
362
|
+
}) as never,
|
|
363
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
364
|
+
const p = params as { agent_id?: string; wait?: boolean; verbose?: boolean };
|
|
365
|
+
if (!p.agent_id) return subagentToolResult("get_subagent_result requires agent_id.", {}, true);
|
|
366
|
+
const record = subagentManager.getRecord(p.agent_id);
|
|
367
|
+
if (!record) return subagentToolResult(`Agent not found: ${p.agent_id}`, {}, true);
|
|
368
|
+
let current = record;
|
|
369
|
+
if (p.wait && (current.status === "running" || current.status === "queued")) {
|
|
370
|
+
current.resultConsumed = true;
|
|
371
|
+
current = await subagentManager.waitForRecord(current.id) ?? current;
|
|
372
|
+
}
|
|
373
|
+
const output = readSubagentRunResult(ctx, current);
|
|
374
|
+
if (current.status !== "running" && current.status !== "queued") current.resultConsumed = true;
|
|
375
|
+
const text = [p.verbose ? formatSubagentRecord(current) : undefined, output ? `${p.verbose ? "\n" : ""}${output}` : current.status === "running" || current.status === "queued" ? "Agent is still running. Use wait=true or check again later." : current.error ?? "No output."].filter((line): line is string => Boolean(line)).join("\n");
|
|
376
|
+
return subagentToolResult(text, { agentId: current.id, runId: current.runId, status: current.status }, current.status === "failed" || current.status === "error");
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const steerSubagentTool: ToolDefinition = {
|
|
381
|
+
name: "steer_subagent",
|
|
382
|
+
label: "Steer Agent",
|
|
383
|
+
description: "Send a steering note to a running pi-crew subagent. Live-session steering is planned; child-process runs expose durable status and can be cancelled if needed.",
|
|
384
|
+
parameters: Type.Object({ agent_id: Type.String(), message: Type.String() }) as never,
|
|
385
|
+
async execute(_id, params) {
|
|
386
|
+
const p = params as { agent_id?: string; message?: string };
|
|
387
|
+
const record = p.agent_id ? subagentManager.getRecord(p.agent_id) : undefined;
|
|
388
|
+
if (!record) return subagentToolResult(`Agent not found: ${p.agent_id ?? ""}`, {}, true);
|
|
389
|
+
return subagentToolResult([`Steering request noted for ${record.id}.`, "Current default pi-crew backend is child-process, so mid-turn session.steer is not available yet.", record.runId ? `Use team cancel runId=${record.runId} if the agent must be interrupted.` : undefined].filter((line): line is string => Boolean(line)).join("\n"), { agentId: record.id, runId: record.runId, status: record.status });
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const crewAgentTool: ToolDefinition = {
|
|
394
|
+
...agentTool,
|
|
395
|
+
name: "crew_agent",
|
|
396
|
+
label: "Crew Agent",
|
|
397
|
+
description: "Launch a real pi-crew subagent using a conflict-safe pi-crew-specific tool name.",
|
|
398
|
+
promptSnippet: "Use crew_agent when you need pi-crew subagents and another extension may own the generic Agent tool.",
|
|
399
|
+
};
|
|
400
|
+
const crewAgentResultTool: ToolDefinition = {
|
|
401
|
+
...getSubagentResultTool,
|
|
402
|
+
name: "crew_agent_result",
|
|
403
|
+
label: "Get Crew Agent Result",
|
|
404
|
+
description: "Check status and retrieve results from a pi-crew subagent using the conflict-safe tool name.",
|
|
405
|
+
};
|
|
406
|
+
const crewAgentSteerTool: ToolDefinition = {
|
|
407
|
+
...steerSubagentTool,
|
|
408
|
+
name: "crew_agent_steer",
|
|
409
|
+
label: "Steer Crew Agent",
|
|
410
|
+
description: "Send a steering note to a pi-crew subagent using the conflict-safe tool name.",
|
|
411
|
+
};
|
|
412
|
+
for (const extraTool of [crewAgentTool, crewAgentResultTool, crewAgentSteerTool]) pi.registerTool(extraTool);
|
|
413
|
+
for (const extraTool of [agentTool, getSubagentResultTool, steerSubagentTool]) {
|
|
414
|
+
try { pi.registerTool(extraTool); } catch {}
|
|
415
|
+
}
|
|
416
|
+
|
|
226
417
|
pi.registerCommand("teams", {
|
|
227
418
|
description: "List pi-crew teams, workflows, and agents",
|
|
228
419
|
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
@@ -234,7 +425,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
234
425
|
pi.registerCommand("team-run", {
|
|
235
426
|
description: "Manually start a pi-crew run (agent may also use the team tool autonomously)",
|
|
236
427
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
237
|
-
const result = await handleTeamTool(parseRunArgs(args), { ...ctx, startForegroundRun: (runner) => startForegroundRun(ctx as ExtensionContext, runner), onRunStarted: (runId) => openLiveSidebar(ctx as ExtensionContext, runId) });
|
|
428
|
+
const result = await handleTeamTool(parseRunArgs(args), { ...ctx, startForegroundRun: (runner, runId) => startForegroundRun(ctx as ExtensionContext, runner, runId), onRunStarted: (runId) => openLiveSidebar(ctx as ExtensionContext, runId) });
|
|
238
429
|
await notifyCommandResult(ctx, commandText(result));
|
|
239
430
|
},
|
|
240
431
|
});
|
|
@@ -59,7 +59,7 @@ type TeamContext = Pick<ExtensionContext, "cwd"> & Partial<Pick<ExtensionContext
|
|
|
59
59
|
sessionManager?: { getBranch?: () => unknown[] };
|
|
60
60
|
events?: { emit?: (event: string, data: unknown) => void };
|
|
61
61
|
signal?: AbortSignal;
|
|
62
|
-
startForegroundRun?: (runner: (signal?: AbortSignal) => Promise<void
|
|
62
|
+
startForegroundRun?: (runner: (signal?: AbortSignal) => Promise<void>, runId?: string) => void;
|
|
63
63
|
onRunStarted?: (runId: string) => void;
|
|
64
64
|
};
|
|
65
65
|
|
|
@@ -300,13 +300,29 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
300
300
|
const teams = allTeams(discoverTeams(ctx.cwd));
|
|
301
301
|
const workflows = allWorkflows(discoverWorkflows(ctx.cwd));
|
|
302
302
|
const agents = allAgents(discoverAgents(ctx.cwd));
|
|
303
|
+
const directAgent = params.agent ? agents.find((item) => item.name === params.agent) : undefined;
|
|
304
|
+
if (params.agent && !directAgent) return result(`Agent '${params.agent}' not found.`, { action: "run", status: "error" }, true);
|
|
303
305
|
const teamName = params.team ?? "default";
|
|
304
|
-
const team =
|
|
306
|
+
const team = directAgent ? {
|
|
307
|
+
name: `direct-${directAgent.name}`,
|
|
308
|
+
description: `Direct subagent run for ${directAgent.name}`,
|
|
309
|
+
source: "builtin" as const,
|
|
310
|
+
filePath: "<generated>",
|
|
311
|
+
roles: [{ name: params.role ?? "agent", agent: directAgent.name, description: directAgent.description }],
|
|
312
|
+
defaultWorkflow: "direct-agent",
|
|
313
|
+
workspaceMode: params.workspaceMode,
|
|
314
|
+
} : teams.find((item) => item.name === teamName);
|
|
305
315
|
if (!team) return result(`Team '${teamName}' not found.`, { action: "run", status: "error" }, true);
|
|
306
|
-
const workflowName = params.workflow ?? team.defaultWorkflow ?? "default";
|
|
307
|
-
const baseWorkflow =
|
|
316
|
+
const workflowName = directAgent ? "direct-agent" : params.workflow ?? team.defaultWorkflow ?? "default";
|
|
317
|
+
const baseWorkflow = directAgent ? {
|
|
318
|
+
name: "direct-agent",
|
|
319
|
+
description: `Direct task for ${directAgent.name}`,
|
|
320
|
+
source: "builtin" as const,
|
|
321
|
+
filePath: "<generated>",
|
|
322
|
+
steps: [{ id: "01_agent", role: params.role ?? "agent", task: "{goal}", model: params.model }],
|
|
323
|
+
} : workflows.find((item) => item.name === workflowName);
|
|
308
324
|
if (!baseWorkflow) return result(`Workflow '${workflowName}' not found.`, { action: "run", status: "error" }, true);
|
|
309
|
-
const workflow = expandParallelResearchWorkflow(baseWorkflow, ctx.cwd);
|
|
325
|
+
const workflow = directAgent ? baseWorkflow : expandParallelResearchWorkflow(baseWorkflow, ctx.cwd);
|
|
310
326
|
|
|
311
327
|
const validationErrors = validateWorkflowForTeam(workflow, team);
|
|
312
328
|
if (validationErrors.length > 0) {
|
|
@@ -352,13 +368,13 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
352
368
|
}
|
|
353
369
|
|
|
354
370
|
const runtime = await resolveCrewRuntime(effectiveRunConfig(loadedConfig.config, params.config));
|
|
355
|
-
const executeWorkers = runtime.kind
|
|
371
|
+
const executeWorkers = runtime.kind !== "scaffold";
|
|
356
372
|
const executedConfig = effectiveRunConfig(loadedConfig.config, params.config);
|
|
357
373
|
if (executeWorkers && ctx.startForegroundRun) {
|
|
358
374
|
ctx.onRunStarted?.(updatedManifest.runId);
|
|
359
375
|
ctx.startForegroundRun(async (signal) => {
|
|
360
376
|
await executeTeamRun({ manifest: updatedManifest, tasks, team, workflow, agents, executeWorkers, limits: executedConfig.limits, runtime, runtimeConfig: executedConfig.runtime, parentContext: buildParentContext(ctx), parentModel: ctx.model, modelRegistry: ctx.modelRegistry, modelOverride: params.model, signal });
|
|
361
|
-
});
|
|
377
|
+
}, updatedManifest.runId);
|
|
362
378
|
const text = [
|
|
363
379
|
`Started foreground pi-crew run ${updatedManifest.runId}.`,
|
|
364
380
|
`Team: ${team.name}`,
|
|
@@ -507,7 +523,7 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
507
523
|
appendEvent(loaded.manifest.eventsPath, { type: "run.resume_requested", runId: loaded.manifest.runId });
|
|
508
524
|
const loadedConfig = loadConfig(ctx.cwd);
|
|
509
525
|
const runtime = await resolveCrewRuntime(loadedConfig.config);
|
|
510
|
-
const executeWorkers = runtime.kind
|
|
526
|
+
const executeWorkers = runtime.kind !== "scaffold";
|
|
511
527
|
const executed = await executeTeamRun({ manifest: loaded.manifest, tasks: resetTasks, team, workflow, agents: allAgents(discoverAgents(ctx.cwd)), executeWorkers, limits: loadedConfig.config.limits, runtime, runtimeConfig: loadedConfig.config.runtime, parentContext: buildParentContext(ctx), parentModel: ctx.model, modelRegistry: ctx.modelRegistry, modelOverride: params.model, signal: ctx.signal });
|
|
512
528
|
return result([`Resumed run ${executed.manifest.runId}.`, `Status: ${executed.manifest.status}`, `Tasks: ${executed.tasks.length}`, `Artifacts: ${executed.manifest.artifactsRoot}`].join("\n"), { action: "resume", status: executed.manifest.status === "failed" ? "error" : "ok", runId: executed.manifest.runId, artifactsRoot: executed.manifest.artifactsRoot }, executed.manifest.status === "failed");
|
|
513
529
|
});
|
package/src/runtime/child-pi.ts
CHANGED
|
@@ -210,8 +210,11 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
210
210
|
observeStdoutChunk(input, stdout);
|
|
211
211
|
return { exitCode: 0, stdout, stderr: "" };
|
|
212
212
|
}
|
|
213
|
-
if (mock === "json-success") {
|
|
214
|
-
const
|
|
213
|
+
if (mock === "json-success" || mock === "adaptive-plan") {
|
|
214
|
+
const text = mock === "adaptive-plan" && input.task.includes("ADAPTIVE_PLAN_JSON_START")
|
|
215
|
+
? `Adaptive mock plan\nADAPTIVE_PLAN_JSON_START\n${JSON.stringify({ phases: [{ name: "research", tasks: [{ role: "explorer", task: "Explore adaptive target" }, { role: "analyst", task: "Analyze adaptive target" }, { role: "planner", task: "Plan adaptive target" }] }, { name: "build", tasks: [{ role: "executor", task: "Implement adaptive target" }] }, { name: "check", tasks: [{ role: "reviewer", task: "Review adaptive target" }, { role: "test-engineer", task: "Test adaptive target" }, { role: "writer", task: "Summarize adaptive target" }] }] })}\nADAPTIVE_PLAN_JSON_END`
|
|
216
|
+
: `Mock JSON success for ${input.agent.name}`;
|
|
217
|
+
const stdout = `${JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text }] } })}\n${JSON.stringify({ type: "message_end", usage: { input: 10, output: 5, cost: 0.001, turns: 1 } })}\n`;
|
|
215
218
|
observeStdoutChunk(input, stdout);
|
|
216
219
|
return { exitCode: 0, stdout, stderr: "" };
|
|
217
220
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface ResolveBatchConcurrencyInput {
|
|
2
|
+
workflowName: string;
|
|
3
|
+
teamMaxConcurrency?: number;
|
|
4
|
+
limitMaxConcurrentWorkers?: number;
|
|
5
|
+
readyCount: number;
|
|
6
|
+
workspaceMode?: "single" | "worktree";
|
|
7
|
+
readyRoles?: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface BatchConcurrencyDecision {
|
|
11
|
+
maxConcurrent: number;
|
|
12
|
+
selectedCount: number;
|
|
13
|
+
defaultConcurrency: number;
|
|
14
|
+
reason: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function defaultWorkflowConcurrency(workflowName: string): number {
|
|
18
|
+
if (workflowName === "parallel-research") return 4;
|
|
19
|
+
if (workflowName === "research") return 2;
|
|
20
|
+
if (workflowName === "implementation" || workflowName === "review" || workflowName === "default") return 2;
|
|
21
|
+
return 1;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function positiveInteger(value: number | undefined): number | undefined {
|
|
25
|
+
if (value === undefined || !Number.isFinite(value)) return undefined;
|
|
26
|
+
return Math.max(1, Math.trunc(value));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveBatchConcurrency(input: ResolveBatchConcurrencyInput): BatchConcurrencyDecision {
|
|
30
|
+
const defaultConcurrency = defaultWorkflowConcurrency(input.workflowName);
|
|
31
|
+
const limitMax = positiveInteger(input.limitMaxConcurrentWorkers);
|
|
32
|
+
const teamMax = positiveInteger(input.teamMaxConcurrency);
|
|
33
|
+
const requested = limitMax ?? teamMax ?? defaultConcurrency;
|
|
34
|
+
const source = limitMax !== undefined ? "limit" : teamMax !== undefined ? "team" : "workflow";
|
|
35
|
+
const readyCount = Math.max(0, Math.trunc(input.readyCount));
|
|
36
|
+
return {
|
|
37
|
+
maxConcurrent: requested,
|
|
38
|
+
selectedCount: readyCount === 0 ? 0 : Math.min(readyCount, requested),
|
|
39
|
+
defaultConcurrency,
|
|
40
|
+
reason: `${source}:${requested};ready:${readyCount}`,
|
|
41
|
+
};
|
|
42
|
+
}
|
package/src/runtime/pi-args.ts
CHANGED
|
@@ -15,6 +15,7 @@ export interface BuildPiWorkerArgsInput {
|
|
|
15
15
|
model?: string;
|
|
16
16
|
sessionEnabled?: boolean;
|
|
17
17
|
maxDepth?: number;
|
|
18
|
+
env?: NodeJS.ProcessEnv;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export interface BuildPiWorkerArgsResult {
|
|
@@ -82,8 +83,9 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
|
|
|
82
83
|
args.push(`Task: ${input.task}`);
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
const
|
|
86
|
-
const
|
|
86
|
+
const env = input.env ?? process.env;
|
|
87
|
+
const parentDepth = currentCrewDepth(env);
|
|
88
|
+
const maxDepth = resolveCrewMaxDepth(input.maxDepth, env);
|
|
87
89
|
return {
|
|
88
90
|
args,
|
|
89
91
|
env: {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export interface ProgressEventSummary {
|
|
2
|
+
eventType: string;
|
|
3
|
+
currentTool?: string;
|
|
4
|
+
toolCount?: number;
|
|
5
|
+
tokens?: number;
|
|
6
|
+
turns?: number;
|
|
7
|
+
activityState?: string;
|
|
8
|
+
lastActivityAt?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ProgressEventCoalesceDecision {
|
|
12
|
+
shouldAppend: boolean;
|
|
13
|
+
reason: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ProgressEventCoalesceInput {
|
|
17
|
+
previous?: ProgressEventSummary;
|
|
18
|
+
next: ProgressEventSummary;
|
|
19
|
+
nowMs: number;
|
|
20
|
+
lastAppendMs?: number;
|
|
21
|
+
minIntervalMs: number;
|
|
22
|
+
force?: boolean;
|
|
23
|
+
tokenThreshold?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_TOKEN_THRESHOLD = 256;
|
|
27
|
+
|
|
28
|
+
function numericIncrease(previous: number | undefined, next: number | undefined): number {
|
|
29
|
+
return next !== undefined && previous !== undefined ? next - previous : next !== undefined ? next : 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function shouldAppendProgressEventUpdate(input: ProgressEventCoalesceInput): ProgressEventCoalesceDecision {
|
|
33
|
+
if (input.force) return { shouldAppend: true, reason: "force" };
|
|
34
|
+
if (!input.previous) return { shouldAppend: true, reason: "first" };
|
|
35
|
+
if (input.previous.activityState !== input.next.activityState) return { shouldAppend: true, reason: "activity_changed" };
|
|
36
|
+
if (input.previous.currentTool !== input.next.currentTool) return { shouldAppend: true, reason: "tool_changed" };
|
|
37
|
+
if (numericIncrease(input.previous.toolCount, input.next.toolCount) > 0) return { shouldAppend: true, reason: "tool_count_increased" };
|
|
38
|
+
if (numericIncrease(input.previous.turns, input.next.turns) > 0) return { shouldAppend: true, reason: "turns_increased" };
|
|
39
|
+
const tokenIncrease = numericIncrease(input.previous.tokens, input.next.tokens);
|
|
40
|
+
if (tokenIncrease >= (input.tokenThreshold ?? DEFAULT_TOKEN_THRESHOLD)) return { shouldAppend: true, reason: "tokens_increased" };
|
|
41
|
+
if (input.lastAppendMs === undefined || input.nowMs - input.lastAppendMs >= input.minIntervalMs) return { shouldAppend: true, reason: "interval" };
|
|
42
|
+
return { shouldAppend: false, reason: "coalesced" };
|
|
43
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { loadRunManifestById } from "../state/state-store.ts";
|
|
2
|
+
import type { PiTeamsToolResult } from "../extension/tool-result.ts";
|
|
3
|
+
|
|
4
|
+
export type SubagentStatus = "queued" | "running" | "completed" | "failed" | "cancelled" | "error" | "stopped";
|
|
5
|
+
|
|
6
|
+
export interface SubagentSpawnOptions {
|
|
7
|
+
cwd: string;
|
|
8
|
+
type: string;
|
|
9
|
+
description: string;
|
|
10
|
+
prompt: string;
|
|
11
|
+
background: boolean;
|
|
12
|
+
model?: string;
|
|
13
|
+
maxTurns?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SubagentRecord {
|
|
17
|
+
id: string;
|
|
18
|
+
runId?: string;
|
|
19
|
+
type: string;
|
|
20
|
+
description: string;
|
|
21
|
+
prompt: string;
|
|
22
|
+
status: SubagentStatus;
|
|
23
|
+
startedAt: number;
|
|
24
|
+
completedAt?: number;
|
|
25
|
+
result?: string;
|
|
26
|
+
error?: string;
|
|
27
|
+
resultConsumed?: boolean;
|
|
28
|
+
model?: string;
|
|
29
|
+
background: boolean;
|
|
30
|
+
promise?: Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type SpawnRunner = (options: SubagentSpawnOptions, signal?: AbortSignal) => Promise<PiTeamsToolResult>;
|
|
34
|
+
type Notify = (record: SubagentRecord) => void;
|
|
35
|
+
|
|
36
|
+
interface QueuedSpawn {
|
|
37
|
+
record: SubagentRecord;
|
|
38
|
+
options: SubagentSpawnOptions;
|
|
39
|
+
runner: SpawnRunner;
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TERMINAL_RUN_STATUS = new Set(["completed", "failed", "cancelled", "blocked"]);
|
|
44
|
+
|
|
45
|
+
function resultText(result: PiTeamsToolResult): string {
|
|
46
|
+
return result.content?.map((item) => item.type === "text" ? item.text : "").filter(Boolean).join("\n") ?? "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function detailsRunId(result: PiTeamsToolResult): string | undefined {
|
|
50
|
+
const details = result.details as { runId?: unknown } | undefined;
|
|
51
|
+
return typeof details?.runId === "string" ? details.runId : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class SubagentManager {
|
|
55
|
+
private readonly records = new Map<string, SubagentRecord>();
|
|
56
|
+
private queue: QueuedSpawn[] = [];
|
|
57
|
+
private runningBackground = 0;
|
|
58
|
+
private counter = 0;
|
|
59
|
+
private maxConcurrent: number;
|
|
60
|
+
private readonly onComplete?: Notify;
|
|
61
|
+
private readonly pollIntervalMs: number;
|
|
62
|
+
|
|
63
|
+
constructor(maxConcurrent = 4, onComplete?: Notify, pollIntervalMs = 1000) {
|
|
64
|
+
this.maxConcurrent = maxConcurrent;
|
|
65
|
+
this.onComplete = onComplete;
|
|
66
|
+
this.pollIntervalMs = pollIntervalMs;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
spawn(options: SubagentSpawnOptions, runner: SpawnRunner, signal?: AbortSignal): SubagentRecord {
|
|
70
|
+
const record: SubagentRecord = {
|
|
71
|
+
id: `agent_${Date.now().toString(36)}_${(++this.counter).toString(36)}`,
|
|
72
|
+
type: options.type,
|
|
73
|
+
description: options.description,
|
|
74
|
+
prompt: options.prompt,
|
|
75
|
+
status: options.background && this.runningBackground >= this.maxConcurrent ? "queued" : "running",
|
|
76
|
+
startedAt: Date.now(),
|
|
77
|
+
model: options.model,
|
|
78
|
+
background: options.background,
|
|
79
|
+
};
|
|
80
|
+
this.records.set(record.id, record);
|
|
81
|
+
if (record.status === "queued") {
|
|
82
|
+
this.queue.push({ record, options, runner, signal });
|
|
83
|
+
return record;
|
|
84
|
+
}
|
|
85
|
+
this.start(record, options, runner, signal);
|
|
86
|
+
return record;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
getRecord(id: string): SubagentRecord | undefined {
|
|
90
|
+
return this.records.get(id);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
listAgents(): SubagentRecord[] {
|
|
94
|
+
return [...this.records.values()].sort((a, b) => b.startedAt - a.startedAt);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
abort(id: string): boolean {
|
|
98
|
+
const record = this.records.get(id);
|
|
99
|
+
if (!record) return false;
|
|
100
|
+
if (record.status === "queued") {
|
|
101
|
+
this.queue = this.queue.filter((entry) => entry.record.id !== id);
|
|
102
|
+
record.status = "stopped";
|
|
103
|
+
record.completedAt = Date.now();
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
if (record.status !== "running") return false;
|
|
107
|
+
record.status = "stopped";
|
|
108
|
+
record.completedAt = Date.now();
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
abortAll(): number {
|
|
113
|
+
let count = 0;
|
|
114
|
+
for (const entry of this.queue) {
|
|
115
|
+
entry.record.status = "stopped";
|
|
116
|
+
entry.record.completedAt = Date.now();
|
|
117
|
+
count++;
|
|
118
|
+
}
|
|
119
|
+
this.queue = [];
|
|
120
|
+
for (const record of this.records.values()) {
|
|
121
|
+
if (record.status === "running") {
|
|
122
|
+
record.status = "stopped";
|
|
123
|
+
record.completedAt = Date.now();
|
|
124
|
+
count++;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return count;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async waitForAll(): Promise<void> {
|
|
131
|
+
while (true) {
|
|
132
|
+
this.drainQueue();
|
|
133
|
+
const pending = this.listAgents().filter((record) => record.status === "running" || record.status === "queued").map((record) => record.promise).filter((promise): promise is Promise<void> => Boolean(promise));
|
|
134
|
+
if (!pending.length) break;
|
|
135
|
+
await Promise.allSettled(pending);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async waitForRecord(id: string): Promise<SubagentRecord | undefined> {
|
|
140
|
+
while (true) {
|
|
141
|
+
const record = this.records.get(id);
|
|
142
|
+
if (!record) return undefined;
|
|
143
|
+
if (record.status !== "running" && record.status !== "queued") return record;
|
|
144
|
+
if (record.promise) await record.promise;
|
|
145
|
+
else await new Promise((resolve) => setTimeout(resolve, 100));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
setMaxConcurrent(value: number): void {
|
|
150
|
+
this.maxConcurrent = Math.max(1, Math.floor(value));
|
|
151
|
+
this.drainQueue();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private start(record: SubagentRecord, options: SubagentSpawnOptions, runner: SpawnRunner, signal?: AbortSignal): void {
|
|
155
|
+
if (options.background) this.runningBackground++;
|
|
156
|
+
record.status = "running";
|
|
157
|
+
record.startedAt = Date.now();
|
|
158
|
+
record.promise = (async () => {
|
|
159
|
+
try {
|
|
160
|
+
const result = await runner(options, signal);
|
|
161
|
+
record.runId = detailsRunId(result);
|
|
162
|
+
record.result = resultText(result);
|
|
163
|
+
if (result.isError) {
|
|
164
|
+
record.status = "error";
|
|
165
|
+
record.error = record.result;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (record.runId) await this.pollRunToTerminal(options.cwd, record);
|
|
169
|
+
else record.status = "completed";
|
|
170
|
+
} catch (error) {
|
|
171
|
+
record.status = "error";
|
|
172
|
+
record.error = error instanceof Error ? error.message : String(error);
|
|
173
|
+
} finally {
|
|
174
|
+
if (options.background) this.runningBackground = Math.max(0, this.runningBackground - 1);
|
|
175
|
+
record.completedAt = record.completedAt ?? Date.now();
|
|
176
|
+
this.onComplete?.(record);
|
|
177
|
+
this.drainQueue();
|
|
178
|
+
}
|
|
179
|
+
})();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private drainQueue(): void {
|
|
183
|
+
while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) {
|
|
184
|
+
const next = this.queue.shift();
|
|
185
|
+
if (!next || next.record.status !== "queued") continue;
|
|
186
|
+
this.start(next.record, next.options, next.runner, next.signal);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private async pollRunToTerminal(cwd: string, record: SubagentRecord): Promise<void> {
|
|
191
|
+
while (record.runId && record.status === "running") {
|
|
192
|
+
const loaded = loadRunManifestById(cwd, record.runId);
|
|
193
|
+
if (loaded && TERMINAL_RUN_STATUS.has(loaded.manifest.status)) {
|
|
194
|
+
record.status = loaded.manifest.status === "completed" ? "completed" : loaded.manifest.status === "cancelled" ? "cancelled" : "failed";
|
|
195
|
+
record.error = record.status === "completed" ? undefined : loaded.manifest.summary;
|
|
196
|
+
record.completedAt = Date.now();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
await new Promise((resolve) => setTimeout(resolve, this.pollIntervalMs));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -22,6 +22,7 @@ import { parseSessionUsage } from "./session-usage.ts";
|
|
|
22
22
|
import type { CrewAgentProgress, CrewRuntimeKind } from "./crew-agent-runtime.ts";
|
|
23
23
|
import { buildMemoryBlock } from "./agent-memory.ts";
|
|
24
24
|
import { runLiveSessionTask } from "./live-session-runtime.ts";
|
|
25
|
+
import { shouldAppendProgressEventUpdate, type ProgressEventSummary } from "./progress-event-coalescer.ts";
|
|
25
26
|
|
|
26
27
|
export interface TaskRunnerInput {
|
|
27
28
|
manifest: TeamRunManifest;
|
|
@@ -201,7 +202,7 @@ function cleanResultText(text: string | undefined): string | undefined {
|
|
|
201
202
|
return trimmed;
|
|
202
203
|
}
|
|
203
204
|
|
|
204
|
-
function progressEventSummary(task: TeamTaskState, event: unknown):
|
|
205
|
+
function progressEventSummary(task: TeamTaskState, event: unknown): ProgressEventSummary {
|
|
205
206
|
const type = asRecord(event)?.type;
|
|
206
207
|
return {
|
|
207
208
|
eventType: typeof type === "string" ? type : "event",
|
|
@@ -306,14 +307,18 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
306
307
|
const transcriptPath = `${manifest.artifactsRoot}/transcripts/${task.id}.jsonl`;
|
|
307
308
|
let lastAgentRecordPersistedAt = 0;
|
|
308
309
|
let lastRunProgressPersistedAt = 0;
|
|
310
|
+
let lastRunProgressSummary: ProgressEventSummary | undefined;
|
|
309
311
|
const persistChildProgress = (event: unknown, force = false): void => {
|
|
310
312
|
const now = Date.now();
|
|
311
313
|
if (force || shouldFlushProgressEvent(event) || now - lastAgentRecordPersistedAt >= 500) {
|
|
312
314
|
upsertCrewAgent(manifest, recordFromTask(manifest, task, "child-process"));
|
|
313
315
|
lastAgentRecordPersistedAt = now;
|
|
314
316
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
+
const summary = progressEventSummary(task, event);
|
|
318
|
+
const decision = shouldAppendProgressEventUpdate({ previous: lastRunProgressSummary, next: summary, nowMs: now, lastAppendMs: lastRunProgressPersistedAt || undefined, minIntervalMs: 1000, force });
|
|
319
|
+
if (decision.shouldAppend) {
|
|
320
|
+
appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, taskId: task.id, data: { ...summary, coalesceReason: decision.reason } });
|
|
321
|
+
lastRunProgressSummary = summary;
|
|
317
322
|
lastRunProgressPersistedAt = now;
|
|
318
323
|
}
|
|
319
324
|
};
|
|
@@ -389,14 +394,18 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
389
394
|
const transcriptPath = `${manifest.artifactsRoot}/transcripts/${task.id}.jsonl`;
|
|
390
395
|
let lastAgentRecordPersistedAt = 0;
|
|
391
396
|
let lastRunProgressPersistedAt = 0;
|
|
397
|
+
let lastRunProgressSummary: ProgressEventSummary | undefined;
|
|
392
398
|
const persistLiveProgress = (event: unknown, force = false): void => {
|
|
393
399
|
const now = Date.now();
|
|
394
400
|
if (force || shouldFlushProgressEvent(event) || now - lastAgentRecordPersistedAt >= 500) {
|
|
395
401
|
upsertCrewAgent(manifest, recordFromTask(manifest, task, "live-session"));
|
|
396
402
|
lastAgentRecordPersistedAt = now;
|
|
397
403
|
}
|
|
398
|
-
|
|
399
|
-
|
|
404
|
+
const summary = progressEventSummary(task, event);
|
|
405
|
+
const decision = shouldAppendProgressEventUpdate({ previous: lastRunProgressSummary, next: summary, nowMs: now, lastAppendMs: lastRunProgressPersistedAt || undefined, minIntervalMs: 1000, force });
|
|
406
|
+
if (decision.shouldAppend) {
|
|
407
|
+
appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, taskId: task.id, data: { ...summary, coalesceReason: decision.reason } });
|
|
408
|
+
lastRunProgressSummary = summary;
|
|
400
409
|
lastRunProgressPersistedAt = now;
|
|
401
410
|
}
|
|
402
411
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
1
2
|
import type { AgentConfig } from "../agents/agent-config.ts";
|
|
2
3
|
import type { CrewLimitsConfig, CrewRuntimeConfig } from "../config/config.ts";
|
|
3
4
|
import type { CrewRuntimeCapabilities } from "./runtime-resolver.ts";
|
|
@@ -17,6 +18,7 @@ import { saveCrewAgents } from "./crew-agent-records.ts";
|
|
|
17
18
|
import { recordsForMaterializedTasks } from "./task-display.ts";
|
|
18
19
|
import { deliverGroupJoin, resolveGroupJoinMode } from "./group-join.ts";
|
|
19
20
|
import { runTeamTask } from "./task-runner.ts";
|
|
21
|
+
import { resolveBatchConcurrency } from "./concurrency.ts";
|
|
20
22
|
|
|
21
23
|
export interface ExecuteTeamRunInput {
|
|
22
24
|
manifest: TeamRunManifest;
|
|
@@ -75,6 +77,129 @@ function mergeTaskUpdates(base: TeamTaskState[], results: Array<{ tasks: TeamTas
|
|
|
75
77
|
return refreshTaskGraphQueues(merged);
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
interface AdaptivePlanTask {
|
|
81
|
+
role: string;
|
|
82
|
+
title?: string;
|
|
83
|
+
task: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface AdaptivePlanPhase {
|
|
87
|
+
name: string;
|
|
88
|
+
tasks: AdaptivePlanTask[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface AdaptivePlan {
|
|
92
|
+
phases: AdaptivePlanPhase[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const MAX_ADAPTIVE_TASKS = 12;
|
|
96
|
+
|
|
97
|
+
function slug(value: string): string {
|
|
98
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32) || "task";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function __test__parseAdaptivePlan(text: string, allowedRoles: string[]): AdaptivePlan | undefined {
|
|
102
|
+
const markerMatch = text.match(/ADAPTIVE_PLAN_JSON_START\s*([\s\S]*?)\s*ADAPTIVE_PLAN_JSON_END/);
|
|
103
|
+
const fencedMatch = markerMatch ? undefined : text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
104
|
+
const raw = markerMatch?.[1] ?? fencedMatch?.[1];
|
|
105
|
+
if (!raw) return undefined;
|
|
106
|
+
let parsed: unknown;
|
|
107
|
+
try { parsed = JSON.parse(raw); } catch { return undefined; }
|
|
108
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
109
|
+
const phasesRaw = Array.isArray((parsed as { phases?: unknown }).phases) ? (parsed as { phases: unknown[] }).phases : Array.isArray((parsed as { tasks?: unknown }).tasks) ? [{ name: "adaptive", tasks: (parsed as { tasks: unknown[] }).tasks }] : undefined;
|
|
110
|
+
if (!phasesRaw) return undefined;
|
|
111
|
+
const allowed = new Set(allowedRoles);
|
|
112
|
+
const phases: AdaptivePlanPhase[] = [];
|
|
113
|
+
let total = 0;
|
|
114
|
+
for (const [phaseIndex, phaseRaw] of phasesRaw.entries()) {
|
|
115
|
+
if (!phaseRaw || typeof phaseRaw !== "object" || Array.isArray(phaseRaw)) return undefined;
|
|
116
|
+
const phaseObj = phaseRaw as { name?: unknown; tasks?: unknown };
|
|
117
|
+
if (!Array.isArray(phaseObj.tasks) || phaseObj.tasks.length === 0) return undefined;
|
|
118
|
+
const tasks: AdaptivePlanTask[] = [];
|
|
119
|
+
for (const taskRaw of phaseObj.tasks) {
|
|
120
|
+
if (!taskRaw || typeof taskRaw !== "object" || Array.isArray(taskRaw)) return undefined;
|
|
121
|
+
const taskObj = taskRaw as { role?: unknown; title?: unknown; task?: unknown };
|
|
122
|
+
if (typeof taskObj.role !== "string" || !allowed.has(taskObj.role)) return undefined;
|
|
123
|
+
if (typeof taskObj.task !== "string" || !taskObj.task.trim()) return undefined;
|
|
124
|
+
if (total >= MAX_ADAPTIVE_TASKS) return undefined;
|
|
125
|
+
tasks.push({ role: taskObj.role, title: typeof taskObj.title === "string" ? taskObj.title : undefined, task: taskObj.task.trim() });
|
|
126
|
+
total++;
|
|
127
|
+
}
|
|
128
|
+
phases.push({ name: typeof phaseObj.name === "string" && phaseObj.name.trim() ? phaseObj.name.trim() : `phase-${phaseIndex + 1}`, tasks });
|
|
129
|
+
}
|
|
130
|
+
return phases.length ? { phases } : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function reconstructAdaptiveWorkflow(workflow: WorkflowConfig, tasks: TeamTaskState[]): WorkflowConfig {
|
|
134
|
+
const existing = new Set(workflow.steps.map((step) => step.id));
|
|
135
|
+
const steps: WorkflowStep[] = [];
|
|
136
|
+
for (const task of tasks) {
|
|
137
|
+
if (!task.stepId?.startsWith("adaptive-") || !task.adaptive?.task || existing.has(task.stepId)) continue;
|
|
138
|
+
steps.push({ id: task.stepId, role: task.role, dependsOn: task.graph?.dependencies ?? task.dependsOn, parallelGroup: `adaptive-${slug(task.adaptive.phase)}`, task: task.adaptive.task });
|
|
139
|
+
}
|
|
140
|
+
return steps.length ? { ...workflow, steps: [...workflow.steps, ...steps] } : workflow;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function injectAdaptivePlanIfReady(input: { manifest: TeamRunManifest; tasks: TeamTaskState[]; workflow: WorkflowConfig; team: TeamConfig }): { tasks: TeamTaskState[]; workflow: WorkflowConfig; injected: boolean; missingPlan: boolean } {
|
|
144
|
+
if (input.workflow.name !== "implementation") return { tasks: input.tasks, workflow: input.workflow, injected: false, missingPlan: false };
|
|
145
|
+
if (input.tasks.some((task) => task.stepId?.startsWith("adaptive-"))) return { tasks: input.tasks, workflow: reconstructAdaptiveWorkflow(input.workflow, input.tasks), injected: false, missingPlan: false };
|
|
146
|
+
const completedAssess = input.tasks.find((task) => task.stepId === "assess" && task.status === "completed");
|
|
147
|
+
if (!completedAssess) return { tasks: input.tasks, workflow: input.workflow, injected: false, missingPlan: false };
|
|
148
|
+
if (!completedAssess.resultArtifact?.path) {
|
|
149
|
+
appendEvent(input.manifest.eventsPath, { type: "adaptive.plan_missing", runId: input.manifest.runId, taskId: completedAssess.id, message: "Adaptive planner result artifact is missing." });
|
|
150
|
+
return { tasks: input.tasks, workflow: input.workflow, injected: false, missingPlan: true };
|
|
151
|
+
}
|
|
152
|
+
const assessTask = completedAssess;
|
|
153
|
+
const resultPath = completedAssess.resultArtifact.path;
|
|
154
|
+
let text = "";
|
|
155
|
+
try { text = fs.readFileSync(resultPath, "utf-8"); } catch {
|
|
156
|
+
appendEvent(input.manifest.eventsPath, { type: "adaptive.plan_missing", runId: input.manifest.runId, taskId: assessTask.id, message: "Adaptive planner result artifact could not be read." });
|
|
157
|
+
return { tasks: input.tasks, workflow: input.workflow, injected: false, missingPlan: true };
|
|
158
|
+
}
|
|
159
|
+
const plan = __test__parseAdaptivePlan(text, input.team.roles.map((role) => role.name));
|
|
160
|
+
if (!plan) {
|
|
161
|
+
appendEvent(input.manifest.eventsPath, { type: "adaptive.plan_missing", runId: input.manifest.runId, taskId: assessTask.id, message: "Adaptive planner did not produce a valid plan; no dynamic subagents were spawned." });
|
|
162
|
+
return { tasks: input.tasks, workflow: input.workflow, injected: false, missingPlan: true };
|
|
163
|
+
}
|
|
164
|
+
const steps: WorkflowStep[] = [];
|
|
165
|
+
const tasks: TeamTaskState[] = [];
|
|
166
|
+
let previousStepIds = ["assess"];
|
|
167
|
+
let counter = 0;
|
|
168
|
+
for (const [phaseIndex, phase] of plan.phases.entries()) {
|
|
169
|
+
const currentStepIds: string[] = [];
|
|
170
|
+
for (const [taskIndex, planned] of phase.tasks.entries()) {
|
|
171
|
+
counter++;
|
|
172
|
+
const stepId = `adaptive-${phaseIndex + 1}-${taskIndex + 1}-${slug(planned.role)}`;
|
|
173
|
+
const taskId = `adaptive-${String(counter).padStart(2, "0")}-${slug(planned.role)}`;
|
|
174
|
+
steps.push({ id: stepId, role: planned.role, dependsOn: previousStepIds, parallelGroup: `adaptive-${slug(phase.name)}`, task: planned.task });
|
|
175
|
+
tasks.push({
|
|
176
|
+
id: taskId,
|
|
177
|
+
runId: input.manifest.runId,
|
|
178
|
+
stepId,
|
|
179
|
+
role: planned.role,
|
|
180
|
+
agent: input.team.roles.find((role) => role.name === planned.role)?.agent ?? planned.role,
|
|
181
|
+
title: planned.title ?? stepId,
|
|
182
|
+
status: "queued",
|
|
183
|
+
dependsOn: previousStepIds,
|
|
184
|
+
cwd: input.manifest.cwd,
|
|
185
|
+
adaptive: { phase: phase.name, task: planned.task },
|
|
186
|
+
graph: { taskId, dependencies: previousStepIds, children: [], queue: "blocked" },
|
|
187
|
+
});
|
|
188
|
+
currentStepIds.push(stepId);
|
|
189
|
+
}
|
|
190
|
+
previousStepIds = currentStepIds;
|
|
191
|
+
}
|
|
192
|
+
const dependencyTaskIdByStep = new Map<string, string>([["assess", assessTask.id], ...tasks.map((task) => [task.stepId ?? task.id, task.id] as const)]);
|
|
193
|
+
const withGraph = tasks.map((task) => ({
|
|
194
|
+
...task,
|
|
195
|
+
dependsOn: task.dependsOn.map((dep) => dependencyTaskIdByStep.get(dep) ?? dep),
|
|
196
|
+
graph: task.graph ? { ...task.graph, dependencies: task.dependsOn.map((dep) => dependencyTaskIdByStep.get(dep) ?? dep), queue: "blocked" as const } : task.graph,
|
|
197
|
+
}));
|
|
198
|
+
const allTasks = refreshTaskGraphQueues([...input.tasks, ...withGraph]);
|
|
199
|
+
appendEvent(input.manifest.eventsPath, { type: "adaptive.plan_injected", runId: input.manifest.runId, taskId: assessTask.id, message: `Injected ${withGraph.length} adaptive subagent task(s) across ${plan.phases.length} phase(s).`, data: { phases: plan.phases.map((phase) => ({ name: phase.name, count: phase.tasks.length, roles: phase.tasks.map((task) => task.role) })) } });
|
|
200
|
+
return { tasks: allTasks, workflow: { ...input.workflow, steps: [...input.workflow.steps, ...steps] }, injected: true, missingPlan: false };
|
|
201
|
+
}
|
|
202
|
+
|
|
78
203
|
function formatTaskProgress(task: TeamTaskState): string {
|
|
79
204
|
return `- ${task.id}: ${task.status} (${task.role} -> ${task.agent})${task.taskPacket ? ` scope=${task.taskPacket.scope}` : ""}${task.verification ? ` green=${task.verification.observedGreenLevel}/${task.verification.requiredGreenLevel}` : ""}${task.error ? ` - ${task.error}` : ""}`;
|
|
80
205
|
}
|
|
@@ -143,8 +268,22 @@ function applyPolicy(manifest: TeamRunManifest, tasks: TeamTaskState[], limits?:
|
|
|
143
268
|
}
|
|
144
269
|
|
|
145
270
|
export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> {
|
|
271
|
+
let workflow = input.workflow;
|
|
146
272
|
let manifest = updateRunStatus(input.manifest, "running", input.executeWorkers ? "Executing team workflow." : "Creating workflow prompts and placeholder results.");
|
|
147
273
|
let tasks = refreshTaskGraphQueues(input.tasks);
|
|
274
|
+
const initialAdaptive = injectAdaptivePlanIfReady({ manifest, tasks, workflow, team: input.team });
|
|
275
|
+
if (initialAdaptive.missingPlan) {
|
|
276
|
+
tasks = markBlocked(tasks, "Adaptive planner did not produce a valid subagent plan.");
|
|
277
|
+
saveRunTasks(manifest, tasks);
|
|
278
|
+
manifest = updateRunStatus(manifest, "blocked", "Adaptive planner did not produce a valid subagent plan.");
|
|
279
|
+
return { manifest, tasks };
|
|
280
|
+
}
|
|
281
|
+
if (initialAdaptive.injected) {
|
|
282
|
+
tasks = initialAdaptive.tasks;
|
|
283
|
+
workflow = initialAdaptive.workflow;
|
|
284
|
+
} else {
|
|
285
|
+
workflow = initialAdaptive.workflow;
|
|
286
|
+
}
|
|
148
287
|
manifest = writeProgress(manifest, tasks, "team-runner");
|
|
149
288
|
saveRunManifest(manifest);
|
|
150
289
|
const runtimeKind = input.runtime?.kind ?? (input.executeWorkers ? "child-process" : "scaffold");
|
|
@@ -167,9 +306,26 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
167
306
|
return { manifest, tasks };
|
|
168
307
|
}
|
|
169
308
|
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
309
|
+
const injectedPlan = injectAdaptivePlanIfReady({ manifest, tasks, workflow, team: input.team });
|
|
310
|
+
if (injectedPlan.missingPlan) {
|
|
311
|
+
tasks = markBlocked(tasks, "Adaptive planner did not produce a valid subagent plan.");
|
|
312
|
+
saveRunTasks(manifest, tasks);
|
|
313
|
+
saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
|
|
314
|
+
manifest = updateRunStatus(manifest, "blocked", "Adaptive planner did not produce a valid subagent plan.");
|
|
315
|
+
return { manifest, tasks };
|
|
316
|
+
}
|
|
317
|
+
if (injectedPlan.injected) {
|
|
318
|
+
tasks = injectedPlan.tasks;
|
|
319
|
+
workflow = injectedPlan.workflow;
|
|
320
|
+
saveRunTasks(manifest, tasks);
|
|
321
|
+
saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
|
|
322
|
+
} else {
|
|
323
|
+
workflow = injectedPlan.workflow;
|
|
324
|
+
}
|
|
325
|
+
const snapshot = taskGraphSnapshot(tasks);
|
|
326
|
+
const readyRoles = snapshot.ready.map((taskId) => tasks.find((task) => task.id === taskId)?.role).filter((role): role is string => Boolean(role));
|
|
327
|
+
const concurrency = resolveBatchConcurrency({ workflowName: workflow.name, teamMaxConcurrency: input.team.maxConcurrency, limitMaxConcurrentWorkers: input.limits?.maxConcurrentWorkers, readyCount: snapshot.ready.length, workspaceMode: manifest.workspaceMode, readyRoles });
|
|
328
|
+
const readyBatch = getReadyTasks(tasks, concurrency.selectedCount);
|
|
173
329
|
if (readyBatch.length === 0) {
|
|
174
330
|
tasks = markBlocked(tasks, "No ready queued task; dependency graph may be invalid.");
|
|
175
331
|
saveRunTasks(manifest, tasks);
|
|
@@ -178,14 +334,28 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
178
334
|
return { manifest, tasks };
|
|
179
335
|
}
|
|
180
336
|
|
|
181
|
-
appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, message: `Starting ready batch with ${readyBatch.length} task(s).`, data: { taskIds: readyBatch.map((task) => task.id), maxConcurrent } });
|
|
337
|
+
appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, message: `Starting ready batch with ${readyBatch.length} task(s).`, data: { taskIds: readyBatch.map((task) => task.id), readyCount: snapshot.ready.length, blockedCount: snapshot.blocked.length, runningCount: snapshot.running.length, doneCount: snapshot.done.length, selectedCount: readyBatch.length, maxConcurrent: concurrency.maxConcurrent, defaultConcurrency: concurrency.defaultConcurrency, concurrencyReason: concurrency.reason } });
|
|
182
338
|
const results = await Promise.all(readyBatch.map((task) => {
|
|
183
|
-
const step = findStep(
|
|
339
|
+
const step = findStep(workflow, task);
|
|
184
340
|
const agent = findAgent(input.agents, task);
|
|
185
341
|
return runTeamTask({ manifest, tasks, task, step, agent, signal: input.signal, executeWorkers: input.executeWorkers, runtimeKind: input.runtime?.kind, runtimeConfig: input.runtimeConfig, parentContext: input.parentContext, parentModel: input.parentModel, modelRegistry: input.modelRegistry, modelOverride: input.modelOverride, limits: input.limits });
|
|
186
342
|
}));
|
|
187
343
|
manifest = { ...results.at(-1)!.manifest, artifacts: mergeArtifacts([manifest.artifacts, ...results.map((item) => item.manifest.artifacts)].flat()) };
|
|
188
344
|
tasks = mergeTaskUpdates(tasks, results);
|
|
345
|
+
const injectedAfterBatch = injectAdaptivePlanIfReady({ manifest, tasks, workflow, team: input.team });
|
|
346
|
+
if (injectedAfterBatch.missingPlan) {
|
|
347
|
+
tasks = markBlocked(tasks, "Adaptive planner did not produce a valid subagent plan.");
|
|
348
|
+
saveRunTasks(manifest, tasks);
|
|
349
|
+
saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
|
|
350
|
+
manifest = updateRunStatus(manifest, "blocked", "Adaptive planner did not produce a valid subagent plan.");
|
|
351
|
+
return { manifest, tasks };
|
|
352
|
+
}
|
|
353
|
+
if (injectedAfterBatch.injected) {
|
|
354
|
+
tasks = injectedAfterBatch.tasks;
|
|
355
|
+
workflow = injectedAfterBatch.workflow;
|
|
356
|
+
} else {
|
|
357
|
+
workflow = injectedAfterBatch.workflow;
|
|
358
|
+
}
|
|
189
359
|
saveRunTasks(manifest, tasks);
|
|
190
360
|
saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
|
|
191
361
|
const completedBatch = readyBatch.map((task) => tasks.find((item) => item.id === task.id) ?? task);
|
package/src/state/types.ts
CHANGED
|
@@ -152,6 +152,10 @@ export interface TeamTaskState {
|
|
|
152
152
|
taskPacket?: TaskPacket;
|
|
153
153
|
verification?: VerificationEvidence;
|
|
154
154
|
graph?: TaskGraphNode;
|
|
155
|
+
adaptive?: {
|
|
156
|
+
phase: string;
|
|
157
|
+
task: string;
|
|
158
|
+
};
|
|
155
159
|
policy?: {
|
|
156
160
|
retryCount?: number;
|
|
157
161
|
lastDecision?: PolicyDecision;
|
|
@@ -16,7 +16,7 @@ function readTasks(tasksPath: string): TeamTaskState[] {
|
|
|
16
16
|
try { const parsed = JSON.parse(fs.readFileSync(tasksPath, "utf-8")); return Array.isArray(parsed) ? parsed as TeamTaskState[] : []; } catch { return []; }
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
function compactTokens(total: number): string {
|
|
19
|
+
export function compactTokens(total: number): string {
|
|
20
20
|
return total >= 1000 ? `${Math.round(total / 1000)}k` : `${total}`;
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -42,23 +42,24 @@ export function updatePiCrewPowerbar(events: EventBus, cwd: string, config?: Cre
|
|
|
42
42
|
const tasks = active.flatMap((item) => readTasks(item.run.tasksPath));
|
|
43
43
|
const running = agents.filter((agent) => agent.status === "running").length;
|
|
44
44
|
const waiting = tasks.filter((task) => task.status === "queued").length;
|
|
45
|
-
const completed =
|
|
46
|
-
const total = Math.max(1,
|
|
45
|
+
const completed = tasks.filter((task) => task.status === "completed").length;
|
|
46
|
+
const total = Math.max(1, tasks.length || agents.length);
|
|
47
47
|
const usage = aggregateUsage(tasks);
|
|
48
48
|
const tokenTotal = usage ? (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0) : 0;
|
|
49
|
-
const model = agents.find((agent) => agent.model)?.model?.split("/").at(-1);
|
|
49
|
+
const model = config?.showModel === false ? undefined : agents.find((agent) => agent.model)?.model?.split("/").at(-1);
|
|
50
|
+
const tokenText = config?.showTokens === false || !tokenTotal ? undefined : compactTokens(tokenTotal);
|
|
50
51
|
safeEmit(events, "powerbar:update", {
|
|
51
52
|
id: "pi-crew-active",
|
|
52
53
|
icon: "⚙",
|
|
53
54
|
text: `crew ${running}a/${waiting}w`,
|
|
54
|
-
suffix: [model,
|
|
55
|
+
suffix: [model, tokenText].filter(Boolean).join(" · ") || undefined,
|
|
55
56
|
color: running ? "accent" : "warning",
|
|
56
57
|
});
|
|
57
58
|
safeEmit(events, "powerbar:update", {
|
|
58
59
|
id: "pi-crew-progress",
|
|
59
60
|
text: active[0]?.run.team ?? "crew",
|
|
60
61
|
bar: Math.round((completed / total) * 100),
|
|
61
|
-
suffix: `${completed}/${total}${
|
|
62
|
+
suffix: `${completed}/${total}${tokenText ? ` · ${tokenText}` : ""}`,
|
|
62
63
|
color: completed === total ? "success" : "accent",
|
|
63
64
|
barSegments: 8,
|
|
64
65
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: implementation
|
|
3
|
-
description: Full implementation team with
|
|
3
|
+
description: Full implementation team with parallel specialists, critique, execution, review, and verification
|
|
4
4
|
defaultWorkflow: implementation
|
|
5
5
|
workspaceMode: single
|
|
6
6
|
maxConcurrency: 3
|
|
@@ -9,7 +9,10 @@ maxConcurrency: 3
|
|
|
9
9
|
- explorer: agent=explorer map the codebase
|
|
10
10
|
- analyst: agent=analyst clarify requirements and constraints
|
|
11
11
|
- planner: agent=planner create execution plan
|
|
12
|
-
- critic: agent=critic challenge
|
|
12
|
+
- critic: agent=critic challenge and synthesize specialist findings
|
|
13
13
|
- executor: agent=executor implement the plan
|
|
14
14
|
- reviewer: agent=reviewer review the implementation
|
|
15
|
+
- security-reviewer: agent=security-reviewer review security and trust boundaries
|
|
16
|
+
- test-engineer: agent=test-engineer design and run verification
|
|
15
17
|
- verifier: agent=verifier verify done
|
|
18
|
+
- writer: agent=writer summarize documentation or release notes when needed
|
|
@@ -1,47 +1,38 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: implementation
|
|
3
|
-
description:
|
|
3
|
+
description: Adaptive implementation workflow where a planner agent decides the subagent fanout
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
##
|
|
7
|
-
role: explorer
|
|
8
|
-
|
|
9
|
-
Map relevant files, APIs, and constraints for: {goal}
|
|
10
|
-
|
|
11
|
-
## analyze
|
|
12
|
-
role: analyst
|
|
13
|
-
dependsOn: explore
|
|
14
|
-
|
|
15
|
-
Analyze requirements, ambiguities, risks, and acceptance criteria for: {goal}
|
|
16
|
-
|
|
17
|
-
## plan
|
|
6
|
+
## assess
|
|
18
7
|
role: planner
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
8
|
+
output: adaptive-plan.json
|
|
9
|
+
|
|
10
|
+
Assess this task and decide how many subagents are actually needed for: {goal}
|
|
11
|
+
|
|
12
|
+
You are the orchestration planner. Inspect the repository enough to choose an efficient crew; do not use a fixed template. Small/simple tasks may need one executor plus one verifier. Risky or broad tasks may need parallel explorers, specialists, implementers, reviewers, security reviewers, or test engineers.
|
|
13
|
+
|
|
14
|
+
Return a concise rationale, then include exactly one JSON block between these markers:
|
|
15
|
+
|
|
16
|
+
ADAPTIVE_PLAN_JSON_START
|
|
17
|
+
{
|
|
18
|
+
"phases": [
|
|
19
|
+
{
|
|
20
|
+
"name": "short-phase-name",
|
|
21
|
+
"tasks": [
|
|
22
|
+
{
|
|
23
|
+
"role": "explorer|analyst|planner|critic|executor|reviewer|security-reviewer|test-engineer|verifier|writer",
|
|
24
|
+
"title": "short task title",
|
|
25
|
+
"task": "specific autonomous task prompt for this subagent"
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
ADAPTIVE_PLAN_JSON_END
|
|
32
|
+
|
|
33
|
+
Rules:
|
|
34
|
+
- Choose the smallest effective number of subagents.
|
|
35
|
+
- Use parallel tasks in the same phase only when their work is independent.
|
|
36
|
+
- Later phases depend on all tasks in the previous phase.
|
|
37
|
+
- Include verification/review tasks when implementation is requested.
|
|
38
|
+
- Do not include more than 12 total subagents unless the user explicitly asks for a large crew.
|