fix(coding-agent): improve subagent parallel summaries closes #4710

This commit is contained in:
Mario Zechner
2026-05-18 22:52:43 +02:00
parent 7670563374
commit 93ecdbea35
3 changed files with 48 additions and 19 deletions

View File

@@ -2,6 +2,10 @@
## [Unreleased] ## [Unreleased]
### Fixed
- Fixed the subagent extension's parallel mode to return useful per-task output and failed-task diagnostics to the parent model instead of 100-character previews ([#4710](https://github.com/earendil-works/pi/issues/4710)).
## [0.75.3] - 2026-05-18 ## [0.75.3] - 2026-05-18
### Fixed ### Fixed

View File

@@ -113,6 +113,8 @@ Use a chain: first have scout find the read tool, then have planner suggest impr
- Shows all tasks with live status (⏳ running, ✓ done, ✗ failed) - Shows all tasks with live status (⏳ running, ✓ done, ✗ failed)
- Updates as each task makes progress - Updates as each task makes progress
- Shows "2/3 done, 1 running" status - Shows "2/3 done, 1 running" status
- Returns each completed task's final output to the parent model, capped at 50 KB per task
- Returns failure diagnostics from stderr/error messages when a child exits before producing output
**Tool call formatting** (mimics built-in tools): **Tool call formatting** (mimics built-in tools):
- `$ command` for bash - `$ command` for bash
@@ -168,5 +170,6 @@ Project agents override user agents with the same name when `agentScope: "both"`
## Limitations ## Limitations
- Output truncated to last 10 items in collapsed view (expand to see all) - Output truncated to last 10 items in collapsed view (expand to see all)
- Parallel model-visible output is capped at 50 KB per task; full results remain in tool details
- Agents discovered fresh on each invocation (allows editing mid-session) - Agents discovered fresh on each invocation (allows editing mid-session)
- Parallel mode limited to 8 tasks, 4 concurrent - Parallel mode limited to 8 tasks, 4 concurrent

View File

@@ -27,6 +27,7 @@ import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
const MAX_PARALLEL_TASKS = 8; const MAX_PARALLEL_TASKS = 8;
const MAX_CONCURRENCY = 4; const MAX_CONCURRENCY = 4;
const COLLAPSED_ITEM_COUNT = 10; const COLLAPSED_ITEM_COUNT = 10;
const PER_TASK_OUTPUT_CAP = 50 * 1024;
function formatTokens(count: number): string { function formatTokens(count: number): string {
if (count < 1000) return count.toString(); if (count < 1000) return count.toString();
@@ -172,6 +173,28 @@ function getFinalOutput(messages: Message[]): string {
return ""; return "";
} }
function isFailedResult(result: SingleResult): boolean {
return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
}
function getResultOutput(result: SingleResult): string {
if (isFailedResult(result)) {
return result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
}
return getFinalOutput(result.messages) || "(no output)";
}
function truncateParallelOutput(output: string): string {
const byteLength = Buffer.byteLength(output, "utf8");
if (byteLength <= PER_TASK_OUTPUT_CAP) return output;
let truncated = output.slice(0, PER_TASK_OUTPUT_CAP);
while (Buffer.byteLength(truncated, "utf8") > PER_TASK_OUTPUT_CAP) {
truncated = truncated.slice(0, -1);
}
return `${truncated}\n\n[Output truncated: ${byteLength - Buffer.byteLength(truncated, "utf8")} bytes omitted. Full output preserved in tool details.]`;
}
type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, any> }; type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, any> };
function getDisplayItems(messages: Message[]): DisplayItem[] { function getDisplayItems(messages: Message[]): DisplayItem[] {
@@ -534,11 +557,9 @@ export default function (pi: ExtensionAPI) {
); );
results.push(result); results.push(result);
const isError = const isError = isFailedResult(result);
result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
if (isError) { if (isError) {
const errorMsg = const errorMsg = getResultOutput(result);
result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
return { return {
content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }], content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }],
details: makeDetails("chain")(results), details: makeDetails("chain")(results),
@@ -617,17 +638,19 @@ export default function (pi: ExtensionAPI) {
return result; return result;
}); });
const successCount = results.filter((r) => r.exitCode === 0).length; const successCount = results.filter((r) => !isFailedResult(r)).length;
const summaries = results.map((r) => { const summaries = results.map((r) => {
const output = getFinalOutput(r.messages); const output = truncateParallelOutput(getResultOutput(r));
const preview = output.slice(0, 100) + (output.length > 100 ? "..." : ""); const status = isFailedResult(r)
return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${preview || "(no output)"}`; ? `failed${r.stopReason && r.stopReason !== "end" ? ` (${r.stopReason})` : ""}`
: "completed";
return `### [${r.agent}] ${status}\n\n${output}`;
}); });
return { return {
content: [ content: [
{ {
type: "text", type: "text",
text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}`, text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n---\n\n")}`,
}, },
], ],
details: makeDetails("parallel")(results), details: makeDetails("parallel")(results),
@@ -646,10 +669,9 @@ export default function (pi: ExtensionAPI) {
onUpdate, onUpdate,
makeDetails("single"), makeDetails("single"),
); );
const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted"; const isError = isFailedResult(result);
if (isError) { if (isError) {
const errorMsg = const errorMsg = getResultOutput(result);
result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
return { return {
content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}` }], content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}` }],
details: makeDetails("single")([result]), details: makeDetails("single")([result]),
@@ -740,7 +762,7 @@ export default function (pi: ExtensionAPI) {
if (details.mode === "single" && details.results.length === 1) { if (details.mode === "single" && details.results.length === 1) {
const r = details.results[0]; const r = details.results[0];
const isError = r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted"; const isError = isFailedResult(r);
const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓"); const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
const displayItems = getDisplayItems(r.messages); const displayItems = getDisplayItems(r.messages);
const finalOutput = getFinalOutput(r.messages); const finalOutput = getFinalOutput(r.messages);
@@ -893,8 +915,8 @@ export default function (pi: ExtensionAPI) {
if (details.mode === "parallel") { if (details.mode === "parallel") {
const running = details.results.filter((r) => r.exitCode === -1).length; const running = details.results.filter((r) => r.exitCode === -1).length;
const successCount = details.results.filter((r) => r.exitCode === 0).length; const successCount = details.results.filter((r) => r.exitCode !== -1 && !isFailedResult(r)).length;
const failCount = details.results.filter((r) => r.exitCode > 0).length; const failCount = details.results.filter((r) => r.exitCode !== -1 && isFailedResult(r)).length;
const isRunning = running > 0; const isRunning = running > 0;
const icon = isRunning const icon = isRunning
? theme.fg("warning", "⏳") ? theme.fg("warning", "⏳")
@@ -916,7 +938,7 @@ export default function (pi: ExtensionAPI) {
); );
for (const r of details.results) { for (const r of details.results) {
const rIcon = r.exitCode === 0 ? theme.fg("success", "") : theme.fg("error", ""); const rIcon = isFailedResult(r) ? theme.fg("error", "") : theme.fg("success", "");
const displayItems = getDisplayItems(r.messages); const displayItems = getDisplayItems(r.messages);
const finalOutput = getFinalOutput(r.messages); const finalOutput = getFinalOutput(r.messages);
@@ -963,9 +985,9 @@ export default function (pi: ExtensionAPI) {
const rIcon = const rIcon =
r.exitCode === -1 r.exitCode === -1
? theme.fg("warning", "⏳") ? theme.fg("warning", "⏳")
: r.exitCode === 0 : isFailedResult(r)
? theme.fg("success", "") ? theme.fg("error", "")
: theme.fg("error", ""); : theme.fg("success", "");
const displayItems = getDisplayItems(r.messages); const displayItems = getDisplayItems(r.messages);
text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon}`; text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon}`;
if (displayItems.length === 0) if (displayItems.length === 0)