feat(coding-agent): added cell-by-cell Python execution tracking with duration and exit codes
- Added cell-by-cell Python execution tracking with duration and exit codes.
- Added template system with {{placeholders}} for task tool context rendering.
- Enhanced patch applicator to preserve exact indentation when intended.
- Changed Python tool to use cells array instead of single code parameter.
- Updated task tool schema to require vars instead of task field.
This commit is contained in:
@@ -1,8 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added cell-by-cell status tracking with duration and exit code for Python execution
|
||||
- Added syntax highlighting for Python code in execution display
|
||||
- Added template system with {{placeholders}} for task tool context
|
||||
- Added task variables support for filling context placeholders
|
||||
- Added enhanced task progress display with variable values
|
||||
- Added concurrent work handling guidance in system prompt
|
||||
- Added extension system support for user Python execution events
|
||||
- Added Python mode border color theming across all themes
|
||||
- Added Python execution indicator to welcome screen help text
|
||||
@@ -16,6 +23,11 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated Python execution to display cells in bordered blocks with status indicators
|
||||
- Changed task tool to use template-based context instead of simple concatenation
|
||||
- Enhanced Python execution component with proper syntax highlighting
|
||||
- Improved patch applicator to preserve exact indentation when intended
|
||||
- Updated task tool schema to require vars instead of task field
|
||||
- Updated Python execution component to use pythonMode theming instead of bashMode
|
||||
- Enhanced UI helpers to handle pending Python components properly
|
||||
- Changed Python tool to use `cells` array instead of single `code` parameter
|
||||
@@ -24,6 +36,8 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed indentation preservation for exact matches and indentation-only patches
|
||||
- Fixed Python execution status updates to show real-time cell progress
|
||||
- Fixed indentation adjustment logic to handle edge cases with mixed indentation levels
|
||||
- Fixed patch indentation normalization for fuzzy matches, tab/space diffs, and ambiguous context alignment
|
||||
|
||||
|
||||
@@ -83,6 +83,34 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
|
||||
return newLines;
|
||||
}
|
||||
|
||||
// If pattern already matches actual exactly (including indentation), preserve agent's intended changes
|
||||
if (patternLines.length === actualLines.length) {
|
||||
let exactMatch = true;
|
||||
for (let i = 0; i < patternLines.length; i++) {
|
||||
if (patternLines[i] !== actualLines[i]) {
|
||||
exactMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (exactMatch) {
|
||||
return newLines;
|
||||
}
|
||||
}
|
||||
|
||||
// If the patch is purely an indentation change (same trimmed content), apply exactly as specified
|
||||
if (patternLines.length === newLines.length) {
|
||||
let indentationOnly = true;
|
||||
for (let i = 0; i < patternLines.length; i++) {
|
||||
if (patternLines[i].trim() !== newLines[i].trim()) {
|
||||
indentationOnly = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (indentationOnly) {
|
||||
return newLines;
|
||||
}
|
||||
}
|
||||
|
||||
// Detect indent character from actual content
|
||||
let indentChar = " ";
|
||||
for (const line of actualLines) {
|
||||
|
||||
@@ -299,6 +299,27 @@ export function normalizeForFuzzy(line: string): string {
|
||||
* to each line in newText.
|
||||
*/
|
||||
export function adjustIndentation(oldText: string, actualText: string, newText: string): string {
|
||||
// If old text already matches actual text exactly, preserve agent's intended indentation
|
||||
if (oldText === actualText) {
|
||||
return newText;
|
||||
}
|
||||
|
||||
// If the patch is purely an indentation change (same trimmed content), apply exactly as specified
|
||||
const oldLines = oldText.split("\n");
|
||||
const newLines = newText.split("\n");
|
||||
if (oldLines.length === newLines.length) {
|
||||
let indentationOnly = true;
|
||||
for (let i = 0; i < oldLines.length; i++) {
|
||||
if (oldLines[i].trim() !== newLines[i].trim()) {
|
||||
indentationOnly = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (indentationOnly) {
|
||||
return newText;
|
||||
}
|
||||
}
|
||||
|
||||
const oldProfile = buildIndentProfile(oldText);
|
||||
const actualProfile = buildIndentProfile(actualText);
|
||||
const newProfile = buildIndentProfile(newText);
|
||||
|
||||
@@ -2,10 +2,10 @@ import { relative, resolve, sep } from "node:path";
|
||||
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
||||
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
||||
import type { Component } from "@oh-my-pi/pi-tui";
|
||||
import { Text, truncateToWidth } from "@oh-my-pi/pi-tui";
|
||||
import { Text, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate";
|
||||
import type { Theme } from "../../modes/interactive/theme/theme";
|
||||
import { highlightCode, type Theme } from "../../modes/interactive/theme/theme";
|
||||
import pythonDescription from "../../prompts/tools/python.md" with { type: "text" };
|
||||
import type { RenderResultOptions } from "../custom-tools/types";
|
||||
import { renderPromptTemplate } from "../prompt-templates";
|
||||
@@ -67,7 +67,19 @@ export type PythonToolResult = {
|
||||
|
||||
export type PythonProxyExecutor = (params: PythonToolParams, signal?: AbortSignal) => Promise<PythonToolResult>;
|
||||
|
||||
export interface PythonCellResult {
|
||||
index: number;
|
||||
title?: string;
|
||||
code: string;
|
||||
output: string;
|
||||
status: "pending" | "running" | "complete" | "error";
|
||||
durationMs?: number;
|
||||
exitCode?: number;
|
||||
statusEvents?: PythonStatusEvent[];
|
||||
}
|
||||
|
||||
export interface PythonToolDetails {
|
||||
cells?: PythonCellResult[];
|
||||
truncation?: TruncationResult;
|
||||
fullOutputPath?: string;
|
||||
fullOutput?: string;
|
||||
@@ -188,6 +200,65 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
|
||||
let tailBytes = 0;
|
||||
const jsonOutputs: unknown[] = [];
|
||||
const images: ImageContent[] = [];
|
||||
const statusEvents: PythonStatusEvent[] = [];
|
||||
|
||||
const cellResults: PythonCellResult[] = cells.map((cell, index) => ({
|
||||
index,
|
||||
title: cell.title,
|
||||
code: cell.code,
|
||||
output: "",
|
||||
status: "pending",
|
||||
}));
|
||||
const cellOutputs: string[] = [];
|
||||
let lastFullOutputPath: string | undefined;
|
||||
|
||||
const appendTail = (text: string) => {
|
||||
if (!text) return;
|
||||
const chunkBytes = Buffer.byteLength(text, "utf-8");
|
||||
tailChunks.push({ text, bytes: chunkBytes });
|
||||
tailBytes += chunkBytes;
|
||||
while (tailBytes > maxTailBytes && tailChunks.length > 1) {
|
||||
const removed = tailChunks.shift();
|
||||
if (removed) {
|
||||
tailBytes -= removed.bytes;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const buildUpdateDetails = (truncation?: TruncationResult): PythonToolDetails => {
|
||||
const details: PythonToolDetails = {
|
||||
cells: cellResults.map((cell) => ({
|
||||
...cell,
|
||||
statusEvents: cell.statusEvents ? [...cell.statusEvents] : undefined,
|
||||
})),
|
||||
};
|
||||
if (truncation) {
|
||||
details.truncation = truncation;
|
||||
}
|
||||
if (lastFullOutputPath) {
|
||||
details.fullOutputPath = lastFullOutputPath;
|
||||
}
|
||||
if (jsonOutputs.length > 0) {
|
||||
details.jsonOutputs = jsonOutputs;
|
||||
}
|
||||
if (images.length > 0) {
|
||||
details.images = images;
|
||||
}
|
||||
if (statusEvents.length > 0) {
|
||||
details.statusEvents = statusEvents;
|
||||
}
|
||||
return details;
|
||||
};
|
||||
|
||||
const pushUpdate = () => {
|
||||
if (!onUpdate) return;
|
||||
const tailText = tailChunks.map((entry) => entry.text).join("");
|
||||
const truncation = truncateTail(tailText);
|
||||
onUpdate({
|
||||
content: [{ type: "text", text: truncation.content || "" }],
|
||||
details: buildUpdateDetails(truncation.truncated ? truncation : undefined),
|
||||
});
|
||||
};
|
||||
|
||||
const sessionFile = this.session.getSessionFile?.() ?? undefined;
|
||||
const sessionId = sessionFile ? `session:${sessionFile}:cwd:${commandCwd}` : `cwd:${commandCwd}`;
|
||||
@@ -198,41 +269,29 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
|
||||
sessionId,
|
||||
kernelMode: this.session.settings?.getPythonKernelMode?.() ?? "session",
|
||||
useSharedGateway: this.session.settings?.getPythonSharedGateway?.() ?? true,
|
||||
onChunk: (chunk) => {
|
||||
const chunkBytes = Buffer.byteLength(chunk, "utf-8");
|
||||
tailChunks.push({ text: chunk, bytes: chunkBytes });
|
||||
tailBytes += chunkBytes;
|
||||
while (tailBytes > maxTailBytes && tailChunks.length > 1) {
|
||||
const removed = tailChunks.shift();
|
||||
if (removed) {
|
||||
tailBytes -= removed.bytes;
|
||||
}
|
||||
}
|
||||
if (onUpdate) {
|
||||
const tailText = tailChunks.map((entry) => entry.text).join("");
|
||||
const truncation = truncateTail(tailText);
|
||||
onUpdate({
|
||||
content: [{ type: "text", text: truncation.content || "" }],
|
||||
details: truncation.truncated ? { truncation } : undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const statusEvents: PythonStatusEvent[] = [];
|
||||
const cellOutputs: string[] = [];
|
||||
let lastFullOutputPath: string | undefined;
|
||||
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
const cell = cells[i];
|
||||
const isFirstCell = i === 0;
|
||||
const cellResult = cellResults[i];
|
||||
cellResult.status = "running";
|
||||
cellResult.output = "";
|
||||
cellResult.statusEvents = undefined;
|
||||
cellResult.exitCode = undefined;
|
||||
cellResult.durationMs = undefined;
|
||||
pushUpdate();
|
||||
|
||||
const executorOptions: PythonExecutorOptions = {
|
||||
...baseExecutorOptions,
|
||||
reset: isFirstCell ? reset : false,
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
const result = await executePython(cell.code, executorOptions);
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
const cellStatusEvents: PythonStatusEvent[] = [];
|
||||
for (const output of result.displayOutputs) {
|
||||
if (output.type === "json") {
|
||||
jsonOutputs.push(output.data);
|
||||
@@ -242,6 +301,7 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
|
||||
}
|
||||
if (output.type === "status") {
|
||||
statusEvents.push(output.event);
|
||||
cellStatusEvents.push(output.event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,23 +310,41 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
|
||||
}
|
||||
|
||||
const cellOutput = result.output.trim();
|
||||
cellResult.output = cellOutput;
|
||||
cellResult.exitCode = result.exitCode;
|
||||
cellResult.durationMs = durationMs;
|
||||
cellResult.statusEvents = cellStatusEvents.length > 0 ? cellStatusEvents : undefined;
|
||||
|
||||
let combinedCellOutput = "";
|
||||
if (cells.length > 1) {
|
||||
const cellHeader = `[${i + 1}/${cells.length}]`;
|
||||
const cellTitle = cell.title ? ` ${cell.title}` : "";
|
||||
if (cellOutput) {
|
||||
cellOutputs.push(`${cellHeader}${cellTitle}\n${cellOutput}`);
|
||||
combinedCellOutput = `${cellHeader}${cellTitle}\n${cellOutput}`;
|
||||
} else {
|
||||
cellOutputs.push(`${cellHeader}${cellTitle} (ok)`);
|
||||
combinedCellOutput = `${cellHeader}${cellTitle} (ok)`;
|
||||
}
|
||||
cellOutputs.push(combinedCellOutput);
|
||||
} else if (cellOutput) {
|
||||
cellOutputs.push(cellOutput);
|
||||
combinedCellOutput = cellOutput;
|
||||
cellOutputs.push(combinedCellOutput);
|
||||
}
|
||||
|
||||
if (combinedCellOutput) {
|
||||
const prefix = cellOutputs.length > 1 ? "\n\n" : "";
|
||||
appendTail(`${prefix}${combinedCellOutput}`);
|
||||
}
|
||||
|
||||
if (result.cancelled) {
|
||||
cellResult.status = "error";
|
||||
pushUpdate();
|
||||
const errorMsg = result.output || "Command aborted";
|
||||
throw new Error(cells.length > 1 ? `Cell ${i + 1} aborted: ${errorMsg}` : errorMsg);
|
||||
}
|
||||
|
||||
if (result.exitCode !== 0 && result.exitCode !== undefined) {
|
||||
cellResult.status = "error";
|
||||
pushUpdate();
|
||||
const combinedOutput = cellOutputs.join("\n\n");
|
||||
throw new Error(
|
||||
cells.length > 1
|
||||
@@ -274,36 +352,32 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
|
||||
: `${combinedOutput}\n\nCommand exited with code ${result.exitCode}`,
|
||||
);
|
||||
}
|
||||
|
||||
cellResult.status = "complete";
|
||||
pushUpdate();
|
||||
}
|
||||
|
||||
const combinedOutput = cellOutputs.join("\n\n");
|
||||
const truncation = truncateTail(combinedOutput);
|
||||
let outputText =
|
||||
truncation.content || (jsonOutputs.length > 0 || images.length > 0 ? "(no text output)" : "(no output)");
|
||||
let details: PythonToolDetails | undefined;
|
||||
|
||||
const details: PythonToolDetails = {
|
||||
cells: cellResults,
|
||||
fullOutputPath: lastFullOutputPath,
|
||||
jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
|
||||
images: images.length > 0 ? images : undefined,
|
||||
statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
|
||||
};
|
||||
|
||||
if (truncation.truncated) {
|
||||
details = {
|
||||
truncation,
|
||||
fullOutputPath: lastFullOutputPath,
|
||||
jsonOutputs: jsonOutputs,
|
||||
images,
|
||||
statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
|
||||
};
|
||||
details.truncation = truncation;
|
||||
outputText += formatTailTruncationNotice(truncation, {
|
||||
fullOutputPath: lastFullOutputPath,
|
||||
originalContent: combinedOutput,
|
||||
});
|
||||
}
|
||||
|
||||
if (!details && (jsonOutputs.length > 0 || images.length > 0 || statusEvents.length > 0)) {
|
||||
details = {
|
||||
jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
|
||||
images: images.length > 0 ? images : undefined,
|
||||
statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return { content: [{ type: "text", text: outputText }], details };
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
@@ -644,11 +718,173 @@ function renderStatusEvents(events: PythonStatusEvent[], theme: Theme, expanded:
|
||||
return lines;
|
||||
}
|
||||
|
||||
function applyCellBackground(line: string, width: number, bgFn?: (text: string) => string): string {
|
||||
if (!bgFn) return line;
|
||||
if (width <= 0) return bgFn(line);
|
||||
const paddingNeeded = Math.max(0, width - visibleWidth(line));
|
||||
const padded = line + " ".repeat(paddingNeeded);
|
||||
return bgFn(padded);
|
||||
}
|
||||
|
||||
function highlightPythonCode(code?: string): string[] {
|
||||
return highlightCode(code ?? "", "python");
|
||||
}
|
||||
|
||||
function formatCellStatus(cell: PythonCellResult, ui: ToolUIKit, spinnerFrame?: number): string | undefined {
|
||||
switch (cell.status) {
|
||||
case "pending":
|
||||
return `${ui.statusIcon("pending")} ${ui.theme.fg("muted", "pending")}`;
|
||||
case "running":
|
||||
return `${ui.statusIcon("running", spinnerFrame)} ${ui.theme.fg("muted", "running")}`;
|
||||
case "complete":
|
||||
return ui.statusIcon("success");
|
||||
case "error":
|
||||
return ui.statusIcon("error");
|
||||
}
|
||||
}
|
||||
|
||||
function formatCellHeader(
|
||||
cell: PythonCellResult,
|
||||
index: number,
|
||||
total: number,
|
||||
ui: ToolUIKit,
|
||||
spinnerFrame?: number,
|
||||
workdirLabel?: string,
|
||||
): string {
|
||||
const indexLabel = ui.theme.fg("accent", `[${index + 1}/${total}]`);
|
||||
const title = cell.title ? ` ${cell.title}` : "";
|
||||
const metaParts: string[] = [];
|
||||
if (workdirLabel) {
|
||||
metaParts.push(ui.theme.fg("dim", workdirLabel));
|
||||
}
|
||||
if (cell.durationMs !== undefined) {
|
||||
metaParts.push(ui.theme.fg("dim", `(${ui.formatDuration(cell.durationMs)})`));
|
||||
}
|
||||
const statusLabel = formatCellStatus(cell, ui, spinnerFrame);
|
||||
if (statusLabel) {
|
||||
metaParts.push(statusLabel);
|
||||
}
|
||||
const meta = metaParts.length > 0 ? ` ${metaParts.join(ui.theme.fg("dim", ui.theme.sep.dot))}` : "";
|
||||
return `${indexLabel}${title}${meta}`;
|
||||
}
|
||||
|
||||
function formatCellOutputLines(
|
||||
cell: PythonCellResult,
|
||||
expanded: boolean,
|
||||
previewLines: number,
|
||||
theme: Theme,
|
||||
): { lines: string[]; hiddenCount: number } {
|
||||
const rawLines = cell.output ? cell.output.split("\n") : [];
|
||||
const displayLines = expanded ? rawLines : rawLines.slice(-previewLines);
|
||||
const hiddenCount = rawLines.length - displayLines.length;
|
||||
const outputLines = displayLines.map((line) => theme.fg("toolOutput", line));
|
||||
|
||||
if (outputLines.length === 0) {
|
||||
return { lines: [], hiddenCount: 0 };
|
||||
}
|
||||
|
||||
return { lines: outputLines, hiddenCount };
|
||||
}
|
||||
|
||||
function renderCellBlock(
|
||||
cell: PythonCellResult,
|
||||
index: number,
|
||||
total: number,
|
||||
ui: ToolUIKit,
|
||||
options: {
|
||||
expanded: boolean;
|
||||
previewLines: number;
|
||||
spinnerFrame?: number;
|
||||
showOutput: boolean;
|
||||
workdirLabel?: string;
|
||||
width: number;
|
||||
bgFn?: (text: string) => string;
|
||||
},
|
||||
): string[] {
|
||||
const { expanded, previewLines, spinnerFrame, showOutput, workdirLabel, width, bgFn } = options;
|
||||
const h = ui.theme.boxSharp.horizontal;
|
||||
const v = ui.theme.boxSharp.vertical;
|
||||
const cap = h.repeat(3);
|
||||
const border = (text: string) => ui.theme.fg("dim", text);
|
||||
const lineWidth = Math.max(0, width);
|
||||
|
||||
const buildBarLine = (leftChar: string, label?: string): string => {
|
||||
const left = border(`${leftChar}${cap}`);
|
||||
if (lineWidth <= 0) return left;
|
||||
const rawLabel = label ? ` ${label} ` : " ";
|
||||
const maxLabelWidth = Math.max(0, lineWidth - visibleWidth(left));
|
||||
const trimmedLabel = truncateToWidth(rawLabel, maxLabelWidth, ui.theme.format.ellipsis);
|
||||
const fillCount = Math.max(0, lineWidth - visibleWidth(left + trimmedLabel));
|
||||
return `${left}${trimmedLabel}${border(h.repeat(fillCount))}`;
|
||||
};
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
applyCellBackground(
|
||||
buildBarLine(ui.theme.boxSharp.topLeft, formatCellHeader(cell, index, total, ui, spinnerFrame, workdirLabel)),
|
||||
lineWidth,
|
||||
bgFn,
|
||||
),
|
||||
);
|
||||
|
||||
const codePrefix = border(`${v} `);
|
||||
const codeWidth = Math.max(0, lineWidth - visibleWidth(codePrefix));
|
||||
const codeLines = highlightPythonCode(cell.code);
|
||||
for (const line of codeLines) {
|
||||
const text = truncateToWidth(line, codeWidth, ui.theme.format.ellipsis);
|
||||
lines.push(applyCellBackground(`${codePrefix}${text}`, lineWidth, bgFn));
|
||||
}
|
||||
|
||||
const statusLines = renderStatusEvents(cell.statusEvents ?? [], ui.theme, expanded);
|
||||
const outputContent = formatCellOutputLines(cell, expanded, previewLines, ui.theme);
|
||||
const hasOutput = outputContent.lines.length > 0;
|
||||
const hasStatus = statusLines.length > 0;
|
||||
const showOutputSection = showOutput && (hasOutput || hasStatus);
|
||||
|
||||
if (showOutputSection) {
|
||||
lines.push(
|
||||
applyCellBackground(
|
||||
buildBarLine(ui.theme.boxSharp.teeRight, ui.theme.fg("toolTitle", "Output")),
|
||||
lineWidth,
|
||||
bgFn,
|
||||
),
|
||||
);
|
||||
|
||||
for (const line of outputContent.lines) {
|
||||
const text = truncateToWidth(line, codeWidth, ui.theme.format.ellipsis);
|
||||
lines.push(applyCellBackground(`${codePrefix}${text}`, lineWidth, bgFn));
|
||||
}
|
||||
if (!expanded && outputContent.hiddenCount > 0) {
|
||||
const hint = ui.theme.fg(
|
||||
"dim",
|
||||
`${ui.theme.format.ellipsis} ${outputContent.hiddenCount} more lines (ctrl+o to expand)`,
|
||||
);
|
||||
lines.push(
|
||||
applyCellBackground(
|
||||
`${codePrefix}${truncateToWidth(hint, codeWidth, ui.theme.format.ellipsis)}`,
|
||||
lineWidth,
|
||||
bgFn,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const line of statusLines) {
|
||||
const text = truncateToWidth(line, codeWidth, ui.theme.format.ellipsis);
|
||||
lines.push(applyCellBackground(`${codePrefix}${text}`, lineWidth, bgFn));
|
||||
}
|
||||
}
|
||||
|
||||
const bottomLeft = border(`${ui.theme.boxSharp.bottomLeft}${cap}`);
|
||||
const bottomFillCount = Math.max(0, lineWidth - visibleWidth(bottomLeft));
|
||||
const bottomLine = `${bottomLeft}${border(h.repeat(bottomFillCount))}`;
|
||||
lines.push(applyCellBackground(bottomLine, lineWidth, bgFn));
|
||||
return lines;
|
||||
}
|
||||
|
||||
export const pythonToolRenderer = {
|
||||
renderCall(args: PythonRenderArgs, uiTheme: Theme): Component {
|
||||
const ui = new ToolUIKit(uiTheme);
|
||||
const cells = args.cells ?? [];
|
||||
const prompt = uiTheme.fg("accent", ">>>");
|
||||
const cwd = process.cwd();
|
||||
let displayWorkdir = args.cwd;
|
||||
|
||||
@@ -666,29 +902,44 @@ export const pythonToolRenderer = {
|
||||
}
|
||||
}
|
||||
|
||||
const workdirPrefix = displayWorkdir ? uiTheme.fg("dim", `cd ${displayWorkdir} && `) : "";
|
||||
|
||||
const workdirLabel = displayWorkdir ? `cd ${displayWorkdir}` : undefined;
|
||||
if (cells.length === 0) {
|
||||
const text = ui.title(`${prompt} ${workdirPrefix}${uiTheme.format.ellipsis}`);
|
||||
const prompt = uiTheme.fg("accent", ">>>");
|
||||
const prefix = workdirLabel ? `${uiTheme.fg("dim", `${workdirLabel} && `)}` : "";
|
||||
const text = ui.title(`${prompt} ${prefix}${uiTheme.format.ellipsis}`);
|
||||
return new Text(text, 0, 0);
|
||||
}
|
||||
|
||||
if (cells.length === 1) {
|
||||
const cell = cells[0];
|
||||
const label = cell.title ? `${cell.title}: ` : "";
|
||||
const text = ui.title(`${prompt} ${workdirPrefix}${label}${cell.code}`);
|
||||
return new Text(text, 0, 0);
|
||||
}
|
||||
|
||||
// Multiple cells: show each with index
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
const cellPrompt = uiTheme.fg("accent", `[${i + 1}]`);
|
||||
const prefix = i === 0 ? workdirPrefix : "";
|
||||
const label = cells[i].title ? `${cells[i].title}: ` : "";
|
||||
lines.push(ui.title(`${cellPrompt} ${prefix}${label}${cells[i].code}`));
|
||||
}
|
||||
return new Text(lines.join("\n"), 0, 0);
|
||||
return {
|
||||
render: (width: number): string[] => {
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
const cell = cells[i];
|
||||
const cellResult: PythonCellResult = {
|
||||
index: i,
|
||||
title: cell.title,
|
||||
code: cell.code,
|
||||
output: "",
|
||||
status: "pending",
|
||||
};
|
||||
lines.push(
|
||||
...renderCellBlock(cellResult, i, cells.length, ui, {
|
||||
expanded: true,
|
||||
previewLines: PYTHON_DEFAULT_PREVIEW_LINES,
|
||||
showOutput: false,
|
||||
workdirLabel: i === 0 ? workdirLabel : undefined,
|
||||
width,
|
||||
bgFn: (text: string) => uiTheme.bg("toolPendingBg", text),
|
||||
}),
|
||||
);
|
||||
if (i < cells.length - 1) {
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
},
|
||||
invalidate: () => {},
|
||||
};
|
||||
},
|
||||
|
||||
renderResult(
|
||||
@@ -704,7 +955,6 @@ export const pythonToolRenderer = {
|
||||
const previewLines = renderContext?.previewLines ?? PYTHON_DEFAULT_PREVIEW_LINES;
|
||||
const output = renderContext?.output ?? (result.content?.find((c) => c.type === "text")?.text ?? "").trim();
|
||||
const fullOutput = details?.fullOutput;
|
||||
const displayOutput = expanded ? (fullOutput ?? output) : output;
|
||||
const showingFullOutput = expanded && fullOutput !== undefined;
|
||||
|
||||
const jsonOutputs = details?.jsonOutputs ?? [];
|
||||
@@ -714,12 +964,6 @@ export const pythonToolRenderer = {
|
||||
return [header, ...treeLines];
|
||||
});
|
||||
|
||||
// Render status events
|
||||
const statusEvents = details?.statusEvents ?? [];
|
||||
const statusLines = renderStatusEvents(statusEvents, uiTheme, expanded);
|
||||
|
||||
const combinedOutput = [displayOutput, ...jsonLines].filter(Boolean).join("\n");
|
||||
|
||||
const truncation = details?.truncation;
|
||||
const fullOutputPath = details?.fullOutputPath;
|
||||
const timeoutSeconds = renderContext?.timeout;
|
||||
@@ -747,12 +991,63 @@ export const pythonToolRenderer = {
|
||||
}
|
||||
}
|
||||
|
||||
const cellResults = details?.cells;
|
||||
if (cellResults && cellResults.length > 0) {
|
||||
return {
|
||||
render: (width: number): string[] => {
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < cellResults.length; i++) {
|
||||
const cell = cellResults[i];
|
||||
const showOutput = cell.status !== "pending";
|
||||
const bgColor =
|
||||
cell.status === "error"
|
||||
? "toolErrorBg"
|
||||
: cell.status === "complete"
|
||||
? "toolSuccessBg"
|
||||
: "toolPendingBg";
|
||||
lines.push(
|
||||
...renderCellBlock(cell, i, cellResults.length, ui, {
|
||||
expanded,
|
||||
previewLines,
|
||||
spinnerFrame: options.spinnerFrame,
|
||||
showOutput,
|
||||
width,
|
||||
bgFn: (text: string) => uiTheme.bg(bgColor, text),
|
||||
}),
|
||||
);
|
||||
if (i < cellResults.length - 1) {
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
if (jsonLines.length > 0) {
|
||||
if (lines.length > 0) {
|
||||
lines.push("");
|
||||
}
|
||||
lines.push(...jsonLines);
|
||||
}
|
||||
if (timeoutLine) {
|
||||
lines.push(timeoutLine);
|
||||
}
|
||||
if (warningLine) {
|
||||
lines.push(warningLine);
|
||||
}
|
||||
return lines;
|
||||
},
|
||||
invalidate: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const displayOutput = expanded ? (fullOutput ?? output) : output;
|
||||
const combinedOutput = [displayOutput, ...jsonLines].filter(Boolean).join("\n");
|
||||
|
||||
const statusEvents = details?.statusEvents ?? [];
|
||||
const statusLines = renderStatusEvents(statusEvents, uiTheme, expanded);
|
||||
|
||||
if (!combinedOutput && statusLines.length === 0) {
|
||||
const lines = [timeoutLine, warningLine].filter(Boolean) as string[];
|
||||
return new Text(lines.join("\n"), 0, 0);
|
||||
}
|
||||
|
||||
// If only status events (no text output), show them directly
|
||||
if (!combinedOutput && statusLines.length > 0) {
|
||||
const lines = [...statusLines, timeoutLine, warningLine].filter(Boolean) as string[];
|
||||
return new Text(lines.join("\n"), 0, 0);
|
||||
@@ -795,7 +1090,6 @@ export const pythonToolRenderer = {
|
||||
outputLines.push(truncateToWidth(skippedLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis)));
|
||||
}
|
||||
outputLines.push(...cachedLines);
|
||||
// Add status events below the output
|
||||
for (const statusLine of statusLines) {
|
||||
outputLines.push(truncateToWidth(statusLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis)));
|
||||
}
|
||||
@@ -814,4 +1108,6 @@ export const pythonToolRenderer = {
|
||||
},
|
||||
};
|
||||
},
|
||||
mergeCallAndResult: true,
|
||||
inline: true,
|
||||
};
|
||||
|
||||
@@ -28,12 +28,14 @@ import { discoverAgents, getAgent } from "./discovery";
|
||||
import { runSubprocess } from "./executor";
|
||||
import { mapWithConcurrencyLimit } from "./parallel";
|
||||
import { renderCall, renderResult } from "./render";
|
||||
import { renderTemplate, validateTaskTemplate } from "./template";
|
||||
import {
|
||||
type AgentProgress,
|
||||
MAX_AGENTS_IN_DESCRIPTION,
|
||||
MAX_CONCURRENCY,
|
||||
MAX_PARALLEL_TASKS,
|
||||
type SingleResult,
|
||||
type TaskParams,
|
||||
type TaskToolDetails,
|
||||
taskSchema,
|
||||
} from "./types";
|
||||
@@ -112,14 +114,6 @@ async function buildDescription(cwd: string): Promise<string> {
|
||||
// Tool Class
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
type TaskParams = {
|
||||
agent: string;
|
||||
context?: string;
|
||||
model?: string;
|
||||
output?: unknown;
|
||||
tasks: Array<{ id: string; task: string; description: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Task tool - Delegate tasks to specialized agents.
|
||||
*
|
||||
@@ -201,7 +195,7 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `No tasks provided. Use: { agent, context, tasks: [{id, task, description}, ...] }`,
|
||||
text: `No tasks provided. Use: { agent, context, tasks: [{id, description, vars}, ...] }`,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
@@ -277,6 +271,18 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
|
||||
};
|
||||
}
|
||||
|
||||
const templateError = validateTaskTemplate(context, tasks);
|
||||
if (templateError) {
|
||||
return {
|
||||
content: [{ type: "text", text: templateError }],
|
||||
details: {
|
||||
projectAgentsDir,
|
||||
results: [],
|
||||
totalDurationMs: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Derive artifacts directory
|
||||
const sessionFile = this.session.getSessionFile();
|
||||
const artifactsDir = sessionFile ? sessionFile.slice(0, -6) : null;
|
||||
@@ -341,10 +347,12 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
|
||||
}
|
||||
|
||||
// Build full prompts with context prepended
|
||||
const contextTemplate = context ?? "";
|
||||
const tasksWithContext = tasks.map((t) => ({
|
||||
task: context ? `${context}\n\n${t.task}` : t.task,
|
||||
task: renderTemplate(contextTemplate, t.vars),
|
||||
description: t.description,
|
||||
taskId: t.id,
|
||||
vars: t.vars,
|
||||
}));
|
||||
|
||||
// Initialize progress for all tasks
|
||||
@@ -357,6 +365,7 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
|
||||
agentSource: agent.source,
|
||||
status: "pending",
|
||||
task: t.task,
|
||||
vars: t.vars,
|
||||
recentTools: [],
|
||||
recentOutput: [],
|
||||
toolCount: 0,
|
||||
@@ -391,7 +400,10 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
|
||||
signal,
|
||||
eventBus: undefined,
|
||||
onProgress: (progress) => {
|
||||
progressMap.set(index, structuredClone(progress));
|
||||
progressMap.set(index, {
|
||||
...structuredClone(progress),
|
||||
vars: tasksWithContext[index]?.vars,
|
||||
});
|
||||
emitProgress();
|
||||
},
|
||||
authStorage: this.session.authStorage,
|
||||
@@ -405,7 +417,12 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
|
||||
|
||||
// Fill in skipped tasks (undefined entries from abort) with placeholder results
|
||||
const results: SingleResult[] = partialResults.map((result, index) => {
|
||||
if (result !== undefined) return result;
|
||||
if (result !== undefined) {
|
||||
return {
|
||||
...result,
|
||||
vars: tasksWithContext[index]?.vars,
|
||||
};
|
||||
}
|
||||
const task = tasksWithContext[index];
|
||||
return {
|
||||
index,
|
||||
@@ -413,6 +430,7 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
|
||||
agent: agentName,
|
||||
agentSource: agent.source,
|
||||
task: task.task,
|
||||
vars: task.vars,
|
||||
description: task.description,
|
||||
exitCode: 1,
|
||||
output: "",
|
||||
|
||||
@@ -67,9 +67,12 @@ function formatFindingSummary(findings: ReportFindingDetails[], theme: Theme): s
|
||||
return `${theme.fg("dim", "Findings:")} ${parts.join(theme.sep.dot)}`;
|
||||
}
|
||||
|
||||
function formatJsonScalar(value: unknown): string {
|
||||
function formatJsonScalar(value: unknown, theme: Theme): string {
|
||||
if (value === null) return "null";
|
||||
if (typeof value === "string") return `"${value}"`;
|
||||
if (typeof value === "string") {
|
||||
const trimmed = truncate(value, 70, theme.format.ellipsis);
|
||||
return `"${trimmed}"`;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return "";
|
||||
}
|
||||
@@ -108,7 +111,7 @@ function renderJsonTreeLines(
|
||||
|
||||
const connector = isLast ? theme.tree.last : theme.tree.branch;
|
||||
const prefix = `${buildTreePrefix(ancestors, theme)}${theme.fg("dim", connector)} `;
|
||||
const scalar = formatJsonScalar(val);
|
||||
const scalar = formatJsonScalar(val, theme);
|
||||
|
||||
if (scalar) {
|
||||
const label = key ? theme.fg("muted", key) : theme.fg("muted", "value");
|
||||
@@ -186,7 +189,33 @@ function renderJsonTreeLines(
|
||||
pushLine(`${prefix}${iconScalar} ${label}: ${theme.fg("dim", String(val))}`);
|
||||
};
|
||||
|
||||
renderNode(value, undefined, [], true, 0);
|
||||
const renderRoot = (val: unknown) => {
|
||||
if (Array.isArray(val)) {
|
||||
for (let i = 0; i < val.length; i++) {
|
||||
renderNode(val[i], `[${i}]`, [], i === val.length - 1, 1);
|
||||
if (lines.length >= maxLines) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (val && typeof val === "object") {
|
||||
const entries = Object.entries(val as Record<string, unknown>);
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const [childKey, child] = entries[i];
|
||||
renderNode(child, childKey, [], i === entries.length - 1, 1);
|
||||
if (lines.length >= maxLines) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
renderNode(val, undefined, [], true, 0);
|
||||
};
|
||||
|
||||
renderRoot(value);
|
||||
|
||||
return { lines, truncated };
|
||||
}
|
||||
@@ -203,11 +232,18 @@ function renderOutputSection(
|
||||
const trimmedOutput = output.trim();
|
||||
if (!trimmedOutput) return lines;
|
||||
|
||||
lines.push(`${continuePrefix}${theme.fg("dim", "Output")}`);
|
||||
|
||||
if (trimmedOutput.startsWith("{") || trimmedOutput.startsWith("[")) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmedOutput);
|
||||
|
||||
// Collapsed: inline format like Vars
|
||||
if (!expanded) {
|
||||
lines.push(`${continuePrefix}${theme.fg("dim", formatOutputInline(parsed, theme))}`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Expanded: tree format
|
||||
lines.push(`${continuePrefix}${theme.fg("dim", "Output")}`);
|
||||
const tree = renderJsonTreeLines(parsed, theme, expanded ? 6 : 2, expanded ? 24 : 6);
|
||||
if (tree.lines.length > 0) {
|
||||
for (const line of tree.lines) {
|
||||
@@ -223,6 +259,8 @@ function renderOutputSection(
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(`${continuePrefix}${theme.fg("dim", "Output")}`);
|
||||
|
||||
const outputLines = output.split("\n").filter((line) => line.trim());
|
||||
const previewCount = expanded ? maxExpanded : maxCollapsed;
|
||||
for (const line of outputLines.slice(0, previewCount)) {
|
||||
@@ -238,6 +276,92 @@ function renderOutputSection(
|
||||
return lines;
|
||||
}
|
||||
|
||||
function formatVarsInline(vars: Record<string, string>, theme: Theme): string {
|
||||
const entries = Object.entries(vars);
|
||||
if (entries.length === 0) return "Vars: none";
|
||||
const pairs = entries.map(([key, value]) => `${key}=${truncate(value, 24, theme.format.ellipsis)}`);
|
||||
return `Vars: ${pairs.join(", ")}`;
|
||||
}
|
||||
|
||||
function formatScalarInline(value: unknown, maxLen: number, theme: Theme): string {
|
||||
if (value === null) return "null";
|
||||
if (value === undefined) return "undefined";
|
||||
if (typeof value === "boolean") return String(value);
|
||||
if (typeof value === "number") return String(value);
|
||||
if (typeof value === "string") return `"${truncate(value, maxLen, theme.format.ellipsis)}"`;
|
||||
if (Array.isArray(value)) return `[${value.length} items]`;
|
||||
if (typeof value === "object") {
|
||||
const keys = Object.keys(value);
|
||||
return `{${keys.length} keys}`;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function formatOutputInline(data: unknown, theme: Theme, maxWidth = 80): string {
|
||||
if (data === null || data === undefined) return "Output: none";
|
||||
|
||||
// For scalars, show directly
|
||||
if (typeof data !== "object") {
|
||||
return `Output: ${formatScalarInline(data, 60, theme)}`;
|
||||
}
|
||||
|
||||
// For arrays, show count and first element preview
|
||||
if (Array.isArray(data)) {
|
||||
if (data.length === 0) return "Output: []";
|
||||
const preview = formatScalarInline(data[0], 40, theme);
|
||||
return `Output: [${data.length} items] ${preview}${data.length > 1 ? theme.format.ellipsis : ""}`;
|
||||
}
|
||||
|
||||
// For objects, show key=value pairs inline
|
||||
const entries = Object.entries(data as Record<string, unknown>);
|
||||
if (entries.length === 0) return "Output: {}";
|
||||
|
||||
const pairs: string[] = [];
|
||||
let totalLen = "Output: ".length;
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
const valueStr = formatScalarInline(value, 24, theme);
|
||||
const pairStr = `${key}=${valueStr}`;
|
||||
const addLen = pairs.length > 0 ? pairStr.length + 2 : pairStr.length; // +2 for ", "
|
||||
|
||||
if (totalLen + addLen > maxWidth && pairs.length > 0) {
|
||||
pairs.push(theme.format.ellipsis);
|
||||
break;
|
||||
}
|
||||
|
||||
pairs.push(pairStr);
|
||||
totalLen += addLen;
|
||||
}
|
||||
|
||||
return `Output: ${pairs.join(", ")}`;
|
||||
}
|
||||
|
||||
function renderVarsSection(
|
||||
vars: Record<string, string> | undefined,
|
||||
continuePrefix: string,
|
||||
expanded: boolean,
|
||||
theme: Theme,
|
||||
): string[] {
|
||||
if (!vars || Object.keys(vars).length === 0) return [];
|
||||
const lines: string[] = [];
|
||||
|
||||
if (!expanded) {
|
||||
lines.push(`${continuePrefix}${theme.fg("dim", formatVarsInline(vars, theme))}`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
lines.push(`${continuePrefix}${theme.fg("dim", "Vars")}`);
|
||||
const tree = renderJsonTreeLines(vars, theme, 4, 16);
|
||||
for (const line of tree.lines) {
|
||||
lines.push(`${continuePrefix} ${line}`);
|
||||
}
|
||||
if (tree.truncated) {
|
||||
lines.push(`${continuePrefix} ${theme.fg("dim", theme.format.ellipsis)}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the tool call arguments.
|
||||
*/
|
||||
@@ -247,24 +371,29 @@ export function renderCall(args: TaskParams, theme: Theme): Component {
|
||||
theme.fg("dim", `${theme.format.bracketLeft}${args.agent}${theme.format.bracketRight}`),
|
||||
);
|
||||
|
||||
if (args.tasks.length === 1) {
|
||||
// Single task - show description preview
|
||||
const task = args.tasks[0];
|
||||
const summary = task.description.trim() || task.task;
|
||||
const taskPreview = truncate(summary, 50, theme.format.ellipsis);
|
||||
return new Text(`${label} ${agentTag} ${theme.fg("muted", taskPreview)}`, 0, 0);
|
||||
const lines: string[] = [];
|
||||
lines.push(`${label} ${agentTag}`);
|
||||
|
||||
const contextTemplate = args.context ?? "";
|
||||
const context = contextTemplate.trim();
|
||||
const hasContext = context.length > 0;
|
||||
const branch = theme.fg("dim", theme.tree.branch);
|
||||
const last = theme.fg("dim", theme.tree.last);
|
||||
const vertical = theme.fg("dim", theme.tree.vertical);
|
||||
|
||||
if (hasContext) {
|
||||
lines.push(` ${branch} ${theme.fg("dim", "Context")}`);
|
||||
for (const line of context.split("\n")) {
|
||||
const content = line ? theme.fg("muted", line) : "";
|
||||
lines.push(` ${vertical} ${content}`);
|
||||
}
|
||||
lines.push(` ${last} ${theme.fg("dim", "Tasks")}: ${theme.fg("muted", `${args.tasks.length} agents`)}`);
|
||||
return new Text(lines.join("\n"), 0, 0);
|
||||
}
|
||||
|
||||
// Multiple tasks - show count and descriptions
|
||||
const descriptions = args.tasks.map((t) => t.description.trim()).join(", ");
|
||||
return new Text(
|
||||
`${label} ${agentTag} ${args.tasks.length} agents: ${theme.fg(
|
||||
"muted",
|
||||
truncate(descriptions, 50, theme.format.ellipsis),
|
||||
)}`,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
lines.push(`${theme.fg("dim", "Tasks")}: ${theme.fg("muted", `${args.tasks.length} agents`)}`);
|
||||
|
||||
return new Text(lines.join("\n"), 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -278,8 +407,8 @@ function renderAgentProgress(
|
||||
spinnerFrame?: number,
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
const prefix = isLast ? theme.tree.last : theme.tree.branch;
|
||||
const continuePrefix = isLast ? " " : `${theme.tree.vertical} `;
|
||||
const prefix = isLast ? theme.fg("dim", theme.tree.last) : theme.fg("dim", theme.tree.branch);
|
||||
const continuePrefix = isLast ? " " : `${theme.fg("dim", theme.tree.vertical)} `;
|
||||
|
||||
const icon = getStatusIcon(progress.status, theme, spinnerFrame);
|
||||
const iconColor =
|
||||
@@ -316,6 +445,8 @@ function renderAgentProgress(
|
||||
|
||||
lines.push(statusLine);
|
||||
|
||||
lines.push(...renderVarsSection(progress.vars, continuePrefix, expanded, theme));
|
||||
|
||||
// Current tool (if running) or most recent completed tool
|
||||
if (progress.status === "running") {
|
||||
if (progress.currentTool) {
|
||||
@@ -498,8 +629,8 @@ function renderFindings(
|
||||
*/
|
||||
function renderAgentResult(result: SingleResult, isLast: boolean, expanded: boolean, theme: Theme): string[] {
|
||||
const lines: string[] = [];
|
||||
const prefix = isLast ? theme.tree.last : theme.tree.branch;
|
||||
const continuePrefix = isLast ? " " : `${theme.tree.vertical} `;
|
||||
const prefix = isLast ? theme.fg("dim", theme.tree.last) : theme.fg("dim", theme.tree.branch);
|
||||
const continuePrefix = isLast ? " " : `${theme.fg("dim", theme.tree.vertical)} `;
|
||||
|
||||
const aborted = result.aborted ?? false;
|
||||
const success = !aborted && result.exitCode === 0;
|
||||
@@ -525,6 +656,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
|
||||
}
|
||||
|
||||
lines.push(statusLine);
|
||||
lines.push(...renderVarsSection(result.vars, continuePrefix, expanded, theme));
|
||||
|
||||
// Check for review result (complete with review schema + report_finding)
|
||||
const completeData = result.extractedToolData?.complete as Array<{ data: unknown }> | undefined;
|
||||
@@ -634,7 +766,7 @@ export function renderResult(
|
||||
const abortedCount = details.results.filter((r) => r.aborted).length;
|
||||
const successCount = details.results.filter((r) => !r.aborted && r.exitCode === 0).length;
|
||||
const failCount = details.results.length - successCount - abortedCount;
|
||||
let summary = `\n${theme.fg("dim", "Total:")} `;
|
||||
let summary = `${theme.fg("dim", "Total:")} `;
|
||||
if (abortedCount > 0) {
|
||||
summary += theme.fg("error", `${abortedCount} aborted`);
|
||||
if (successCount > 0 || failCount > 0) summary += theme.sep.dot;
|
||||
@@ -656,7 +788,8 @@ export function renderResult(
|
||||
return new Text(theme.fg("dim", "No results"), 0, 0);
|
||||
}
|
||||
|
||||
return new Text(lines.join("\n"), 0, 0);
|
||||
const indented = lines.map((line) => (line.trim() ? ` ${line}` : ""));
|
||||
return new Text(indented.join("\n"), 0, 0);
|
||||
}
|
||||
|
||||
export const taskToolRenderer = {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export function renderTemplate(template: string, vars: Record<string, string>): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => vars[key] ?? `{{${key}}}`);
|
||||
}
|
||||
|
||||
export function extractPlaceholders(template: string): string[] {
|
||||
return [...template.matchAll(/\{\{(\w+)\}\}/g)].map((match) => match[1]);
|
||||
}
|
||||
|
||||
export function validateTaskTemplate(
|
||||
context: string | undefined,
|
||||
tasks: Array<{ id: string; vars: Record<string, string> }>,
|
||||
): string | null {
|
||||
const template = context ?? "";
|
||||
const placeholders = extractPlaceholders(template);
|
||||
|
||||
if (tasks.length > 1 && placeholders.length === 0) {
|
||||
return "Multi-task invocations require {{placeholders}} in context";
|
||||
}
|
||||
|
||||
if (placeholders.length > 0) {
|
||||
for (const task of tasks) {
|
||||
const missing = placeholders.filter((placeholder) => !(placeholder in task.vars));
|
||||
if (missing.length > 0) {
|
||||
return `Task "${task.id}" missing vars: ${missing.join(", ")}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.length > 1 && placeholders.length > 0) {
|
||||
const withoutPlaceholders = template.replace(/\{\{\w+\}\}/g, "").trim();
|
||||
if (withoutPlaceholders.length < 50) {
|
||||
return "Context must contain instructions (50+ chars) around {{placeholders}}";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -46,8 +46,10 @@ export const taskItemSchema = Type.Object({
|
||||
description: "Short task identifier for display (max 32 chars, CamelCase, e.g. 'SessionStore', 'WebFetchFix')",
|
||||
maxLength: 32,
|
||||
}),
|
||||
task: Type.String({ description: "Task description for the agent" }),
|
||||
description: Type.String({ description: "Short description for UI display" }),
|
||||
vars: Type.Record(Type.String(), Type.String(), {
|
||||
description: "Template variables to fill {{placeholders}} in context",
|
||||
}),
|
||||
});
|
||||
|
||||
export type TaskItem = Static<typeof taskItemSchema>;
|
||||
@@ -55,7 +57,7 @@ export type TaskItem = Static<typeof taskItemSchema>;
|
||||
/** Task tool parameters */
|
||||
export const taskSchema = Type.Object({
|
||||
agent: Type.String({ description: "Agent type to use for all tasks" }),
|
||||
context: Type.String({ description: "Shared context prepended to all task prompts" }),
|
||||
context: Type.String({ description: "Template with {{placeholders}} filled by task vars" }),
|
||||
model: Type.Optional(
|
||||
Type.String({
|
||||
description: "Model override for all tasks (fuzzy matching, e.g. 'sonnet', 'opus')",
|
||||
@@ -120,6 +122,7 @@ export interface AgentProgress {
|
||||
agentSource: AgentSource;
|
||||
status: "pending" | "running" | "completed" | "failed" | "aborted";
|
||||
task: string;
|
||||
vars?: Record<string, string>;
|
||||
description?: string;
|
||||
currentTool?: string;
|
||||
currentToolArgs?: string;
|
||||
@@ -141,6 +144,7 @@ export interface SingleResult {
|
||||
agent: string;
|
||||
agentSource: AgentSource;
|
||||
task: string;
|
||||
vars?: Record<string, string>;
|
||||
description?: string;
|
||||
exitCode: number;
|
||||
output: string;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type TruncationResult,
|
||||
truncateTail,
|
||||
} from "../../../core/tools/truncate";
|
||||
import { getSymbolTheme, theme } from "../theme/theme";
|
||||
import { getSymbolTheme, highlightCode, theme } from "../theme/theme";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
import { truncateToVisualLines } from "./visual-truncate";
|
||||
|
||||
@@ -29,6 +29,16 @@ export class PythonExecutionComponent extends Container {
|
||||
private contentContainer: Container;
|
||||
private excludeFromContext: boolean;
|
||||
|
||||
private formatHeader(colorKey: "dim" | "pythonMode"): Text {
|
||||
const prompt = theme.fg(colorKey, theme.bold(">>>"));
|
||||
const continuation = theme.fg(colorKey, " ");
|
||||
const codeLines = highlightCode(this.code, "python");
|
||||
const headerLines = codeLines.map((line, index) =>
|
||||
index === 0 ? `${prompt} ${line}` : `${continuation}${line}`,
|
||||
);
|
||||
return new Text(headerLines.join("\n"), 1, 0);
|
||||
}
|
||||
|
||||
constructor(code: string, ui: TUI, excludeFromContext = false) {
|
||||
super();
|
||||
this.code = code;
|
||||
@@ -42,9 +52,7 @@ export class PythonExecutionComponent extends Container {
|
||||
|
||||
this.contentContainer = new Container();
|
||||
this.addChild(this.contentContainer);
|
||||
|
||||
const header = new Text(theme.fg(colorKey, theme.bold(`>>> ${code}`)), 1, 0);
|
||||
this.contentContainer.addChild(header);
|
||||
this.contentContainer.addChild(this.formatHeader(colorKey));
|
||||
|
||||
this.loader = new Loader(
|
||||
ui,
|
||||
@@ -115,8 +123,7 @@ export class PythonExecutionComponent extends Container {
|
||||
this.contentContainer.clear();
|
||||
|
||||
const colorKey = this.excludeFromContext ? "dim" : "pythonMode";
|
||||
const header = new Text(theme.fg(colorKey, theme.bold(`>>> ${this.code}`)), 1, 0);
|
||||
this.contentContainer.addChild(header);
|
||||
this.contentContainer.addChild(this.formatHeader(colorKey));
|
||||
|
||||
if (availableLines.length > 0) {
|
||||
if (this.expanded) {
|
||||
|
||||
@@ -200,6 +200,13 @@ Do not open a file hoping to find something. Know where to look first.
|
||||
- When summarizing: plain text, file paths. Do not echo content back.
|
||||
{{/ifAny}}
|
||||
- Be brief. Show file paths clearly.
|
||||
{{#has tools "ask"}}
|
||||
|
||||
### Concurrent work
|
||||
Other agents or the user may be editing files concurrently.
|
||||
When file contents differ from expectations or edits fail: re-read and adapt.
|
||||
**Ask before** `git checkout/restore/reset`, bulk overwrites, or deleting code you didn't write.
|
||||
{{/has}}
|
||||
</practice>
|
||||
|
||||
<method>
|
||||
@@ -324,6 +331,7 @@ Keep going until finished.
|
||||
- Do not write code before stating assumptions.
|
||||
- Do not claim correctness you haven't verified.
|
||||
- Do not handle only the happy path.
|
||||
{{#has tools "ask"}}- If files differ from expectations, ask before discarding uncommitted work.{{/has}}
|
||||
|
||||
|
||||
Let edge cases surface before you handle them. Let the failure modes exist in your mind before you prevent them. Let the code be smaller than your first instinct.
|
||||
|
||||
@@ -49,12 +49,12 @@ Agents with "Output: structured" have a fixed schema enforced via frontmatter; y
|
||||
## Parameters
|
||||
|
||||
- `agent`: Agent type to use for all tasks
|
||||
- `context`: **Required context from conversation** - include ALL relevant info: requirements, schemas, decisions, constraints. Subagents cannot see chat history.
|
||||
- `model`: (optional) Model override (fuzzy matching, e.g., "sonnet", "opus")
|
||||
- `tasks`: Array of `{id, task, description}` - tasks to run in parallel (max {{MAX_PARALLEL_TASKS}}, {{MAX_CONCURRENCY}} concurrent)
|
||||
- `context`: Template with `{{placeholders}}` for multi-task. Each placeholder is filled from task vars.
|
||||
- `model`: (optional) Model override for all tasks (fuzzy matching, e.g., "sonnet", "opus")
|
||||
- `tasks`: Array of `{id, description, vars}` - tasks to run in parallel (max {{MAX_PARALLEL_TASKS}}, {{MAX_CONCURRENCY}} concurrent)
|
||||
- `id`: Short CamelCase identifier for display (max 20 chars, e.g., "SessionStore", "LspRefactor")
|
||||
- `task`: The task prompt for the agent
|
||||
- `description`: Short human-readable description of what the task does
|
||||
- `vars`: Object with keys matching `{{placeholders}}` in context
|
||||
- `output`: (optional) JTD schema for structured subagent output (used by the complete tool)
|
||||
|
||||
## Example
|
||||
@@ -65,7 +65,7 @@ assistant: I'll execute the refactoring plan.
|
||||
assistant: Uses the Task tool:
|
||||
{
|
||||
"agent": "task",
|
||||
"context": "Refactoring the auth module into separate concerns.\n\nPlan:\n1. AuthProvider - Extract React context and provider from src/auth/index.tsx\n2. AuthApi - Extract API calls to src/auth/api.ts, use existing fetchJson helper\n3. AuthTypes - Move types to src/auth/types.ts, re-export from index\n\nConstraints:\n- Preserve all existing exports from src/auth/index.tsx\n- Use project's fetchJson (src/utils/http.ts), don't use raw fetch\n- No new dependencies",
|
||||
"context": "Refactoring the auth module into separate concerns.\n\nPlan:\n1. AuthProvider - Extract React context and provider from src/auth/index.tsx\n2. AuthApi - Extract API calls to src/auth/api.ts, use existing fetchJson helper\n3. AuthTypes - Move types to types.ts, re-export from index\n\nConstraints:\n- Preserve all existing exports from src/auth/index.tsx\n- Use project's fetchJson (src/utils/http.ts), don't use raw fetch\n- No new dependencies\n\nTask: {{step}}\n\nFiles: {{files}}",
|
||||
"output": {
|
||||
"properties": {
|
||||
"summary": { "type": "string" },
|
||||
@@ -74,14 +74,9 @@ assistant: Uses the Task tool:
|
||||
}
|
||||
},
|
||||
"tasks": [
|
||||
{ "id": "AuthProvider", "task": "Execute step 1: Extract AuthProvider and AuthContext", "description": "Extract React context" },
|
||||
{ "id": "AuthApi", "task": "Execute step 2: Extract API calls to api.ts", "description": "Extract API layer" },
|
||||
{ "id": "AuthTypes", "task": "Execute step 3: Move types to types.ts", "description": "Extract types" }
|
||||
{ "id": "AuthProvider", "description": "Extract React context", "vars": { "step": "Execute step 1: Extract AuthProvider and AuthContext", "files": "src/auth/index.tsx" } },
|
||||
{ "id": "AuthApi", "description": "Extract API layer", "vars": { "step": "Execute step 2: Extract API calls to api.ts", "files": "src/auth/api.ts" } },
|
||||
{ "id": "AuthTypes", "description": "Extract types", "vars": { "step": "Execute step 3: Move types to types.ts", "files": "src/auth/types.ts" } }
|
||||
]
|
||||
}
|
||||
</example>
|
||||
|
||||
Key points:
|
||||
- **Plan in context**: The full plan is written once; each task references its step without repeating shared constraints
|
||||
- **Parallel execution**: 3 agents run concurrently, each owning one step - no duplicated work
|
||||
- **Structured output**: JTD schema ensures consistent reporting across all agents
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { pythonToolRenderer } from "../../src/core/tools/python";
|
||||
import { truncateTail } from "../../src/core/tools/truncate";
|
||||
import { getThemeByName } from "../../src/modes/interactive/theme/theme";
|
||||
|
||||
describe("pythonToolRenderer", () => {
|
||||
@@ -11,26 +10,37 @@ describe("pythonToolRenderer", () => {
|
||||
const uiTheme = theme!;
|
||||
|
||||
const fullOutput = ["line 1", "line 2", "line 3", "line 4"].join("\n");
|
||||
const truncation = truncateTail(fullOutput, { maxLines: 2, maxBytes: 128 });
|
||||
|
||||
const result = {
|
||||
content: [{ type: "text", text: truncation.content }],
|
||||
content: [{ type: "text", text: fullOutput }],
|
||||
details: {
|
||||
truncation,
|
||||
fullOutput,
|
||||
cells: [
|
||||
{
|
||||
index: 0,
|
||||
title: "run",
|
||||
code: "print('hello')",
|
||||
output: fullOutput,
|
||||
status: "complete" as const,
|
||||
durationMs: 12,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const collapsed = pythonToolRenderer.renderResult(result, { expanded: false, isPartial: false }, uiTheme);
|
||||
const collapsed = pythonToolRenderer.renderResult(
|
||||
result,
|
||||
{ expanded: false, isPartial: false, renderContext: { previewLines: 2 } },
|
||||
uiTheme,
|
||||
);
|
||||
const collapsedLines = stripAnsi(collapsed.render(80).join("\n"));
|
||||
expect(collapsedLines).toContain("line 4");
|
||||
expect(collapsedLines).not.toContain("line 1");
|
||||
expect(collapsedLines).toContain("Truncated:");
|
||||
expect(collapsedLines).toContain("more lines");
|
||||
|
||||
const expanded = pythonToolRenderer.renderResult(result, { expanded: true, isPartial: false }, uiTheme);
|
||||
const expandedLines = stripAnsi(expanded.render(80).join("\n"));
|
||||
expect(expandedLines).toContain("line 1");
|
||||
expect(expandedLines).toContain("line 4");
|
||||
expect(expandedLines).not.toContain("Truncated:");
|
||||
expect(expandedLines).not.toContain("more lines");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { extractPlaceholders, renderTemplate, validateTaskTemplate } from "../../src/core/tools/task/template";
|
||||
|
||||
// ============================================================================
|
||||
// renderTemplate
|
||||
// ============================================================================
|
||||
|
||||
describe("task template rendering", () => {
|
||||
test("extracts placeholders", () => {
|
||||
expect(extractPlaceholders("Do {{thing}} then {{thing}} with {{target}}")).toEqual(["thing", "thing", "target"]);
|
||||
});
|
||||
|
||||
test("renders single placeholder", () => {
|
||||
expect(renderTemplate("Hello {{name}}", { name: "Ada" })).toBe("Hello Ada");
|
||||
});
|
||||
|
||||
test("renders multiple placeholders", () => {
|
||||
expect(renderTemplate("{{greet}} {{name}}", { greet: "Hi", name: "Ada" })).toBe("Hi Ada");
|
||||
});
|
||||
|
||||
test("leaves unknown placeholders intact", () => {
|
||||
expect(renderTemplate("Hello {{name}} {{missing}}", { name: "Ada" })).toBe("Hello Ada {{missing}}");
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// validateTaskTemplate
|
||||
// ============================================================================
|
||||
|
||||
describe("task template validation", () => {
|
||||
test("requires placeholders for multi-task", () => {
|
||||
const error = validateTaskTemplate("Just instructions", [
|
||||
{ id: "One", vars: {} },
|
||||
{ id: "Two", vars: {} },
|
||||
]);
|
||||
expect(error).toBe("Multi-task invocations require {{placeholders}} in context");
|
||||
});
|
||||
|
||||
test("errors on missing vars", () => {
|
||||
const error = validateTaskTemplate("Do {{thing}}", [{ id: "Only", vars: {} }]);
|
||||
expect(error).toBe('Task "Only" missing vars: thing');
|
||||
});
|
||||
|
||||
test("errors on empty context around placeholders", () => {
|
||||
const error = validateTaskTemplate("{{task}}", [
|
||||
{ id: "A", vars: { task: "One" } },
|
||||
{ id: "B", vars: { task: "Two" } },
|
||||
]);
|
||||
expect(error).toBe("Context must contain instructions (50+ chars) around {{placeholders}}");
|
||||
});
|
||||
|
||||
test("allows single-task without placeholders", () => {
|
||||
const error = validateTaskTemplate("Just do the thing", [{ id: "Only", vars: {} }]);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user