From eaf9944e3ec64bd20d42595da00d74b73478fd62 Mon Sep 17 00:00:00 2001 From: stepandel Date: Fri, 13 Mar 2026 18:48:19 -0700 Subject: [PATCH 01/11] Add parallel task execution for agent and delegate tools Extract shared runAgentTask helper to eliminate ~40 lines of duplicated session logic. Change agent/delegate tool schemas from single `task` to `tasks` array, running them concurrently via Promise.allSettled with p-limit(3). Move p-limit to production dependencies. Co-Authored-By: Claude Opus 4.6 --- bun.lock | 2 +- package.json | 2 +- src/tools/agent-session-runner.ts | 89 +++++++++++++++++++++ src/tools/agent-tools.ts | 120 ++++++++-------------------- src/tools/delegate.ts | 127 +++++++++--------------------- src/tools/workflow/tool.ts | 2 +- 6 files changed, 164 insertions(+), 178 deletions(-) create mode 100644 src/tools/agent-session-runner.ts diff --git a/bun.lock b/bun.lock index ae30564..58fced4 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "jose": "^6.2.1", "langsmith": "^0.5.9", "linkedom": "^0.18.12", + "p-limit": "^7.3.0", "yaml": "^2.7.0", }, "devDependencies": { @@ -26,7 +27,6 @@ "@types/yargs": "^17.0.35", "csv-parse": "^6.1.0", "handlebars": "^4.7.8", - "p-limit": "^7.3.0", "pino": "^10.3.1", "pino-pretty": "^13.1.3", "strip-ansi": "^7.2.0", diff --git a/package.json b/package.json index 0eca653..a513793 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "jose": "^6.2.1", "langsmith": "^0.5.9", "linkedom": "^0.18.12", + "p-limit": "^7.3.0", "yaml": "^2.7.0" }, "devDependencies": { @@ -33,7 +34,6 @@ "@types/yargs": "^17.0.35", "csv-parse": "^6.1.0", "handlebars": "^4.7.8", - "p-limit": "^7.3.0", "pino": "^10.3.1", "pino-pretty": "^13.1.3", "strip-ansi": "^7.2.0", diff --git a/src/tools/agent-session-runner.ts b/src/tools/agent-session-runner.ts new file mode 100644 index 0000000..d0a631a --- /dev/null +++ b/src/tools/agent-session-runner.ts @@ -0,0 +1,89 @@ +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; +import { + createAgentSession, + SessionManager, + SettingsManager, +} from "@mariozechner/pi-coding-agent"; +import { createBuiltinTool } from "./builtin-tools"; +import { getModel } from "@mariozechner/pi-ai"; +import type { AgentDefinition } from "./delegate"; + +/** + * Shared helper that creates an in-memory agent session, prompts it with a + * task, extracts the output, and disposes the session. + */ +export async function runAgentTask( + cwd: string, + agentDef: AgentDefinition, + parentCustomTools: ToolDefinition[], + task: string, + maxOutputChars: number, +): Promise { + // Resolve model + let model; + if (agentDef.model) { + try { + const [provider, modelId] = agentDef.model.split("/", 2); + model = (getModel as any)(provider, modelId); + } catch { + model = getModel("anthropic", "claude-sonnet-4-6"); + } + } else { + model = getModel("anthropic", "claude-sonnet-4-6"); + } + + // Resolve tools + const toolNames = agentDef.tools + ? agentDef.tools.split(",").map((t) => t.trim()).filter(Boolean) + : ["read", "search", "find", "ls"]; + + const resolvedBuiltinTools: AgentTool[] = []; + const resolvedCustomTools: ToolDefinition[] = []; + const fileHashes = new Map(); + + for (const name of toolNames) { + const builtin = createBuiltinTool(name, cwd, fileHashes); + if (builtin) { + resolvedBuiltinTools.push(builtin); + } else { + const custom = parentCustomTools.find( + (t) => t.name === name && t.name !== "delegate" + ); + if (custom) { + resolvedCustomTools.push(custom); + } + } + } + + const thinkingLevel = (agentDef.thinking || "low") as any; + + let sub: Awaited>["session"] | undefined; + try { + const result = await createAgentSession({ + cwd, + model, + thinkingLevel, + tools: resolvedBuiltinTools.length > 0 ? resolvedBuiltinTools : undefined, + customTools: resolvedCustomTools.length > 0 ? resolvedCustomTools : undefined, + sessionManager: SessionManager.inMemory(), + settingsManager: SettingsManager.inMemory(), + }); + sub = result.session; + + const fullPrompt = agentDef.body.trim() + ? `${agentDef.body.trim()}\n\n---\n\nTask: ${task}` + : task; + + await sub.prompt(fullPrompt); + + let output = sub.getLastAssistantText() ?? "(no output)"; + if (output.length > maxOutputChars) { + output = output.slice(0, maxOutputChars) + "\n\n(output truncated)"; + } + + return output; + } finally { + sub?.dispose(); + } +} diff --git a/src/tools/agent-tools.ts b/src/tools/agent-tools.ts index b5f1fac..bab2b21 100644 --- a/src/tools/agent-tools.ts +++ b/src/tools/agent-tools.ts @@ -1,15 +1,12 @@ import { Type } from "@sinclair/typebox"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { AgentTool } from "@mariozechner/pi-agent-core"; import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; -import { - createAgentSession, - SessionManager, - SettingsManager, -} from "@mariozechner/pi-coding-agent"; -import { createBuiltinTool } from "./builtin-tools"; -import { getModel } from "@mariozechner/pi-ai"; import { loadAgentDefinitions, type AgentDefinition } from "./delegate"; +import { runAgentTask } from "./agent-session-runner"; +import pLimit from "p-limit"; + +const MAX_TOTAL_OUTPUT = 50_000; +const CONCURRENCY_LIMIT = 3; /** * Load the main agent definition body (name: "main") from .pi/agents/. @@ -26,10 +23,13 @@ function createAgentTool( parentCustomTools: ToolDefinition[], ): ToolDefinition { const taskSchema = Type.Object({ - task: Type.String({ - description: - "Complete task description — include all necessary context, the agent has no access to your conversation", - }), + tasks: Type.Array( + Type.String({ + description: + "Complete task description — include all necessary context, the agent has no access to your conversation", + }), + { minItems: 1 }, + ), }); return { @@ -39,87 +39,35 @@ function createAgentTool( promptSnippet: agentDef.description, parameters: taskSchema, async execute(_toolCallId, args: any) { - const task = args.task as string; - - // Resolve model - let model; - if (agentDef.model) { - try { - const [provider, modelId] = agentDef.model.split("/", 2); - model = (getModel as any)(provider, modelId); - } catch { - model = getModel("anthropic", "claude-sonnet-4-6"); - } - } else { - model = getModel("anthropic", "claude-sonnet-4-6"); - } + const tasks = args.tasks as string[]; + const perTaskBudget = Math.floor(MAX_TOTAL_OUTPUT / tasks.length); + const limit = pLimit(CONCURRENCY_LIMIT); - // Resolve tools - const toolNames = agentDef.tools - ? agentDef.tools.split(",").map((t) => t.trim()).filter(Boolean) - : ["read", "search", "find", "ls"]; + const results = await Promise.allSettled( + tasks.map((task, i) => + limit(() => runAgentTask(cwd, agentDef, parentCustomTools, task, perTaskBudget)) + ), + ); - const resolvedBuiltinTools: AgentTool[] = []; - const resolvedCustomTools: ToolDefinition[] = []; - const fileHashes = new Map(); + const sections: string[] = []; + const statusList: { task: number; status: "fulfilled" | "rejected"; error?: string }[] = []; - for (const name of toolNames) { - const builtin = createBuiltinTool(name, cwd, fileHashes); - if (builtin) { - resolvedBuiltinTools.push(builtin); + for (let i = 0; i < results.length; i++) { + const r = results[i]; + if (r.status === "fulfilled") { + sections.push(`## Task ${i + 1}\n\n${r.value}`); + statusList.push({ task: i + 1, status: "fulfilled" }); } else { - const custom = parentCustomTools.find( - (t) => t.name === name && t.name !== "delegate" - ); - if (custom) { - resolvedCustomTools.push(custom); - } + const errMsg = r.reason?.message ?? String(r.reason); + sections.push(`## Task ${i + 1}\n\nAgent "${agentDef.name}" failed: ${errMsg}`); + statusList.push({ task: i + 1, status: "rejected", error: errMsg }); } } - const thinkingLevel = (agentDef.thinking || "low") as any; - - let sub: Awaited>["session"] | undefined; - try { - const result = await createAgentSession({ - cwd, - model, - thinkingLevel, - tools: resolvedBuiltinTools.length > 0 ? resolvedBuiltinTools : undefined, - customTools: resolvedCustomTools.length > 0 ? resolvedCustomTools : undefined, - sessionManager: SessionManager.inMemory(), - settingsManager: SettingsManager.inMemory(), - }); - sub = result.session; - - const fullPrompt = agentDef.body.trim() - ? `${agentDef.body.trim()}\n\n---\n\nTask: ${task}` - : task; - - await sub.prompt(fullPrompt); - - let output = sub.getLastAssistantText() ?? "(no output)"; - if (output.length > 50_000) { - output = output.slice(0, 50_000) + "\n\n(output truncated)"; - } - - return { - content: [{ type: "text", text: output }], - details: { agent: agentDef.name }, - } as AgentToolResult; - } catch (err: any) { - return { - content: [ - { - type: "text", - text: `Agent "${agentDef.name}" failed: ${err.message}`, - }, - ], - details: { error: err.message }, - } as AgentToolResult; - } finally { - sub?.dispose(); - } + return { + content: [{ type: "text", text: sections.join("\n\n---\n\n") }], + details: { agent: agentDef.name, taskResults: statusList }, + } as AgentToolResult; }, }; } diff --git a/src/tools/delegate.ts b/src/tools/delegate.ts index 6b6b87a..6523bef 100644 --- a/src/tools/delegate.ts +++ b/src/tools/delegate.ts @@ -1,24 +1,24 @@ import { Type } from "@sinclair/typebox"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; -import { - createAgentSession, - parseFrontmatter, - SessionManager, - SettingsManager, -} from "@mariozechner/pi-coding-agent"; -import { createBuiltinTool } from "./builtin-tools"; -import { getModel } from "@mariozechner/pi-ai"; -import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { parseFrontmatter } from "@mariozechner/pi-coding-agent"; +import { runAgentTask } from "./agent-session-runner"; +import pLimit from "p-limit"; import fs from "node:fs"; import { join } from "node:path"; +const MAX_TOTAL_OUTPUT = 50_000; +const CONCURRENCY_LIMIT = 3; + const delegateSchema = Type.Object({ agent: Type.String({ description: "Name of the agent to delegate to" }), - task: Type.String({ - description: - "Complete task description — include all necessary context, the agent has no access to your conversation", - }), + tasks: Type.Array( + Type.String({ + description: + "Complete task description — include all necessary context, the agent has no access to your conversation", + }), + { minItems: 1 }, + ), }); export interface AgentDefinition { @@ -75,7 +75,9 @@ export function createDelegateTool( ); } promptGuidelines.push( - "The sub-agent sees ONLY the task you provide — include all necessary context. Use for self-contained tasks." + "The sub-agent sees ONLY the task you provide — include all necessary context. " + + "You can pass multiple independent tasks in the `tasks` array and they will run in parallel. " + + "Each task must be fully self-contained." ); return { @@ -88,7 +90,7 @@ export function createDelegateTool( parameters: delegateSchema, async execute(_toolCallId, args: any) { const agentName = args.agent as string; - const task = args.task as string; + const tasks = args.tasks as string[]; const agentDef = agentDefs.get(agentName); if (!agentDef) { @@ -104,87 +106,34 @@ export function createDelegateTool( } as AgentToolResult; } - // Resolve model - let model; - if (agentDef.model) { - try { - const [provider, modelId] = agentDef.model.split("/", 2); - model = (getModel as any)(provider, modelId); - } catch { - model = getModel("anthropic", "claude-sonnet-4-6"); - } - } else { - model = getModel("anthropic", "claude-sonnet-4-6"); - } + const perTaskBudget = Math.floor(MAX_TOTAL_OUTPUT / tasks.length); + const limit = pLimit(CONCURRENCY_LIMIT); - // Resolve tools - const toolNames = agentDef.tools - ? agentDef.tools.split(",").map((t) => t.trim()).filter(Boolean) - : ["read", "search", "find", "ls"]; + const results = await Promise.allSettled( + tasks.map((task) => + limit(() => runAgentTask(cwd, agentDef, parentCustomTools, task, perTaskBudget)) + ), + ); - const resolvedBuiltinTools: AgentTool[] = []; - const resolvedCustomTools: ToolDefinition[] = []; - const fileHashes = new Map(); + const sections: string[] = []; + const statusList: { task: number; status: "fulfilled" | "rejected"; error?: string }[] = []; - for (const name of toolNames) { - const builtin = createBuiltinTool(name, cwd, fileHashes); - if (builtin) { - resolvedBuiltinTools.push(builtin); + for (let i = 0; i < results.length; i++) { + const r = results[i]; + if (r.status === "fulfilled") { + sections.push(`## Task ${i + 1}\n\n${r.value}`); + statusList.push({ task: i + 1, status: "fulfilled" }); } else { - // Look in parent custom tools, but exclude delegate to prevent recursion - const custom = parentCustomTools.find( - (t) => t.name === name && t.name !== "delegate" - ); - if (custom) { - resolvedCustomTools.push(custom); - } + const errMsg = r.reason?.message ?? String(r.reason); + sections.push(`## Task ${i + 1}\n\nDelegation to "${agentName}" failed: ${errMsg}`); + statusList.push({ task: i + 1, status: "rejected", error: errMsg }); } } - const thinkingLevel = (agentDef.thinking || "low") as any; - - let sub: Awaited>["session"] | undefined; - try { - const result = await createAgentSession({ - cwd, - model, - thinkingLevel, - tools: resolvedBuiltinTools.length > 0 ? resolvedBuiltinTools : undefined, - customTools: resolvedCustomTools.length > 0 ? resolvedCustomTools : undefined, - sessionManager: SessionManager.inMemory(), - settingsManager: SettingsManager.inMemory(), - }); - sub = result.session; - - // Build prompt with agent system prompt body - const fullPrompt = agentDef.body.trim() - ? `${agentDef.body.trim()}\n\n---\n\nTask: ${task}` - : task; - - await sub.prompt(fullPrompt); - - let output = sub.getLastAssistantText() ?? "(no output)"; - if (output.length > 50_000) { - output = output.slice(0, 50_000) + "\n\n(output truncated)"; - } - - return { - content: [{ type: "text", text: output }], - details: { agent: agentName }, - } as AgentToolResult; - } catch (err: any) { - return { - content: [ - { - type: "text", - text: `Delegation to "${agentName}" failed: ${err.message}`, - }, - ], - details: { error: err.message }, - } as AgentToolResult; - } finally { - sub?.dispose(); - } + return { + content: [{ type: "text", text: sections.join("\n\n---\n\n") }], + details: { agent: agentName, taskResults: statusList }, + } as AgentToolResult; }, }; } diff --git a/src/tools/workflow/tool.ts b/src/tools/workflow/tool.ts index 09215ff..ea60000 100644 --- a/src/tools/workflow/tool.ts +++ b/src/tools/workflow/tool.ts @@ -60,7 +60,7 @@ export function createWorkflowTool( // Delegation bridging — reuse createDelegateTool const delegateTool = createDelegateTool(cwd, parentCustomTools); const delegate = async (agent: string, task: string): Promise => { - const result = await delegateTool.execute("wf", { agent, task }, undefined, undefined, undefined as any); + const result = await delegateTool.execute("wf", { agent, tasks: [task] }, undefined, undefined, undefined as any); const first = result.content?.[0]; return (first && "text" in first ? first.text : undefined) ?? "(no output)"; }; From 787140ea21f8e9580c2815d2912c03d13fb24ca3 Mon Sep 17 00:00:00 2001 From: stepandel Date: Fri, 13 Mar 2026 19:07:04 -0700 Subject: [PATCH 02/11] Add post-completion validation agent Spawn a fresh validator agent after the main agent finishes to review modified files, run build/tests, and fix issues. Enabled via VALIDATION_ENABLED=true env var. Integrated into headless, Linear, and GitHub handlers. Co-Authored-By: Claude Opus 4.6 --- src/github/handler.ts | 14 ++++ src/headless.ts | 13 ++++ src/linear/handler.ts | 15 ++++ src/validation.ts | 177 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 219 insertions(+) create mode 100644 src/validation.ts diff --git a/src/github/handler.ts b/src/github/handler.ts index 62150fb..0d705e9 100644 --- a/src/github/handler.ts +++ b/src/github/handler.ts @@ -1,6 +1,7 @@ import type { AgentSessionEvent } from "@mariozechner/pi-coding-agent"; import { formatToolArgs } from "../shared"; import { createSession, type AgentSession, type SessionDeps } from "../session"; +import { isValidationEnabled, getModifiedFiles, validateWork } from "../validation"; import { addIssueCommentReaction, addPullReviewCommentReaction, @@ -230,6 +231,19 @@ export function initGitHubHandler(deps: GitHubHandlerDeps): GitHubHandler { await session.prompt(prompt); let output = session.getLastAssistantText() ?? "Done."; + + // Validation pass + if (isValidationEnabled()) { + const modifiedFiles = getModifiedFiles(cwd); + if (modifiedFiles.length > 0) { + log(` ${tag} Running validation on ${modifiedFiles.length} modified files...`); + const validation = await validateWork(cwd, prompt, modifiedFiles, output, { + onLog: (msg) => log(` ${tag} ${msg}`), + }); + output += `\n\n---\n\n${validation.report}`; + } + } + if (output.length > 65000) { output = output.substring(0, 64950) + "\n\n_(truncated)_"; } diff --git a/src/headless.ts b/src/headless.ts index 9dc1a0f..bf0b88d 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -5,6 +5,7 @@ import { createSession } from "./session"; import { formatToolArgs } from "./shared"; import { isLangSmithEnabled, createLangSmithTracer } from "./langsmith"; import { createDebugRequestsCapture } from "./debug-requests"; +import { isValidationEnabled, getModifiedFiles, validateWork } from "./validation"; // --------------------------------------------------------------------------- // Arg parsing @@ -125,6 +126,18 @@ async function main() { await session.prompt(instruction); const output = session.getLastAssistantText() ?? ""; + // Validation pass + if (isValidationEnabled()) { + const modifiedFiles = getModifiedFiles(cwd); + if (modifiedFiles.length > 0) { + console.error(`[headless] Running validation on ${modifiedFiles.length} modified files...`); + const validation = await validateWork(cwd, instruction, modifiedFiles, output, { + onLog: (msg) => console.error(msg), + }); + console.error(`[headless] Validation report:\n${validation.report}`); + } + } + // Write output to stdout process.stdout.write(output); diff --git a/src/linear/handler.ts b/src/linear/handler.ts index 1cf6c1f..bfeb7cc 100644 --- a/src/linear/handler.ts +++ b/src/linear/handler.ts @@ -3,6 +3,7 @@ import { activity } from "./activity"; import type { AgentSessionEventPayload } from "./types"; import { formatToolArgs } from "../shared"; import { createSession, type AgentSession, type SessionDeps } from "../session"; +import { isValidationEnabled, getModifiedFiles, validateWork } from "../validation"; // --------------------------------------------------------------------------- // Types @@ -227,6 +228,20 @@ export function initLinearHandler(deps: LinearHandlerDeps): LinearHandler { await session.prompt(prompt); let output = session.getLastAssistantText() ?? "Done."; + + // Validation pass + if (isValidationEnabled()) { + const modifiedFiles = getModifiedFiles(cwd); + if (modifiedFiles.length > 0) { + log(` ${tag} Running validation on ${modifiedFiles.length} modified files...`); + activity.action(linearSessionId, "Validating work..."); + const validation = await validateWork(cwd, prompt, modifiedFiles, output, { + onLog: (msg) => log(` ${tag} ${msg}`), + }); + output += `\n\n---\n\n${validation.report}`; + } + } + if (output.length > 35000) { output = output.substring(0, 34950) + "\n\n_(truncated)_"; } diff --git a/src/validation.ts b/src/validation.ts new file mode 100644 index 0000000..afb30e8 --- /dev/null +++ b/src/validation.ts @@ -0,0 +1,177 @@ +import { execSync } from "node:child_process"; +import type { AgentSessionEvent } from "@mariozechner/pi-coding-agent"; +import { + createAgentSession, + SessionManager, + SettingsManager, +} from "@mariozechner/pi-coding-agent"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { createBuiltinTool } from "./tools/builtin-tools"; +import { getModel } from "@mariozechner/pi-ai"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ValidationResult { + report: string; +} + +export interface ValidationOptions { + onLog?: (msg: string) => void; +} + +// --------------------------------------------------------------------------- +// Validator system prompt +// --------------------------------------------------------------------------- + +const VALIDATOR_PROMPT = `You are a code validation agent. Your job is to verify that an implementation task was completed correctly and fix any issues you find. + +## Your Process + +1. **Review** — Read the modified files and understand what was changed +2. **Build** — Run the project's build/typecheck command to check for compilation errors +3. **Test** — Run the existing test suite to check for regressions +4. **Assess** — Evaluate whether the changes correctly address the original task +5. **Fix** — If you find issues (build errors, test failures, bugs, missing edge cases), fix them +6. **Report** — Summarize your findings + +## Guidelines + +- Focus on correctness: does the code compile, pass tests, and fulfill the task? +- Fix issues directly rather than just reporting them +- If the build or tests fail, fix the failures +- Be concise in your report +- Don't refactor or make stylistic changes unless they fix actual bugs + +## Output Format + +End your response with a validation summary: + +### Validation Summary +- **Status**: PASS or FAIL +- **Checks**: What you verified +- **Fixes**: Any fixes you made (or "None") +- **Issues**: Any remaining concerns (or "None")`; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +export function isValidationEnabled(): boolean { + return process.env.VALIDATION_ENABLED === "true"; +} + +/** + * Collect all modified, staged, and untracked files in the working tree. + */ +export function getModifiedFiles(cwd: string): string[] { + const results = new Set(); + + const run = (cmd: string) => { + try { + const out = execSync(cmd, { + cwd, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + if (out) out.split("\n").forEach((f) => results.add(f)); + } catch { + // git command may fail if not in a repo — ignore + } + }; + + run("git diff --name-only"); + run("git diff --cached --name-only"); + run("git ls-files --others --exclude-standard"); + + return [...results]; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +/** + * Spawn a fresh validator agent that reviews the main agent's work, + * runs checks, and fixes any issues it finds. + */ +export async function validateWork( + cwd: string, + originalTask: string, + modifiedFiles: string[], + agentSummary: string, + opts?: ValidationOptions, +): Promise { + const log = opts?.onLog ?? (() => {}); + + if (modifiedFiles.length === 0) { + return { report: "No files were modified — nothing to validate." }; + } + + // Build the task prompt for the validator + const fileList = modifiedFiles.map((f) => `- ${f}`).join("\n"); + const taskPrompt = [ + VALIDATOR_PROMPT, + "", + "---", + "", + "## Original Task", + originalTask, + "", + "## Modified Files", + fileList, + "", + "## Agent Summary", + agentSummary, + "", + "---", + "", + "Review the modified files, run the build and tests, verify the implementation is correct, and fix any issues you find.", + ].join("\n"); + + // Spawn a fresh validator session with full tool access + const model = getModel("anthropic", "claude-sonnet-4-6"); + const fileHashes = new Map(); + const tools = ["read", "bash", "edit", "write", "search", "find", "ls"] + .map((name) => createBuiltinTool(name, cwd, fileHashes)) + .filter((t): t is AgentTool => t !== undefined); + + log("[validator] Spawning validation agent..."); + + const { session } = await createAgentSession({ + cwd, + model, + thinkingLevel: "medium" as any, + tools, + sessionManager: SessionManager.inMemory(), + settingsManager: SettingsManager.inMemory(), + }); + + // Log tool activity + session.subscribe((ev: AgentSessionEvent) => { + switch (ev.type) { + case "tool_execution_start": + log(`[validator] ⚙ ${ev.toolName}`); + break; + case "tool_execution_end": { + const icon = ev.isError ? "✗" : "✓"; + log(`[validator] ${icon} ${ev.toolName} done`); + break; + } + } + }); + + try { + await session.prompt(taskPrompt); + const report = + session.getLastAssistantText() ?? "(no output from validator)"; + log("[validator] Validation complete"); + return { report }; + } catch (err: any) { + log(`[validator] Validation failed: ${err.message}`); + return { report: `Validation agent failed: ${err.message}` }; + } finally { + session.dispose(); + } +} From b0dafd452acc001b5b8ceaf6c63a4c03576fa35b Mon Sep 17 00:00:00 2001 From: stepandel Date: Fri, 13 Mar 2026 19:11:46 -0700 Subject: [PATCH 03/11] Move validator to .pi/agents/ and reuse runAgentTask - Add .pi/agents/validator.md with system prompt, model, and tool config - Rewrite validation.ts to load the agent definition and delegate to runAgentTask instead of manually spawning sessions - Filter validator out of delegation tools (like main) Co-Authored-By: Claude Opus 4.6 --- .pi/agents/validator.md | 36 +++++++++++++++ src/tools/agent-tools.ts | 2 +- src/validation.ts | 97 ++++++---------------------------------- 3 files changed, 51 insertions(+), 84 deletions(-) create mode 100644 .pi/agents/validator.md diff --git a/.pi/agents/validator.md b/.pi/agents/validator.md new file mode 100644 index 0000000..2df5869 --- /dev/null +++ b/.pi/agents/validator.md @@ -0,0 +1,36 @@ +--- +name: validator +description: Post-completion validation agent that reviews work, runs checks, and fixes issues +tools: read, bash, edit, write, search, find, ls +model: anthropic/claude-sonnet-4-6 +thinking: medium +--- + +You are a code validation agent. Your job is to verify that an implementation task was completed correctly and fix any issues you find. + +## Your Process + +1. **Review** — Read the modified files and understand what was changed +2. **Build** — Run the project's build/typecheck command to check for compilation errors +3. **Test** — Run the existing test suite to check for regressions +4. **Assess** — Evaluate whether the changes correctly address the original task +5. **Fix** — If you find issues (build errors, test failures, bugs, missing edge cases), fix them +6. **Report** — Summarize your findings + +## Guidelines + +- Focus on correctness: does the code compile, pass tests, and fulfill the task? +- Fix issues directly rather than just reporting them +- If the build or tests fail, fix the failures +- Be concise in your report +- Don't refactor or make stylistic changes unless they fix actual bugs + +## Output Format + +End your response with a validation summary: + +### Validation Summary +- **Status**: PASS or FAIL +- **Checks**: What you verified +- **Fixes**: Any fixes you made (or "None") +- **Issues**: Any remaining concerns (or "None") diff --git a/src/tools/agent-tools.ts b/src/tools/agent-tools.ts index bab2b21..b4364e8 100644 --- a/src/tools/agent-tools.ts +++ b/src/tools/agent-tools.ts @@ -78,6 +78,6 @@ export function createAgentTools( ): ToolDefinition[] { const agentDefs = loadAgentDefinitions(cwd); return Array.from(agentDefs.values()) - .filter((def) => def.name !== "main") + .filter((def) => def.name !== "main" && def.name !== "validator") .map((def) => createAgentTool(cwd, def, parentCustomTools)); } diff --git a/src/validation.ts b/src/validation.ts index afb30e8..1240ea3 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -1,13 +1,6 @@ import { execSync } from "node:child_process"; -import type { AgentSessionEvent } from "@mariozechner/pi-coding-agent"; -import { - createAgentSession, - SessionManager, - SettingsManager, -} from "@mariozechner/pi-coding-agent"; -import type { AgentTool } from "@mariozechner/pi-agent-core"; -import { createBuiltinTool } from "./tools/builtin-tools"; -import { getModel } from "@mariozechner/pi-ai"; +import { loadAgentDefinitions } from "./tools/delegate"; +import { runAgentTask } from "./tools/agent-session-runner"; // --------------------------------------------------------------------------- // Types @@ -21,39 +14,6 @@ export interface ValidationOptions { onLog?: (msg: string) => void; } -// --------------------------------------------------------------------------- -// Validator system prompt -// --------------------------------------------------------------------------- - -const VALIDATOR_PROMPT = `You are a code validation agent. Your job is to verify that an implementation task was completed correctly and fix any issues you find. - -## Your Process - -1. **Review** — Read the modified files and understand what was changed -2. **Build** — Run the project's build/typecheck command to check for compilation errors -3. **Test** — Run the existing test suite to check for regressions -4. **Assess** — Evaluate whether the changes correctly address the original task -5. **Fix** — If you find issues (build errors, test failures, bugs, missing edge cases), fix them -6. **Report** — Summarize your findings - -## Guidelines - -- Focus on correctness: does the code compile, pass tests, and fulfill the task? -- Fix issues directly rather than just reporting them -- If the build or tests fail, fix the failures -- Be concise in your report -- Don't refactor or make stylistic changes unless they fix actual bugs - -## Output Format - -End your response with a validation summary: - -### Validation Summary -- **Status**: PASS or FAIL -- **Checks**: What you verified -- **Fixes**: Any fixes you made (or "None") -- **Issues**: Any remaining concerns (or "None")`; - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -95,6 +55,9 @@ export function getModifiedFiles(cwd: string): string[] { /** * Spawn a fresh validator agent that reviews the main agent's work, * runs checks, and fixes any issues it finds. + * + * Loads the "validator" agent definition from .pi/agents/validator.md + * and uses the shared runAgentTask helper. */ export async function validateWork( cwd: string, @@ -109,13 +72,15 @@ export async function validateWork( return { report: "No files were modified — nothing to validate." }; } - // Build the task prompt for the validator + const agentDefs = loadAgentDefinitions(cwd); + const validatorDef = agentDefs.get("validator"); + if (!validatorDef) { + return { report: "Validator agent not found in .pi/agents/ — skipping validation." }; + } + + // Build the task prompt with the three inputs the validator needs const fileList = modifiedFiles.map((f) => `- ${f}`).join("\n"); - const taskPrompt = [ - VALIDATOR_PROMPT, - "", - "---", - "", + const task = [ "## Original Task", originalTask, "", @@ -130,48 +95,14 @@ export async function validateWork( "Review the modified files, run the build and tests, verify the implementation is correct, and fix any issues you find.", ].join("\n"); - // Spawn a fresh validator session with full tool access - const model = getModel("anthropic", "claude-sonnet-4-6"); - const fileHashes = new Map(); - const tools = ["read", "bash", "edit", "write", "search", "find", "ls"] - .map((name) => createBuiltinTool(name, cwd, fileHashes)) - .filter((t): t is AgentTool => t !== undefined); - log("[validator] Spawning validation agent..."); - const { session } = await createAgentSession({ - cwd, - model, - thinkingLevel: "medium" as any, - tools, - sessionManager: SessionManager.inMemory(), - settingsManager: SettingsManager.inMemory(), - }); - - // Log tool activity - session.subscribe((ev: AgentSessionEvent) => { - switch (ev.type) { - case "tool_execution_start": - log(`[validator] ⚙ ${ev.toolName}`); - break; - case "tool_execution_end": { - const icon = ev.isError ? "✗" : "✓"; - log(`[validator] ${icon} ${ev.toolName} done`); - break; - } - } - }); - try { - await session.prompt(taskPrompt); - const report = - session.getLastAssistantText() ?? "(no output from validator)"; + const report = await runAgentTask(cwd, validatorDef, [], task, 50_000); log("[validator] Validation complete"); return { report }; } catch (err: any) { log(`[validator] Validation failed: ${err.message}`); return { report: `Validation agent failed: ${err.message}` }; - } finally { - session.dispose(); } } From 8758783d8793df94ec396f47ad09d382f383b0a9 Mon Sep 17 00:00:00 2001 From: stepandel Date: Fri, 13 Mar 2026 19:14:08 -0700 Subject: [PATCH 04/11] Pass diffs instead of file names to validator agent Replace getModifiedFiles() with getWorkingTreeDiff() which returns combined staged + unstaged diffs plus untracked file names. The validator now receives the actual diff in a code block, giving it full context of what changed without needing to read every file. Co-Authored-By: Claude Opus 4.6 --- .pi/agents/validator.md | 2 +- src/github/handler.ts | 10 ++++---- src/headless.ts | 10 ++++---- src/linear/handler.ts | 10 ++++---- src/validation.ts | 53 +++++++++++++++++++++++++---------------- 5 files changed, 49 insertions(+), 36 deletions(-) diff --git a/.pi/agents/validator.md b/.pi/agents/validator.md index 2df5869..07536fa 100644 --- a/.pi/agents/validator.md +++ b/.pi/agents/validator.md @@ -10,7 +10,7 @@ You are a code validation agent. Your job is to verify that an implementation ta ## Your Process -1. **Review** — Read the modified files and understand what was changed +1. **Review** — Examine the provided diff to understand what was changed 2. **Build** — Run the project's build/typecheck command to check for compilation errors 3. **Test** — Run the existing test suite to check for regressions 4. **Assess** — Evaluate whether the changes correctly address the original task diff --git a/src/github/handler.ts b/src/github/handler.ts index 0d705e9..f778c9a 100644 --- a/src/github/handler.ts +++ b/src/github/handler.ts @@ -1,7 +1,7 @@ import type { AgentSessionEvent } from "@mariozechner/pi-coding-agent"; import { formatToolArgs } from "../shared"; import { createSession, type AgentSession, type SessionDeps } from "../session"; -import { isValidationEnabled, getModifiedFiles, validateWork } from "../validation"; +import { isValidationEnabled, getWorkingTreeDiff, validateWork } from "../validation"; import { addIssueCommentReaction, addPullReviewCommentReaction, @@ -234,10 +234,10 @@ export function initGitHubHandler(deps: GitHubHandlerDeps): GitHubHandler { // Validation pass if (isValidationEnabled()) { - const modifiedFiles = getModifiedFiles(cwd); - if (modifiedFiles.length > 0) { - log(` ${tag} Running validation on ${modifiedFiles.length} modified files...`); - const validation = await validateWork(cwd, prompt, modifiedFiles, output, { + const diff = getWorkingTreeDiff(cwd); + if (diff) { + log(` ${tag} Running validation...`); + const validation = await validateWork(cwd, prompt, diff, output, { onLog: (msg) => log(` ${tag} ${msg}`), }); output += `\n\n---\n\n${validation.report}`; diff --git a/src/headless.ts b/src/headless.ts index bf0b88d..b24a09c 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -5,7 +5,7 @@ import { createSession } from "./session"; import { formatToolArgs } from "./shared"; import { isLangSmithEnabled, createLangSmithTracer } from "./langsmith"; import { createDebugRequestsCapture } from "./debug-requests"; -import { isValidationEnabled, getModifiedFiles, validateWork } from "./validation"; +import { isValidationEnabled, getWorkingTreeDiff, validateWork } from "./validation"; // --------------------------------------------------------------------------- // Arg parsing @@ -128,10 +128,10 @@ async function main() { // Validation pass if (isValidationEnabled()) { - const modifiedFiles = getModifiedFiles(cwd); - if (modifiedFiles.length > 0) { - console.error(`[headless] Running validation on ${modifiedFiles.length} modified files...`); - const validation = await validateWork(cwd, instruction, modifiedFiles, output, { + const diff = getWorkingTreeDiff(cwd); + if (diff) { + console.error("[headless] Running validation..."); + const validation = await validateWork(cwd, instruction, diff, output, { onLog: (msg) => console.error(msg), }); console.error(`[headless] Validation report:\n${validation.report}`); diff --git a/src/linear/handler.ts b/src/linear/handler.ts index bfeb7cc..67f5649 100644 --- a/src/linear/handler.ts +++ b/src/linear/handler.ts @@ -3,7 +3,7 @@ import { activity } from "./activity"; import type { AgentSessionEventPayload } from "./types"; import { formatToolArgs } from "../shared"; import { createSession, type AgentSession, type SessionDeps } from "../session"; -import { isValidationEnabled, getModifiedFiles, validateWork } from "../validation"; +import { isValidationEnabled, getWorkingTreeDiff, validateWork } from "../validation"; // --------------------------------------------------------------------------- // Types @@ -231,11 +231,11 @@ export function initLinearHandler(deps: LinearHandlerDeps): LinearHandler { // Validation pass if (isValidationEnabled()) { - const modifiedFiles = getModifiedFiles(cwd); - if (modifiedFiles.length > 0) { - log(` ${tag} Running validation on ${modifiedFiles.length} modified files...`); + const diff = getWorkingTreeDiff(cwd); + if (diff) { + log(` ${tag} Running validation...`); activity.action(linearSessionId, "Validating work..."); - const validation = await validateWork(cwd, prompt, modifiedFiles, output, { + const validation = await validateWork(cwd, prompt, diff, output, { onLog: (msg) => log(` ${tag} ${msg}`), }); output += `\n\n---\n\n${validation.report}`; diff --git a/src/validation.ts b/src/validation.ts index 1240ea3..aa1f251 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -23,29 +23,42 @@ export function isValidationEnabled(): boolean { } /** - * Collect all modified, staged, and untracked files in the working tree. + * Return a combined diff of all working-tree changes (staged + unstaged) + * plus a list of any new untracked files. Returns empty string when clean. */ -export function getModifiedFiles(cwd: string): string[] { - const results = new Set(); - - const run = (cmd: string) => { +export function getWorkingTreeDiff(cwd: string): string { + const run = (cmd: string): string => { try { - const out = execSync(cmd, { + return execSync(cmd, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], }).trim(); - if (out) out.split("\n").forEach((f) => results.add(f)); } catch { - // git command may fail if not in a repo — ignore + return ""; } }; - run("git diff --name-only"); - run("git diff --cached --name-only"); - run("git ls-files --others --exclude-standard"); + const parts: string[] = []; + + const staged = run("git diff --cached"); + if (staged) parts.push(staged); + + const unstaged = run("git diff"); + if (unstaged) parts.push(unstaged); + + const untracked = run("git ls-files --others --exclude-standard"); + if (untracked) { + parts.push( + "New untracked files:\n" + + untracked + .split("\n") + .map((f) => ` ${f}`) + .join("\n"), + ); + } - return [...results]; + return parts.join("\n"); } // --------------------------------------------------------------------------- @@ -62,14 +75,14 @@ export function getModifiedFiles(cwd: string): string[] { export async function validateWork( cwd: string, originalTask: string, - modifiedFiles: string[], + diff: string, agentSummary: string, opts?: ValidationOptions, ): Promise { const log = opts?.onLog ?? (() => {}); - if (modifiedFiles.length === 0) { - return { report: "No files were modified — nothing to validate." }; + if (!diff) { + return { report: "No changes detected — nothing to validate." }; } const agentDefs = loadAgentDefinitions(cwd); @@ -78,21 +91,21 @@ export async function validateWork( return { report: "Validator agent not found in .pi/agents/ — skipping validation." }; } - // Build the task prompt with the three inputs the validator needs - const fileList = modifiedFiles.map((f) => `- ${f}`).join("\n"); const task = [ "## Original Task", originalTask, "", - "## Modified Files", - fileList, + "## Diff", + "```diff", + diff, + "```", "", "## Agent Summary", agentSummary, "", "---", "", - "Review the modified files, run the build and tests, verify the implementation is correct, and fix any issues you find.", + "Review the diff, run the build and tests, verify the implementation is correct, and fix any issues you find.", ].join("\n"); log("[validator] Spawning validation agent..."); From 3c8f75b279a808db6512731f8e3cf99e03164952 Mon Sep 17 00:00:00 2001 From: stepandel Date: Fri, 13 Mar 2026 19:20:30 -0700 Subject: [PATCH 05/11] Fix cron executor delegate bridge to use new tasks array schema Co-Authored-By: Claude Opus 4.6 --- src/tools/cron/executor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/cron/executor.ts b/src/tools/cron/executor.ts index d604388..8575227 100644 --- a/src/tools/cron/executor.ts +++ b/src/tools/cron/executor.ts @@ -80,7 +80,7 @@ function buildEngineContext( const delegate = async (agent: string, task: string): Promise => { const result = await delegateTool.execute( "cron", - { agent, task }, + { agent, tasks: [task] }, undefined, undefined, undefined as any, From 2750b4029110d39974ccad426865c294fdc01e31 Mon Sep 17 00:00:00 2001 From: stepandel Date: Fri, 13 Mar 2026 19:21:44 -0700 Subject: [PATCH 06/11] Re-prompt main agent when validation fails Parse PASS/FAIL status from validator report. On failure, feed the validator's report back to the main agent session (which is still alive) so it can address the issues with full conversation context. Co-Authored-By: Claude Opus 4.6 --- src/github/handler.ts | 9 ++++++++- src/headless.ts | 10 +++++++++- src/linear/handler.ts | 10 +++++++++- src/validation.ts | 13 ++++++++----- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/github/handler.ts b/src/github/handler.ts index f778c9a..fe5fafb 100644 --- a/src/github/handler.ts +++ b/src/github/handler.ts @@ -240,7 +240,14 @@ export function initGitHubHandler(deps: GitHubHandlerDeps): GitHubHandler { const validation = await validateWork(cwd, prompt, diff, output, { onLog: (msg) => log(` ${tag} ${msg}`), }); - output += `\n\n---\n\n${validation.report}`; + + if (!validation.passed) { + log(` ${tag} Validation failed — re-prompting main agent...`); + await session.prompt( + `A validator agent reviewed your work and found issues:\n\n${validation.report}\n\nPlease address the issues identified above.`, + ); + output = session.getLastAssistantText() ?? output; + } } } diff --git a/src/headless.ts b/src/headless.ts index b24a09c..238ecfa 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -124,7 +124,7 @@ async function main() { // Run the agent await session.prompt(instruction); - const output = session.getLastAssistantText() ?? ""; + let output = session.getLastAssistantText() ?? ""; // Validation pass if (isValidationEnabled()) { @@ -135,6 +135,14 @@ async function main() { onLog: (msg) => console.error(msg), }); console.error(`[headless] Validation report:\n${validation.report}`); + + if (!validation.passed) { + console.error("[headless] Validation failed — re-prompting main agent..."); + await session.prompt( + `A validator agent reviewed your work and found issues:\n\n${validation.report}\n\nPlease address the issues identified above.`, + ); + output = session.getLastAssistantText() ?? output; + } } } diff --git a/src/linear/handler.ts b/src/linear/handler.ts index 67f5649..8036fb0 100644 --- a/src/linear/handler.ts +++ b/src/linear/handler.ts @@ -238,7 +238,15 @@ export function initLinearHandler(deps: LinearHandlerDeps): LinearHandler { const validation = await validateWork(cwd, prompt, diff, output, { onLog: (msg) => log(` ${tag} ${msg}`), }); - output += `\n\n---\n\n${validation.report}`; + + if (!validation.passed) { + log(` ${tag} Validation failed — re-prompting main agent...`); + activity.action(linearSessionId, "Addressing validation feedback..."); + await session.prompt( + `A validator agent reviewed your work and found issues:\n\n${validation.report}\n\nPlease address the issues identified above.`, + ); + output = session.getLastAssistantText() ?? output; + } } } diff --git a/src/validation.ts b/src/validation.ts index aa1f251..f6243bf 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -7,6 +7,8 @@ import { runAgentTask } from "./tools/agent-session-runner"; // --------------------------------------------------------------------------- export interface ValidationResult { + /** Whether the validator judged the work as passing. */ + passed: boolean; report: string; } @@ -82,13 +84,13 @@ export async function validateWork( const log = opts?.onLog ?? (() => {}); if (!diff) { - return { report: "No changes detected — nothing to validate." }; + return { passed: true, report: "No changes detected — nothing to validate." }; } const agentDefs = loadAgentDefinitions(cwd); const validatorDef = agentDefs.get("validator"); if (!validatorDef) { - return { report: "Validator agent not found in .pi/agents/ — skipping validation." }; + return { passed: true, report: "Validator agent not found in .pi/agents/ — skipping validation." }; } const task = [ @@ -112,10 +114,11 @@ export async function validateWork( try { const report = await runAgentTask(cwd, validatorDef, [], task, 50_000); - log("[validator] Validation complete"); - return { report }; + const passed = !(/\*\*Status\*\*:\s*FAIL/i.test(report)); + log(`[validator] Validation complete — ${passed ? "PASS" : "FAIL"}`); + return { passed, report }; } catch (err: any) { log(`[validator] Validation failed: ${err.message}`); - return { report: `Validation agent failed: ${err.message}` }; + return { passed: false, report: `Validation agent failed: ${err.message}` }; } } From 6179b84c070cdadec2b36f02d83474f68791d47a Mon Sep 17 00:00:00 2001 From: stepandel Date: Fri, 13 Mar 2026 20:33:55 -0700 Subject: [PATCH 07/11] Add promptGuidelines to agent tools for better discoverability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `guidelines` frontmatter field to AgentDefinition, surfaced as `promptGuidelines` on the tool so the model knows WHEN to use each agent - Fix HTML comments before frontmatter delimiters that silently broke parseFrontmatter — descriptions, model, tools, and thinking config were all being ignored (falling back to defaults) - Add clear usage guidelines to architect and scout agent definitions - Strengthen delegation guidance in main agent system prompt Co-Authored-By: Claude Opus 4.6 --- .pi/agents/architect.md | 8 +++----- .pi/agents/main.md | 13 +++++-------- .pi/agents/scout.md | 8 +++----- src/tools/agent-tools.ts | 10 ++++++++++ src/tools/delegate.ts | 2 ++ 5 files changed, 23 insertions(+), 18 deletions(-) diff --git a/.pi/agents/architect.md b/.pi/agents/architect.md index b96fd30..1329abf 100644 --- a/.pi/agents/architect.md +++ b/.pi/agents/architect.md @@ -1,16 +1,14 @@ - --- name: architect description: Strategic planning agent that analyzes codebases and creates implementation plans without making changes tools: read, search, find, ls, bash, scout model: anthropic/claude-opus-4-6 thinking: medium +guidelines: Use architect for non-trivial tasks BEFORE writing code — designing new features, planning multi-file refactors, evaluating trade-offs, or creating implementation roadmaps. Architect uses Opus for deep reasoning and produces structured plans with checkboxes. Use it whenever the user asks to plan, design, or evaluate an approach — or when a task spans 3+ files and you need a strategy before diving in. --- + + You are Architect, a strategic planning and analysis assistant. You analyze requirements, create structured plans, and provide recommendations without making any changes to the codebase. Bash is for read-only commands only: `git log`, `git show`, `git blame`, `git diff`, `wc`, `file`. Do NOT modify files, run builds, or install anything. diff --git a/.pi/agents/main.md b/.pi/agents/main.md index f519d56..4efdf0f 100644 --- a/.pi/agents/main.md +++ b/.pi/agents/main.md @@ -1,8 +1,3 @@ - --- name: main description: Hands-on implementation agent for software development tasks @@ -10,6 +5,8 @@ model: anthropic/claude-opus-4-6 thinking: high --- + + You are an expert software engineering assistant. Your knowledge spans multiple programming languages, frameworks, design patterns, and best practices. ## Core Principles @@ -85,9 +82,9 @@ assistant: I've found some existing telemetry code. I'll start designing the met - **write_todos** — Track tasks, break down work, and mark progress - **update_memory** — Persist project context and preferences across sessions -### Delegation -- **scout** — Delegate deep architectural analysis, tracing complex flows, or understanding system design decisions -- **architect** — Delegate strategic planning, creating implementation roadmaps, and risk assessment +### Delegation — use these proactively, not just when asked +- **scout** — Send research tasks before touching unfamiliar code. Use for: tracing data flows, finding all callers of a function, auditing patterns, mapping dependencies. Fast (Sonnet) and read-only — cheaper than reading files yourself. Send multiple independent questions in parallel. +- **architect** — Send planning tasks before non-trivial implementations. Use for: designing features, planning multi-file refactors, evaluating approaches, risk assessment. Uses Opus for deep reasoning. Always architect before implementing when a task spans 3+ files. ### Automation - **run_workflow** — Execute predefined workflows from .pi/workflows/ diff --git a/.pi/agents/scout.md b/.pi/agents/scout.md index 17f2e22..01a465f 100644 --- a/.pi/agents/scout.md +++ b/.pi/agents/scout.md @@ -1,16 +1,14 @@ - --- name: scout description: Research-only codebase explorer for architecture, patterns, data flow, and dependency analysis tools: read, search, find, ls, bash model: anthropic/claude-sonnet-4-6 thinking: medium +guidelines: Use scout BEFORE implementation when you need to understand unfamiliar code — trace how a feature works end-to-end, find all callers/consumers of a function, understand data flow across modules, audit patterns, or map dependencies. Scout is fast (Sonnet) and read-only — prefer it over manually reading many files yourself. Send multiple tasks in parallel when investigating independent questions. --- + + You are Scout, an expert codebase research and exploration assistant. Your function is to explore, analyze, and provide insights about codebases without making any modifications. Bash is for read-only commands only: `git log`, `git show`, `git blame`, `git diff`, `wc`, `file`. Do NOT modify files, run builds, or install anything. diff --git a/src/tools/agent-tools.ts b/src/tools/agent-tools.ts index b4364e8..1152e8f 100644 --- a/src/tools/agent-tools.ts +++ b/src/tools/agent-tools.ts @@ -32,11 +32,21 @@ function createAgentTool( ), }); + const promptGuidelines: string[] = []; + if (agentDef.guidelines) { + promptGuidelines.push(agentDef.guidelines); + } + promptGuidelines.push( + `The ${agentDef.name} agent sees ONLY the task you provide — include all necessary context. ` + + "You can pass multiple independent tasks in the `tasks` array and they will run in parallel." + ); + return { name: agentDef.name, label: agentDef.name.charAt(0).toUpperCase() + agentDef.name.slice(1), description: agentDef.description, promptSnippet: agentDef.description, + promptGuidelines, parameters: taskSchema, async execute(_toolCallId, args: any) { const tasks = args.tasks as string[]; diff --git a/src/tools/delegate.ts b/src/tools/delegate.ts index 6523bef..8cde755 100644 --- a/src/tools/delegate.ts +++ b/src/tools/delegate.ts @@ -27,6 +27,7 @@ export interface AgentDefinition { model?: string; tools?: string; thinking?: string; + guidelines?: string; body: string; } @@ -47,6 +48,7 @@ export function loadAgentDefinitions(cwd: string): Map model: fm.model as string | undefined, tools: fm.tools as string | undefined, thinking: fm.thinking as string | undefined, + guidelines: fm.guidelines as string | undefined, body: parsed.body, }); } catch { From 80014fa9f6479d19d1bfa4a969cf70ccac094b89 Mon Sep 17 00:00:00 2001 From: stepandel Date: Fri, 13 Mar 2026 20:38:41 -0700 Subject: [PATCH 08/11] =?UTF-8?q?Update=20agent=20tool=20lists=20=E2=80=94?= =?UTF-8?q?=20add=20web=5Fsearch,=20web=5Ffetch=20to=20research=20agents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scout and architect were missing web_search and web_fetch, limiting them to codebase-only analysis. Also add find/ls to main agent's tool docs and mention web tools in scout/architect body text. Co-Authored-By: Claude Opus 4.6 --- .pi/agents/architect.md | 4 +++- .pi/agents/main.md | 2 ++ .pi/agents/scout.md | 4 +++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.pi/agents/architect.md b/.pi/agents/architect.md index 1329abf..b138fa9 100644 --- a/.pi/agents/architect.md +++ b/.pi/agents/architect.md @@ -1,7 +1,7 @@ --- name: architect description: Strategic planning agent that analyzes codebases and creates implementation plans without making changes -tools: read, search, find, ls, bash, scout +tools: read, search, find, ls, bash, web_search, web_fetch, scout model: anthropic/claude-opus-4-6 thinking: medium guidelines: Use architect for non-trivial tasks BEFORE writing code — designing new features, planning multi-file refactors, evaluating trade-offs, or creating implementation roadmaps. Architect uses Opus for deep reasoning and produces structured plans with checkboxes. Use it whenever the user asks to plan, design, or evaluate an approach — or when a task spans 3+ files and you need a strategy before diving in. @@ -13,6 +13,8 @@ You are Architect, a strategic planning and analysis assistant. You analyze requ Bash is for read-only commands only: `git log`, `git show`, `git blame`, `git diff`, `wc`, `file`. Do NOT modify files, run builds, or install anything. +Use `web_search` and `web_fetch` to research external documentation, API references, or best practices when planning requires knowledge beyond the codebase. + ## Core Principles 1. **Solution-Oriented** — Focus on effective strategic solutions diff --git a/.pi/agents/main.md b/.pi/agents/main.md index 4efdf0f..df6db59 100644 --- a/.pi/agents/main.md +++ b/.pi/agents/main.md @@ -72,6 +72,8 @@ assistant: I've found some existing telemetry code. I'll start designing the met - **edit** — Modify existing files. Always prefer edit over write for any change to an existing file, even if changing many lines. Use multiple edit calls if needed. - **write** — Create new files only. Do NOT use write to modify existing files — use edit instead. - **search** — Search file contents for regex patterns, OR discover/list files by glob and type. Prefer this over `grep`, `rg`, `find`, or `ls` in bash. +- **find** — Discover files by glob pattern +- **ls** — List directory contents ### Execution - **bash** — Execute shell commands, run builds, tests, and git operations. Use GitHub CLI for GitHub operations. Do NOT use bash for operations that have dedicated tools: use `read` instead of `cat`, `search` instead of `grep`/`find`/`ls`. diff --git a/.pi/agents/scout.md b/.pi/agents/scout.md index 01a465f..6d46c49 100644 --- a/.pi/agents/scout.md +++ b/.pi/agents/scout.md @@ -1,7 +1,7 @@ --- name: scout description: Research-only codebase explorer for architecture, patterns, data flow, and dependency analysis -tools: read, search, find, ls, bash +tools: read, search, find, ls, bash, web_search, web_fetch model: anthropic/claude-sonnet-4-6 thinking: medium guidelines: Use scout BEFORE implementation when you need to understand unfamiliar code — trace how a feature works end-to-end, find all callers/consumers of a function, understand data flow across modules, audit patterns, or map dependencies. Scout is fast (Sonnet) and read-only — prefer it over manually reading many files yourself. Send multiple tasks in parallel when investigating independent questions. @@ -13,6 +13,8 @@ You are Scout, an expert codebase research and exploration assistant. Your funct Bash is for read-only commands only: `git log`, `git show`, `git blame`, `git diff`, `wc`, `file`. Do NOT modify files, run builds, or install anything. +Use `web_search` and `web_fetch` to look up external documentation, API references, or library usage patterns when codebase analysis alone is insufficient. + ## Core Principles 1. **Research-Oriented** — Understand and explain code structures, patterns, and relationships From c0ab6469f4fb61b2973b5b800578a6d313d5d622 Mon Sep 17 00:00:00 2001 From: stepandel Date: Fri, 13 Mar 2026 20:39:59 -0700 Subject: [PATCH 09/11] =?UTF-8?q?Remove=20find/ls=20from=20agent=20tool=20?= =?UTF-8?q?lists=20=E2=80=94=20search=20covers=20file=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The search tool handles both content search and file discovery (glob, type filters), making find and ls redundant. Also update the default fallback tool list in agent-session-runner. Co-Authored-By: Claude Opus 4.6 --- .pi/agents/architect.md | 2 +- .pi/agents/main.md | 2 -- .pi/agents/scout.md | 2 +- .pi/agents/validator.md | 2 +- src/tools/agent-session-runner.ts | 2 +- 5 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.pi/agents/architect.md b/.pi/agents/architect.md index b138fa9..2f8ffd0 100644 --- a/.pi/agents/architect.md +++ b/.pi/agents/architect.md @@ -1,7 +1,7 @@ --- name: architect description: Strategic planning agent that analyzes codebases and creates implementation plans without making changes -tools: read, search, find, ls, bash, web_search, web_fetch, scout +tools: read, search, bash, web_search, web_fetch, scout model: anthropic/claude-opus-4-6 thinking: medium guidelines: Use architect for non-trivial tasks BEFORE writing code — designing new features, planning multi-file refactors, evaluating trade-offs, or creating implementation roadmaps. Architect uses Opus for deep reasoning and produces structured plans with checkboxes. Use it whenever the user asks to plan, design, or evaluate an approach — or when a task spans 3+ files and you need a strategy before diving in. diff --git a/.pi/agents/main.md b/.pi/agents/main.md index df6db59..4efdf0f 100644 --- a/.pi/agents/main.md +++ b/.pi/agents/main.md @@ -72,8 +72,6 @@ assistant: I've found some existing telemetry code. I'll start designing the met - **edit** — Modify existing files. Always prefer edit over write for any change to an existing file, even if changing many lines. Use multiple edit calls if needed. - **write** — Create new files only. Do NOT use write to modify existing files — use edit instead. - **search** — Search file contents for regex patterns, OR discover/list files by glob and type. Prefer this over `grep`, `rg`, `find`, or `ls` in bash. -- **find** — Discover files by glob pattern -- **ls** — List directory contents ### Execution - **bash** — Execute shell commands, run builds, tests, and git operations. Use GitHub CLI for GitHub operations. Do NOT use bash for operations that have dedicated tools: use `read` instead of `cat`, `search` instead of `grep`/`find`/`ls`. diff --git a/.pi/agents/scout.md b/.pi/agents/scout.md index 6d46c49..e5be623 100644 --- a/.pi/agents/scout.md +++ b/.pi/agents/scout.md @@ -1,7 +1,7 @@ --- name: scout description: Research-only codebase explorer for architecture, patterns, data flow, and dependency analysis -tools: read, search, find, ls, bash, web_search, web_fetch +tools: read, search, bash, web_search, web_fetch model: anthropic/claude-sonnet-4-6 thinking: medium guidelines: Use scout BEFORE implementation when you need to understand unfamiliar code — trace how a feature works end-to-end, find all callers/consumers of a function, understand data flow across modules, audit patterns, or map dependencies. Scout is fast (Sonnet) and read-only — prefer it over manually reading many files yourself. Send multiple tasks in parallel when investigating independent questions. diff --git a/.pi/agents/validator.md b/.pi/agents/validator.md index 07536fa..8862016 100644 --- a/.pi/agents/validator.md +++ b/.pi/agents/validator.md @@ -1,7 +1,7 @@ --- name: validator description: Post-completion validation agent that reviews work, runs checks, and fixes issues -tools: read, bash, edit, write, search, find, ls +tools: read, bash, edit, write, search model: anthropic/claude-sonnet-4-6 thinking: medium --- diff --git a/src/tools/agent-session-runner.ts b/src/tools/agent-session-runner.ts index d0a631a..d118ade 100644 --- a/src/tools/agent-session-runner.ts +++ b/src/tools/agent-session-runner.ts @@ -36,7 +36,7 @@ export async function runAgentTask( // Resolve tools const toolNames = agentDef.tools ? agentDef.tools.split(",").map((t) => t.trim()).filter(Boolean) - : ["read", "search", "find", "ls"]; + : ["read", "search"]; const resolvedBuiltinTools: AgentTool[] = []; const resolvedCustomTools: ToolDefinition[] = []; From 6458fe95526a3ade67db93d3905939807e86f74f Mon Sep 17 00:00:00 2001 From: Anton Date: Sat, 14 Mar 2026 19:37:31 +0000 Subject: [PATCH 10/11] feat: Add undo tool and JSON/YAML validation for edit/write tools (ARM-20) Undo: - New undo.ts tool: restores a file to its pre-edit state (one-level deep) - Shared previousVersions Map wired through builtin-tools.ts - edit.ts and write.ts snapshot file contents before every write - Undo clears its history after use; updates fileHashes for stale detection Validation: - New file-validation.ts: validates JSON (JSON.parse) and YAML (yaml.parse) - edit.ts and write.ts call validateFileContent() after every write - Non-blocking: appends a warning to success message if parse fails - No validation for non-JSON/YAML files (returns null) Tests: - undo.test.ts: restore, history clearing, hash updates, diff output - file-validation.test.ts: JSON/YAML valid/invalid, non-validatable files - edit.test.ts: previousVersions snapshots, validation warnings --- src/tools/builtin-tools.ts | 35 +++-- src/tools/edit.test.ts | 224 ++++++++++++++++++++++++++++++ src/tools/edit.ts | 18 ++- src/tools/file-validation.test.ts | 92 ++++++++++++ src/tools/file-validation.ts | 60 ++++++++ src/tools/undo.test.ts | 208 +++++++++++++++++++++++++++ src/tools/undo.ts | 140 +++++++++++++++++++ src/tools/write.ts | 25 +++- 8 files changed, 785 insertions(+), 17 deletions(-) create mode 100644 src/tools/edit.test.ts create mode 100644 src/tools/file-validation.test.ts create mode 100644 src/tools/file-validation.ts create mode 100644 src/tools/undo.test.ts create mode 100644 src/tools/undo.ts diff --git a/src/tools/builtin-tools.ts b/src/tools/builtin-tools.ts index bdecc67..fd98acc 100644 --- a/src/tools/builtin-tools.ts +++ b/src/tools/builtin-tools.ts @@ -7,26 +7,40 @@ import { import { createReadTool } from "./read"; import { createEditTool } from "./edit"; import { createWriteTool } from "./write"; +import { createUndoTool } from "./undo"; import { createSearchTool } from "./search"; /** - * Create a single built-in tool by name, wiring edit+write to the same - * fileHashes map so stale-edit detection works across both tools. + * Shared state maps for built-in file tools. + * - fileHashes: stale-edit detection across read/edit/write + * - previousVersions: one-level undo history for edit/write/undo + */ +export interface BuiltinToolMaps { + fileHashes: Map; + previousVersions: Map; +} + +/** + * Create a single built-in tool by name, wiring edit+write+undo to the same + * fileHashes and previousVersions maps. */ export function createBuiltinTool( name: string, cwd: string, - fileHashes: Map, + maps: BuiltinToolMaps, ): AgentTool | undefined { + const { fileHashes, previousVersions } = maps; switch (name) { case "read": return createReadTool(cwd, { fileHashes }); case "bash": return createBashTool(cwd); case "edit": - return createEditTool(cwd, { fileHashes }); + return createEditTool(cwd, { fileHashes, previousVersions }); case "write": - return createWriteTool(cwd, { fileHashes }); + return createWriteTool(cwd, { fileHashes, previousVersions }); + case "undo": + return createUndoTool(cwd, { fileHashes, previousVersions }); case "search": return createSearchTool(cwd); case "grep": // backward compat alias @@ -41,15 +55,18 @@ export function createBuiltinTool( } /** - * Create all built-in tools with a shared fileHashes map. + * Create all built-in tools with shared fileHashes and previousVersions maps. */ export function createAllBuiltinTools( cwd: string, ): Map> { - const fileHashes = new Map(); + const maps: BuiltinToolMaps = { + fileHashes: new Map(), + previousVersions: new Map(), + }; const tools = new Map>(); - for (const name of ["read", "bash", "edit", "write", "search", "find", "ls"]) { - const tool = createBuiltinTool(name, cwd, fileHashes); + for (const name of ["read", "bash", "edit", "write", "undo", "search", "find", "ls"]) { + const tool = createBuiltinTool(name, cwd, maps); if (tool) tools.set(name, tool); } return tools; diff --git a/src/tools/edit.test.ts b/src/tools/edit.test.ts new file mode 100644 index 0000000..609a6b0 --- /dev/null +++ b/src/tools/edit.test.ts @@ -0,0 +1,224 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { createEditTool, hashContent } from "./edit"; +import type { EditOperations, EditToolOptions } from "./edit"; + +// --------------------------------------------------------------------------- +// In-memory filesystem for testing +// --------------------------------------------------------------------------- + +function createMemFs(files: Record = {}) { + const store = new Map(Object.entries(files)); + + const ops: EditOperations = { + readFile: async (path) => { + const content = store.get(path); + if (content === undefined) throw new Error(`ENOENT: ${path}`); + return Buffer.from(content, "utf-8"); + }, + writeFile: async (path, content) => { + store.set(path, content); + }, + access: async (path) => { + if (!store.has(path)) throw new Error(`ENOENT: ${path}`); + }, + }; + + return { store, ops }; +} + +// --------------------------------------------------------------------------- +// Undo tests +// --------------------------------------------------------------------------- + +describe("edit tool — undo (previousVersions)", () => { + test("stores previous version before edit", async () => { + const { ops } = createMemFs({ "/test.txt": "hello world" }); + const fileHashes = new Map(); + const previousVersions = new Map(); + + // Pre-populate hash (simulates a prior read) + fileHashes.set("/test.txt", hashContent("hello world")); + + const tool = createEditTool("/", { + operations: ops, + fileHashes, + previousVersions, + }); + + await tool.execute("call-1", { + file_path: "/test.txt", + old_string: "hello", + new_string: "goodbye", + }); + + expect(previousVersions.get("/test.txt")).toBe("hello world"); + }); + + test("stores previous version on full file replacement", async () => { + const { ops } = createMemFs({ "/test.txt": "original content" }); + const fileHashes = new Map(); + const previousVersions = new Map(); + + fileHashes.set("/test.txt", hashContent("original content")); + + const tool = createEditTool("/", { + operations: ops, + fileHashes, + previousVersions, + }); + + await tool.execute("call-1", { + file_path: "/test.txt", + old_string: "", + new_string: "new content", + }); + + expect(previousVersions.get("/test.txt")).toBe("original content"); + }); + + test("overwrites previous version on consecutive edits", async () => { + const { store, ops } = createMemFs({ "/test.txt": "aaa bbb ccc" }); + const fileHashes = new Map(); + const previousVersions = new Map(); + + fileHashes.set("/test.txt", hashContent("aaa bbb ccc")); + + const tool = createEditTool("/", { + operations: ops, + fileHashes, + previousVersions, + }); + + // First edit + await tool.execute("call-1", { + file_path: "/test.txt", + old_string: "aaa", + new_string: "AAA", + }); + expect(previousVersions.get("/test.txt")).toBe("aaa bbb ccc"); + + // Second edit — previous version should now be the result of first edit + await tool.execute("call-2", { + file_path: "/test.txt", + old_string: "bbb", + new_string: "BBB", + }); + expect(previousVersions.get("/test.txt")).toBe("AAA bbb ccc"); + expect(store.get("/test.txt")).toBe("AAA BBB ccc"); + }); +}); + +// --------------------------------------------------------------------------- +// Validation tests +// --------------------------------------------------------------------------- + +describe("edit tool — JSON/YAML validation", () => { + test("warns on invalid JSON after edit", async () => { + const jsonContent = '{"key": "value"}'; + const { ops } = createMemFs({ "/data.json": jsonContent }); + const fileHashes = new Map(); + fileHashes.set("/data.json", hashContent(jsonContent)); + + const tool = createEditTool("/", { + operations: ops, + fileHashes, + }); + + const result = await tool.execute("call-1", { + file_path: "/data.json", + old_string: '"value"', + new_string: '"value"invalid', + }); + + const text = result.content[0].text; + expect(text).toContain("Warning"); + expect(text).toContain("JSON"); + }); + + test("no warning on valid JSON after edit", async () => { + const jsonContent = '{"key": "value"}'; + const { ops } = createMemFs({ "/data.json": jsonContent }); + const fileHashes = new Map(); + fileHashes.set("/data.json", hashContent(jsonContent)); + + const tool = createEditTool("/", { + operations: ops, + fileHashes, + }); + + const result = await tool.execute("call-1", { + file_path: "/data.json", + old_string: '"value"', + new_string: '"updated"', + }); + + const text = result.content[0].text; + expect(text).not.toContain("Warning"); + expect(text).toContain("Successfully"); + }); + + test("warns on invalid YAML after edit", async () => { + const yamlContent = "key: value\nother: stuff\n"; + const { ops } = createMemFs({ "/config.yaml": yamlContent }); + const fileHashes = new Map(); + fileHashes.set("/config.yaml", hashContent(yamlContent)); + + const tool = createEditTool("/", { + operations: ops, + fileHashes, + }); + + const result = await tool.execute("call-1", { + file_path: "/config.yaml", + old_string: "key: value", + new_string: "key: value\n bad indent: broken", + }); + + const text = result.content[0].text; + expect(text).toContain("Warning"); + expect(text).toContain("YAML"); + }); + + test("no warning on valid YAML after edit", async () => { + const yamlContent = "key: value\n"; + const { ops } = createMemFs({ "/config.yml": yamlContent }); + const fileHashes = new Map(); + fileHashes.set("/config.yml", hashContent(yamlContent)); + + const tool = createEditTool("/", { + operations: ops, + fileHashes, + }); + + const result = await tool.execute("call-1", { + file_path: "/config.yml", + old_string: "key: value", + new_string: "key: updated", + }); + + const text = result.content[0].text; + expect(text).not.toContain("Warning"); + }); + + test("no validation for non-JSON/YAML files", async () => { + const content = "not valid json {{{"; + const { ops } = createMemFs({ "/script.ts": content }); + const fileHashes = new Map(); + fileHashes.set("/script.ts", hashContent(content)); + + const tool = createEditTool("/", { + operations: ops, + fileHashes, + }); + + const result = await tool.execute("call-1", { + file_path: "/script.ts", + old_string: "not valid", + new_string: "still not valid", + }); + + const text = result.content[0].text; + expect(text).not.toContain("Warning"); + expect(text).toContain("Successfully"); + }); +}); diff --git a/src/tools/edit.ts b/src/tools/edit.ts index 00c6790..0fcc471 100644 --- a/src/tools/edit.ts +++ b/src/tools/edit.ts @@ -9,6 +9,7 @@ import { import { resolve, isAbsolute } from "path"; import { createHash } from "node:crypto"; import * as Diff from "diff"; +import { validateFileContent } from "./file-validation"; // --------------------------------------------------------------------------- // Schema (forge naming: file_path, old_string, new_string, replace_all) @@ -45,6 +46,7 @@ export interface EditOperations { export interface EditToolOptions { operations?: EditOperations; fileHashes?: Map; + previousVersions?: Map; } const defaultEditOperations: EditOperations = { @@ -342,6 +344,7 @@ export function createEditTool( ): AgentTool { const ops = options?.operations ?? defaultEditOperations; const fileHashes = options?.fileHashes ?? new Map(); + const previousVersions = options?.previousVersions ?? new Map(); return { name: "edit", @@ -412,6 +415,7 @@ export function createEditTool( if (old_string.trim() === "") { const finalContent = bom + restoreLineEndings(normalizedNewText, originalEnding); + previousVersions.set(absolutePath, rawContent); await ops.writeFile(absolutePath, finalContent); fileHashes.set(absolutePath, hashContent(finalContent)); @@ -419,11 +423,16 @@ export function createEditTool( if (signal) signal.removeEventListener("abort", onAbort); const diffResult = generateDiffString(normalizedContent, normalizedNewText); + let successText = `Successfully replaced entire file content in ${file_path}.`; + const validation = validateFileContent(absolutePath, finalContent); + if (validation && !validation.valid) { + successText += `\n⚠️ Warning: The file is not valid ${validation.format}. Parse error: ${validation.error}`; + } resolve({ content: [ { type: "text", - text: `Successfully replaced entire file content in ${file_path}.`, + text: successText, }, ], details: { @@ -482,6 +491,7 @@ export function createEditTool( // 9. Write back with restored line endings + BOM const finalContent = bom + restoreLineEndings(newContent, originalEnding); + previousVersions.set(absolutePath, rawContent); await ops.writeFile(absolutePath, finalContent); fileHashes.set(absolutePath, hashContent(finalContent)); @@ -491,9 +501,13 @@ export function createEditTool( if (signal) signal.removeEventListener("abort", onAbort); const diffResult = generateDiffString(baseContent, newContent); - const successText = matchResult.usedFuzzyMatch + let successText = matchResult.usedFuzzyMatch ? `Successfully replaced text in ${file_path}. (Note: matched via fuzzy normalization — old_string contained unicode characters that were normalized to ASCII equivalents.)` : `Successfully replaced text in ${file_path}.`; + const validation = validateFileContent(absolutePath, finalContent); + if (validation && !validation.valid) { + successText += `\n⚠️ Warning: The file is not valid ${validation.format}. Parse error: ${validation.error}`; + } resolve({ content: [ { diff --git a/src/tools/file-validation.test.ts b/src/tools/file-validation.test.ts new file mode 100644 index 0000000..e22b1ba --- /dev/null +++ b/src/tools/file-validation.test.ts @@ -0,0 +1,92 @@ +import { describe, test, expect } from "bun:test"; +import { validateFileContent } from "./file-validation"; + +describe("file-validation", () => { + // JSON validation + describe("JSON files", () => { + test("valid JSON returns valid", () => { + const result = validateFileContent("/data.json", '{"key": "value"}'); + expect(result).not.toBeNull(); + expect(result!.valid).toBe(true); + expect(result!.format).toBe("JSON"); + }); + + test("invalid JSON returns error", () => { + const result = validateFileContent("/data.json", '{"key": invalid}'); + expect(result).not.toBeNull(); + expect(result!.valid).toBe(false); + expect(result!.format).toBe("JSON"); + expect(result!.error).toBeDefined(); + }); + + test("empty string is invalid JSON", () => { + const result = validateFileContent("/data.json", ""); + expect(result).not.toBeNull(); + expect(result!.valid).toBe(false); + }); + + test("JSON array is valid", () => { + const result = validateFileContent("/data.json", '[1, 2, 3]'); + expect(result).not.toBeNull(); + expect(result!.valid).toBe(true); + }); + }); + + // YAML validation + describe("YAML files (.yaml)", () => { + test("valid YAML returns valid", () => { + const result = validateFileContent("/config.yaml", "key: value\nother: 123\n"); + expect(result).not.toBeNull(); + expect(result!.valid).toBe(true); + expect(result!.format).toBe("YAML"); + }); + + test("invalid YAML returns error", () => { + const result = validateFileContent( + "/config.yaml", + "key: value\n bad: indent\n worse:\n mixed: up", + ); + // Note: YAML is quite permissive — some seemingly broken indentation is still valid + // We test with something truly invalid + const result2 = validateFileContent("/config.yaml", ":\n - :\n - : :"); + // This may or may not fail depending on YAML parser strictness + // Let's use a definitely invalid case + const result3 = validateFileContent("/config.yaml", "key: [unclosed bracket"); + expect(result3).not.toBeNull(); + expect(result3!.valid).toBe(false); + expect(result3!.format).toBe("YAML"); + }); + }); + + describe("YAML files (.yml)", () => { + test("valid YAML with .yml extension", () => { + const result = validateFileContent("/docker-compose.yml", "version: '3'\nservices:\n app:\n image: node\n"); + expect(result).not.toBeNull(); + expect(result!.valid).toBe(true); + expect(result!.format).toBe("YAML"); + }); + }); + + // Non-validatable files + describe("non-validatable files", () => { + test("returns null for .ts files", () => { + expect(validateFileContent("/app.ts", "const x = 1;")).toBeNull(); + }); + + test("returns null for .js files", () => { + expect(validateFileContent("/app.js", "var x = 1;")).toBeNull(); + }); + + test("returns null for .py files", () => { + expect(validateFileContent("/app.py", "x = 1")).toBeNull(); + }); + + test("returns null for .md files", () => { + expect(validateFileContent("/README.md", "# Hello")).toBeNull(); + }); + + test("returns null for files without extension", () => { + expect(validateFileContent("/Makefile", "all: build")).toBeNull(); + }); + }); +}); diff --git a/src/tools/file-validation.ts b/src/tools/file-validation.ts new file mode 100644 index 0000000..f5a31e9 --- /dev/null +++ b/src/tools/file-validation.ts @@ -0,0 +1,60 @@ +import { extname } from "path"; +import YAML from "yaml"; + +// --------------------------------------------------------------------------- +// Post-edit validation for structured file formats +// --------------------------------------------------------------------------- + +export interface ValidationResult { + valid: boolean; + format?: string; + error?: string; +} + +/** + * Validate file content based on file extension. + * Currently supports JSON and YAML validation. + * Returns null if the file type is not validatable. + */ +export function validateFileContent( + filePath: string, + content: string, +): ValidationResult | null { + const ext = extname(filePath).toLowerCase(); + + switch (ext) { + case ".json": + return validateJson(content); + case ".yaml": + case ".yml": + return validateYaml(content); + default: + return null; + } +} + +function validateJson(content: string): ValidationResult { + try { + JSON.parse(content); + return { valid: true, format: "JSON" }; + } catch (err: any) { + return { + valid: false, + format: "JSON", + error: err.message, + }; + } +} + +function validateYaml(content: string): ValidationResult { + try { + YAML.parse(content); + return { valid: true, format: "YAML" }; + } catch (err: any) { + return { + valid: false, + format: "YAML", + error: err.message, + }; + } +} diff --git a/src/tools/undo.test.ts b/src/tools/undo.test.ts new file mode 100644 index 0000000..897b822 --- /dev/null +++ b/src/tools/undo.test.ts @@ -0,0 +1,208 @@ +import { describe, test, expect } from "bun:test"; +import { createUndoTool } from "./undo"; +import { createEditTool, hashContent } from "./edit"; +import type { EditOperations } from "./edit"; +import type { UndoOperations } from "./undo"; + +// --------------------------------------------------------------------------- +// In-memory filesystem for testing +// --------------------------------------------------------------------------- + +function createMemFs(files: Record = {}) { + const store = new Map(Object.entries(files)); + + const editOps: EditOperations = { + readFile: async (path) => { + const content = store.get(path); + if (content === undefined) throw new Error(`ENOENT: ${path}`); + return Buffer.from(content, "utf-8"); + }, + writeFile: async (path, content) => { + store.set(path, content); + }, + access: async (path) => { + if (!store.has(path)) throw new Error(`ENOENT: ${path}`); + }, + }; + + const undoOps: UndoOperations = { + readFile: editOps.readFile, + writeFile: editOps.writeFile as any, + }; + + return { store, editOps, undoOps }; +} + +// --------------------------------------------------------------------------- +// Undo tool tests +// --------------------------------------------------------------------------- + +describe("undo tool", () => { + test("restores file to previous version after edit", async () => { + const { store, editOps, undoOps } = createMemFs({ + "/test.txt": "hello world", + }); + const fileHashes = new Map(); + const previousVersions = new Map(); + + fileHashes.set("/test.txt", hashContent("hello world")); + + const editTool = createEditTool("/", { + operations: editOps, + fileHashes, + previousVersions, + }); + const undoTool = createUndoTool("/", { + operations: undoOps, + fileHashes, + previousVersions, + }); + + // Edit the file + await editTool.execute("call-1", { + file_path: "/test.txt", + old_string: "hello", + new_string: "goodbye", + }); + expect(store.get("/test.txt")).toBe("goodbye world"); + + // Undo + const result = await undoTool.execute("call-2", { + file_path: "/test.txt", + }); + expect(store.get("/test.txt")).toBe("hello world"); + expect(result.content[0].text).toContain("Successfully undid"); + }); + + test("clears undo history after undo", async () => { + const { store, editOps, undoOps } = createMemFs({ + "/test.txt": "original", + }); + const fileHashes = new Map(); + const previousVersions = new Map(); + + fileHashes.set("/test.txt", hashContent("original")); + + const editTool = createEditTool("/", { + operations: editOps, + fileHashes, + previousVersions, + }); + const undoTool = createUndoTool("/", { + operations: undoOps, + fileHashes, + previousVersions, + }); + + // Edit + await editTool.execute("call-1", { + file_path: "/test.txt", + old_string: "original", + new_string: "modified", + }); + + // Undo + await undoTool.execute("call-2", { file_path: "/test.txt" }); + expect(store.get("/test.txt")).toBe("original"); + + // Second undo should fail — history cleared + try { + await undoTool.execute("call-3", { file_path: "/test.txt" }); + expect(true).toBe(false); // Should not reach here + } catch (err: any) { + expect(err.message).toContain("No undo history"); + } + }); + + test("errors when no undo history exists", async () => { + const { undoOps } = createMemFs({ "/test.txt": "content" }); + const fileHashes = new Map(); + const previousVersions = new Map(); + + const undoTool = createUndoTool("/", { + operations: undoOps, + fileHashes, + previousVersions, + }); + + try { + await undoTool.execute("call-1", { file_path: "/test.txt" }); + expect(true).toBe(false); // Should not reach here + } catch (err: any) { + expect(err.message).toContain("No undo history"); + } + }); + + test("updates fileHashes after undo", async () => { + const { store, editOps, undoOps } = createMemFs({ + "/test.txt": "original", + }); + const fileHashes = new Map(); + const previousVersions = new Map(); + + fileHashes.set("/test.txt", hashContent("original")); + + const editTool = createEditTool("/", { + operations: editOps, + fileHashes, + previousVersions, + }); + const undoTool = createUndoTool("/", { + operations: undoOps, + fileHashes, + previousVersions, + }); + + // Edit + await editTool.execute("call-1", { + file_path: "/test.txt", + old_string: "original", + new_string: "modified", + }); + + // Undo + await undoTool.execute("call-2", { file_path: "/test.txt" }); + + // Hash should match original content + expect(fileHashes.get("/test.txt")).toBe(hashContent("original")); + }); + + test("returns diff showing the undo changes", async () => { + const { store, editOps, undoOps } = createMemFs({ + "/test.txt": "line one\nline two\nline three\n", + }); + const fileHashes = new Map(); + const previousVersions = new Map(); + + fileHashes.set( + "/test.txt", + hashContent("line one\nline two\nline three\n"), + ); + + const editTool = createEditTool("/", { + operations: editOps, + fileHashes, + previousVersions, + }); + const undoTool = createUndoTool("/", { + operations: undoOps, + fileHashes, + previousVersions, + }); + + // Edit + await editTool.execute("call-1", { + file_path: "/test.txt", + old_string: "line two", + new_string: "LINE TWO CHANGED", + }); + + // Undo + const result = await undoTool.execute("call-2", { + file_path: "/test.txt", + }); + + expect(result.details?.diff).toBeDefined(); + expect(result.details.diff).toContain("line two"); + }); +}); diff --git a/src/tools/undo.ts b/src/tools/undo.ts new file mode 100644 index 0000000..f110b34 --- /dev/null +++ b/src/tools/undo.ts @@ -0,0 +1,140 @@ +import { Type } from "@sinclair/typebox"; +import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; +import { + readFile as fsReadFile, + writeFile as fsWriteFile, +} from "fs/promises"; +import { resolve, isAbsolute } from "path"; +import { hashContent, generateDiffString } from "./edit"; + +// --------------------------------------------------------------------------- +// Schema +// --------------------------------------------------------------------------- + +const undoSchema = Type.Object({ + file_path: Type.String({ + description: "The path to the file to undo the last edit/write for", + }), +}); + +// --------------------------------------------------------------------------- +// Pluggable file I/O +// --------------------------------------------------------------------------- + +export interface UndoOperations { + readFile: (absolutePath: string) => Promise; + writeFile: (absolutePath: string, content: string) => Promise; +} + +export interface UndoToolOptions { + operations?: UndoOperations; + fileHashes?: Map; + previousVersions?: Map; +} + +const defaultUndoOperations: UndoOperations = { + readFile: (path) => fsReadFile(path), + writeFile: (path, content) => fsWriteFile(path, content, "utf-8"), +}; + +// --------------------------------------------------------------------------- +// Description +// --------------------------------------------------------------------------- + +const DESCRIPTION = `Undo the last edit or write operation on a file, restoring it to its previous content. +- Only one level of undo is available per file (the state before the most recent edit/write). +- After undoing, the undo history for that file is cleared — you cannot undo an undo. +- Use this when an edit produced incorrect results and you want to restore the file before trying again.`; + +// --------------------------------------------------------------------------- +// createUndoTool factory +// --------------------------------------------------------------------------- + +export function createUndoTool( + cwd: string, + options?: UndoToolOptions, +): AgentTool { + const ops = options?.operations ?? defaultUndoOperations; + const fileHashes = options?.fileHashes ?? new Map(); + const previousVersions = options?.previousVersions ?? new Map(); + + return { + name: "undo", + label: "undo", + description: DESCRIPTION, + parameters: undoSchema, + + execute: async (_toolCallId, args, signal) => { + const { file_path } = args as { file_path: string }; + const absolutePath = isAbsolute(file_path) + ? file_path + : resolve(cwd, file_path); + + return new Promise>((resolvePromise, reject) => { + if (signal?.aborted) { + reject(new Error("Operation aborted")); + return; + } + + let aborted = false; + const onAbort = () => { + aborted = true; + reject(new Error("Operation aborted")); + }; + if (signal) { + signal.addEventListener("abort", onAbort, { once: true }); + } + + (async () => { + try { + // 1. Check if we have a previous version + const previousContent = previousVersions.get(absolutePath); + if (previousContent === undefined) { + reject( + new Error( + `No undo history available for ${file_path}. Undo is only available after an edit or write operation.`, + ), + ); + return; + } + + if (aborted) return; + + // 2. Read current content for diff + const buffer = await ops.readFile(absolutePath); + const currentContent = buffer.toString("utf-8"); + if (aborted) return; + + // 3. Write the previous version back + await ops.writeFile(absolutePath, previousContent); + if (aborted) return; + + // 4. Update hash map and clear undo history for this file + fileHashes.set(absolutePath, hashContent(previousContent)); + previousVersions.delete(absolutePath); + + if (signal) signal.removeEventListener("abort", onAbort); + + // 5. Generate diff showing what changed + const diffResult = generateDiffString(currentContent, previousContent); + resolvePromise({ + content: [ + { + type: "text", + text: `Successfully undid last change to ${file_path}. The file has been restored to its previous state.`, + }, + ], + details: { + diff: diffResult.diff, + firstChangedLine: diffResult.firstChangedLine, + }, + }); + } catch (error) { + if (signal) signal.removeEventListener("abort", onAbort); + if (!aborted) reject(error); + } + })(); + }); + }, + }; +} diff --git a/src/tools/write.ts b/src/tools/write.ts index 507dc29..3df3951 100644 --- a/src/tools/write.ts +++ b/src/tools/write.ts @@ -8,6 +8,7 @@ import { } from "fs/promises"; import { resolve, isAbsolute, dirname } from "path"; import { hashContent, generateDiffString } from "./edit"; +import { validateFileContent } from "./file-validation"; // --------------------------------------------------------------------------- // Schema @@ -32,6 +33,7 @@ export interface WriteOperations { export interface WriteToolOptions { operations?: WriteOperations; fileHashes?: Map; + previousVersions?: Map; } const defaultWriteOperations: WriteOperations = { @@ -61,6 +63,7 @@ export function createWriteTool( ): AgentTool { const ops = options?.operations ?? defaultWriteOperations; const fileHashes = options?.fileHashes ?? new Map(); + const previousVersions = options?.previousVersions ?? new Map(); return { name: "write", @@ -105,27 +108,37 @@ export function createWriteTool( } if (aborted) return; - // 2. Ensure parent directories exist + // 2. Store previous version for undo (only if file existed) + if (oldContent !== null) { + previousVersions.set(absolutePath, oldContent); + } + + // 3. Ensure parent directories exist await ops.mkdir(dirname(absolutePath)); if (aborted) return; - // 3. Write the file + // 4. Write the file await ops.writeFile(absolutePath, content); if (aborted) return; - // 4. Update shared hash map + // 5. Update shared hash map fileHashes.set(absolutePath, hashContent(content)); if (signal) signal.removeEventListener("abort", onAbort); - // 5. Build result + // 6. Build result + const validation = validateFileContent(absolutePath, content); + const validationWarning = + validation && !validation.valid + ? `\n⚠️ Warning: The file is not valid ${validation.format}. Parse error: ${validation.error}` + : ""; if (oldContent !== null) { const diffResult = generateDiffString(oldContent, content); resolvePromise({ content: [ { type: "text", - text: `Overwrote ${file_path}.`, + text: `Overwrote ${file_path}.${validationWarning}`, }, ], details: { @@ -138,7 +151,7 @@ export function createWriteTool( content: [ { type: "text", - text: `Created new file ${file_path}.`, + text: `Created new file ${file_path}.${validationWarning}`, }, ], details: {}, From 8b9bb50abcca9f006f017128a9951a28c8d1015c Mon Sep 17 00:00:00 2001 From: Anton Date: Wed, 25 Mar 2026 19:08:45 +0000 Subject: [PATCH 11/11] fix: resolve biome lint and formatting errors - Use node: protocol for Node.js imports (file-validation.ts, undo.ts) - Fix import ordering (handler.ts, headless.ts, validation.ts, etc.) - Fix unused variables in tests (undo.test.ts, file-validation.test.ts) - Fix unused imports in edit.test.ts - Apply biome formatting across all changed files --- src/github/handler.ts | 2 +- src/headless.ts | 2 +- src/linear/handler.ts | 1 - src/tools/agent-session-runner.ts | 2 +- src/tools/builtin-tools.ts | 8 ++------ src/tools/cron/executor.ts | 2 +- src/tools/edit.test.ts | 4 ++-- src/tools/file-validation.test.ts | 11 ++++------- src/tools/file-validation.ts | 7 ++----- src/tools/undo.test.ts | 15 ++++++--------- src/tools/undo.ts | 20 ++++++-------------- src/tools/workflow/engine-builtins.ts | 2 +- src/validation.ts | 4 ++-- 13 files changed, 29 insertions(+), 51 deletions(-) diff --git a/src/github/handler.ts b/src/github/handler.ts index 2b23ae1..7bbf406 100644 --- a/src/github/handler.ts +++ b/src/github/handler.ts @@ -5,7 +5,7 @@ import { type AgentSession, createSession, type SessionDeps } from "../session"; import { type GitHubSessionMetadata, SessionRegistry } from "../session/registry"; import { loadSnapshot } from "../session/snapshotter"; import { formatToolArgs } from "../shared"; -import { isValidationEnabled, getWorkingTreeDiff, validateWork } from "../validation"; +import { getWorkingTreeDiff, isValidationEnabled, validateWork } from "../validation"; import { addIssueCommentReaction, addPullReviewCommentReaction, diff --git a/src/headless.ts b/src/headless.ts index b6ddb14..71982ed 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -6,7 +6,7 @@ import { createSession, flushAllTraces } from "./session"; import { formatToolArgs } from "./shared"; import { initStorage, startTigrisSync } from "./storage"; import { createTools } from "./tools"; -import { isValidationEnabled, getWorkingTreeDiff, validateWork } from "./validation"; +import { getWorkingTreeDiff, isValidationEnabled, validateWork } from "./validation"; // --------------------------------------------------------------------------- // Arg parsing diff --git a/src/linear/handler.ts b/src/linear/handler.ts index a10f459..1701329 100644 --- a/src/linear/handler.ts +++ b/src/linear/handler.ts @@ -77,7 +77,6 @@ export function initLinearHandler(deps: LinearHandlerDeps): LinearHandler { } return; } - } } diff --git a/src/tools/agent-session-runner.ts b/src/tools/agent-session-runner.ts index 3730205..24b81cc 100644 --- a/src/tools/agent-session-runner.ts +++ b/src/tools/agent-session-runner.ts @@ -3,7 +3,7 @@ import { type Api, getModel, type Model } from "@mariozechner/pi-ai"; import type { AgentSessionEvent, ToolDefinition } from "@mariozechner/pi-coding-agent"; import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent"; import { createLangSmithTracer, isLangSmithEnabled } from "../langsmith"; -import { createBuiltinTool, type BuiltinToolMaps } from "./builtin-tools"; +import { type BuiltinToolMaps, createBuiltinTool } from "./builtin-tools"; import type { AgentDefinition } from "./delegate"; /** Typed wrapper for dynamic (runtime string) model resolution. */ diff --git a/src/tools/builtin-tools.ts b/src/tools/builtin-tools.ts index 9307fdd..1097755 100644 --- a/src/tools/builtin-tools.ts +++ b/src/tools/builtin-tools.ts @@ -2,8 +2,8 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; import { createBashTool } from "@mariozechner/pi-coding-agent"; import { createEditTool } from "./edit"; import { createReadTool } from "./read"; -import { createWriteTool } from "./write"; import { createUndoTool } from "./undo"; +import { createWriteTool } from "./write"; /** * Shared state maps for built-in file tools. @@ -19,11 +19,7 @@ export interface BuiltinToolMaps { * Create a single built-in tool by name, wiring edit+write+undo to the same * fileHashes and previousVersions maps. */ -export function createBuiltinTool( - name: string, - cwd: string, - maps: BuiltinToolMaps, -): AgentTool | undefined { +export function createBuiltinTool(name: string, cwd: string, maps: BuiltinToolMaps): AgentTool | undefined { const { fileHashes, previousVersions } = maps; switch (name) { case "read": diff --git a/src/tools/cron/executor.ts b/src/tools/cron/executor.ts index 3bf6b03..aeea95b 100644 --- a/src/tools/cron/executor.ts +++ b/src/tools/cron/executor.ts @@ -5,7 +5,7 @@ import { createAgentSession, SessionManager, SettingsManager } from "@mariozechn import type { VeraPaths } from "../../paths"; import type { SlackClient } from "../../slack/client"; import { markdownToMrkdwn } from "../../slack/format"; -import { createBuiltinTool, type BuiltinToolMaps } from "../builtin-tools"; +import { type BuiltinToolMaps, createBuiltinTool } from "../builtin-tools"; import { createDelegateTool } from "../delegate"; import type { ToolRegistry } from "../registry"; import { WorkflowEngine } from "../workflow/engine"; diff --git a/src/tools/edit.test.ts b/src/tools/edit.test.ts index 103806b..474d5da 100644 --- a/src/tools/edit.test.ts +++ b/src/tools/edit.test.ts @@ -1,6 +1,6 @@ -import { describe, test, expect, beforeEach } from "bun:test"; +import { describe, expect, test } from "bun:test"; +import type { EditOperations } from "./edit"; import { createEditTool, hashContent } from "./edit"; -import type { EditOperations, EditToolOptions } from "./edit"; // --------------------------------------------------------------------------- // In-memory filesystem for testing diff --git a/src/tools/file-validation.test.ts b/src/tools/file-validation.test.ts index e22b1ba..da8966e 100644 --- a/src/tools/file-validation.test.ts +++ b/src/tools/file-validation.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { validateFileContent } from "./file-validation"; describe("file-validation", () => { @@ -26,7 +26,7 @@ describe("file-validation", () => { }); test("JSON array is valid", () => { - const result = validateFileContent("/data.json", '[1, 2, 3]'); + const result = validateFileContent("/data.json", "[1, 2, 3]"); expect(result).not.toBeNull(); expect(result!.valid).toBe(true); }); @@ -42,13 +42,10 @@ describe("file-validation", () => { }); test("invalid YAML returns error", () => { - const result = validateFileContent( - "/config.yaml", - "key: value\n bad: indent\n worse:\n mixed: up", - ); + const _result = validateFileContent("/config.yaml", "key: value\n bad: indent\n worse:\n mixed: up"); // Note: YAML is quite permissive — some seemingly broken indentation is still valid // We test with something truly invalid - const result2 = validateFileContent("/config.yaml", ":\n - :\n - : :"); + const _result2 = validateFileContent("/config.yaml", ":\n - :\n - : :"); // This may or may not fail depending on YAML parser strictness // Let's use a definitely invalid case const result3 = validateFileContent("/config.yaml", "key: [unclosed bracket"); diff --git a/src/tools/file-validation.ts b/src/tools/file-validation.ts index f5a31e9..64c9cb4 100644 --- a/src/tools/file-validation.ts +++ b/src/tools/file-validation.ts @@ -1,4 +1,4 @@ -import { extname } from "path"; +import { extname } from "node:path"; import YAML from "yaml"; // --------------------------------------------------------------------------- @@ -16,10 +16,7 @@ export interface ValidationResult { * Currently supports JSON and YAML validation. * Returns null if the file type is not validatable. */ -export function validateFileContent( - filePath: string, - content: string, -): ValidationResult | null { +export function validateFileContent(filePath: string, content: string): ValidationResult | null { const ext = extname(filePath).toLowerCase(); switch (ext) { diff --git a/src/tools/undo.test.ts b/src/tools/undo.test.ts index 285a2b2..5d4d365 100644 --- a/src/tools/undo.test.ts +++ b/src/tools/undo.test.ts @@ -1,8 +1,8 @@ -import { describe, test, expect } from "bun:test"; -import { createUndoTool } from "./undo"; -import { createEditTool, hashContent } from "./edit"; +import { describe, expect, test } from "bun:test"; import type { EditOperations } from "./edit"; +import { createEditTool, hashContent } from "./edit"; import type { UndoOperations } from "./undo"; +import { createUndoTool } from "./undo"; // --------------------------------------------------------------------------- // In-memory filesystem for testing @@ -134,7 +134,7 @@ describe("undo tool", () => { }); test("updates fileHashes after undo", async () => { - const { store, editOps, undoOps } = createMemFs({ + const { editOps, undoOps } = createMemFs({ "/test.txt": "original", }); const fileHashes = new Map(); @@ -168,16 +168,13 @@ describe("undo tool", () => { }); test("returns diff showing the undo changes", async () => { - const { store, editOps, undoOps } = createMemFs({ + const { editOps, undoOps } = createMemFs({ "/test.txt": "line one\nline two\nline three\n", }); const fileHashes = new Map(); const previousVersions = new Map(); - fileHashes.set( - "/test.txt", - hashContent("line one\nline two\nline three\n"), - ); + fileHashes.set("/test.txt", hashContent("line one\nline two\nline three\n")); const editTool = createEditTool("/", { operations: editOps, diff --git a/src/tools/undo.ts b/src/tools/undo.ts index f110b34..e3157d4 100644 --- a/src/tools/undo.ts +++ b/src/tools/undo.ts @@ -1,11 +1,8 @@ -import { Type } from "@sinclair/typebox"; +import { readFile as fsReadFile, writeFile as fsWriteFile } from "node:fs/promises"; +import { isAbsolute, resolve } from "node:path"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; -import { - readFile as fsReadFile, - writeFile as fsWriteFile, -} from "fs/promises"; -import { resolve, isAbsolute } from "path"; -import { hashContent, generateDiffString } from "./edit"; +import { Type } from "@sinclair/typebox"; +import { generateDiffString, hashContent } from "./edit"; // --------------------------------------------------------------------------- // Schema @@ -50,10 +47,7 @@ const DESCRIPTION = `Undo the last edit or write operation on a file, restoring // createUndoTool factory // --------------------------------------------------------------------------- -export function createUndoTool( - cwd: string, - options?: UndoToolOptions, -): AgentTool { +export function createUndoTool(cwd: string, options?: UndoToolOptions): AgentTool { const ops = options?.operations ?? defaultUndoOperations; const fileHashes = options?.fileHashes ?? new Map(); const previousVersions = options?.previousVersions ?? new Map(); @@ -66,9 +60,7 @@ export function createUndoTool( execute: async (_toolCallId, args, signal) => { const { file_path } = args as { file_path: string }; - const absolutePath = isAbsolute(file_path) - ? file_path - : resolve(cwd, file_path); + const absolutePath = isAbsolute(file_path) ? file_path : resolve(cwd, file_path); return new Promise>((resolvePromise, reject) => { if (signal?.aborted) { diff --git a/src/tools/workflow/engine-builtins.ts b/src/tools/workflow/engine-builtins.ts index ef8db43..bd45bd7 100644 --- a/src/tools/workflow/engine-builtins.ts +++ b/src/tools/workflow/engine-builtins.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import type { AgentTool } from "@mariozechner/pi-agent-core"; -import { createBuiltinTool, type BuiltinToolMaps } from "../builtin-tools"; +import { type BuiltinToolMaps, createBuiltinTool } from "../builtin-tools"; export function createEngineBuiltinTools(cwd: string): Map> { const maps: BuiltinToolMaps = { diff --git a/src/validation.ts b/src/validation.ts index f6243bf..e6dca2c 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -1,6 +1,6 @@ import { execSync } from "node:child_process"; -import { loadAgentDefinitions } from "./tools/delegate"; import { runAgentTask } from "./tools/agent-session-runner"; +import { loadAgentDefinitions } from "./tools/delegate"; // --------------------------------------------------------------------------- // Types @@ -114,7 +114,7 @@ export async function validateWork( try { const report = await runAgentTask(cwd, validatorDef, [], task, 50_000); - const passed = !(/\*\*Status\*\*:\s*FAIL/i.test(report)); + const passed = !/\*\*Status\*\*:\s*FAIL/i.test(report); log(`[validator] Validation complete — ${passed ? "PASS" : "FAIL"}`); return { passed, report }; } catch (err: any) {