diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index c0b85ecd8..dc5d120d5 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -214,6 +214,7 @@ ${chalk.bold("Examples:")} ${APP_NAME} --export session.jsonl output.html ${chalk.bold("Environment Variables:")} + ${chalk.dim("# Model providers")} ANTHROPIC_API_KEY - Anthropic Claude API key ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth token (alternative to API key) OPENAI_API_KEY - OpenAI GPT API key @@ -222,7 +223,15 @@ ${chalk.bold("Environment Variables:")} CEREBRAS_API_KEY - Cerebras API key XAI_API_KEY - xAI Grok API key OPENROUTER_API_KEY - OpenRouter API key + MISTRAL_API_KEY - Mistral API key ZAI_API_KEY - ZAI API key + GITHUB_TOKEN - GitHub Copilot models (or GH_TOKEN, COPILOT_GITHUB_TOKEN) + + ${chalk.dim("# Web search providers")} + EXA_API_KEY - Exa search API key + PERPLEXITY_API_KEY - Perplexity search API key + + ${chalk.dim("# Configuration")} ${ENV_AGENT_DIR.padEnd(23)} - Session storage directory (default: ~/${CONFIG_DIR_NAME}/agent) ${chalk.bold("Available Tools (default: read, bash, edit, write):")} diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index c2764973e..0233209a2 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -63,7 +63,6 @@ ${commitsText}`; /** Tool descriptions for system prompt */ const toolDescriptions: Record = { ask: "Ask user for input or clarification", - ast: "Perform AST-level code analysis and transformations", read: "Read file contents", bash: "Execute bash commands (git, npm, docker, etc.)", edit: "Make surgical edits to files (find exact text and replace)", @@ -73,78 +72,11 @@ const toolDescriptions: Record = { ls: "List directory contents", lsp: "PREFERRED for semantic code queries: go-to-definition, find-all-references, hover (type info), call hierarchy. Returns precise, deterministic results. Use BEFORE grep for symbol lookups.", notebook: "Edit Jupyter notebook cells", - replace: "Find and replace text across multiple files", task: "Spawn a sub-agent to handle complex tasks", web_fetch: "Fetch and render URLs into clean text for LLM consumption", web_search: "Search the web for information", }; -/** - * Anti-bash rules - explicit patterns that MUST use specialized tools instead of bash. - * These rules are critical for preventing LLM from falling back to shell commands. - */ -const _antiBashRules = `## Tool Usage Rules — MANDATORY - -### Forbidden Bash Patterns -NEVER use bash for these operations: - -| Operation | Forbidden | Required Tool | -|-----------|-----------|---------------| -| File reading | cat, head, tail, less, more | read | -| Content search | grep, rg, ag, ack | grep | -| File finding | find, fd, locate | find | -| Directory listing | ls | ls (or find) | -| File editing | sed, awk, perl -pi, echo >, cat < Result<$T, $E>\` -- Find async functions: \`async fn $NAME($$PARAMS) $BODY\` -- Find macro invocations: \`$MACRO!($$ARGS)\` - -**TypeScript/JavaScript examples:** -- Find React components: \`function $NAME($$PROPS) { $$BODY }\` with JSX -- Find async arrow functions: \`async ($$PARAMS) => $$BODY\` -- Find imports: \`import { $$NAMES } from "$MODULE"\` - -**When to use AST vs Grep:** -- **ast**: Code structure (function shapes, impl blocks, call patterns, type patterns) -- **grep**: Text content (strings, comments, error messages, config values, symbols) - -### Search-First Protocol -Before reading any file: -1. If you don't know the codebase structure → \`find pattern: "*.rs"\` to see layout -2. If you know roughly where to look → \`grep\` for the specific symbol/error -3. Use \`read offset/limit\` for specific line ranges, not entire large files -4. Never read an entire large file hoping to find something — search first -`; - /** * Generate anti-bash rules section if the agent has both bash and specialized tools. * Only include rules for tools that are actually available. @@ -158,12 +90,10 @@ function generateAntiBashRules(tools: ToolName[]): string | null { const hasFind = tools.includes("find"); const hasLs = tools.includes("ls"); const hasEdit = tools.includes("edit"); - const hasReplace = tools.includes("replace"); - const hasAst = tools.includes("ast"); const hasLsp = tools.includes("lsp"); // Only show rules if we have specialized tools that should be preferred - const hasSpecializedTools = hasRead || hasGrep || hasFind || hasLs || hasEdit || hasReplace; + const hasSpecializedTools = hasRead || hasGrep || hasFind || hasLs || hasEdit; if (!hasSpecializedTools) return null; const lines: string[] = []; @@ -175,18 +105,15 @@ function generateAntiBashRules(tools: ToolName[]): string | null { if (hasGrep) lines.push("- **Content search**: Use `grep` instead of grep/rg/ag/ack"); if (hasFind) lines.push("- **File finding**: Use `find` instead of find/fd/locate"); if (hasLs) lines.push("- **Directory listing**: Use `ls` instead of bash ls"); - if (hasEdit || hasReplace) - lines.push("- **File editing**: Use `edit`/`replace` instead of sed/awk/perl -pi/echo >/cat </cat < `${i + 1}. ${t}`).join("\n")); @@ -194,22 +121,15 @@ function generateAntiBashRules(tools: ToolName[]): string | null { if (hasLsp) { lines.push("\n### LSP — Preferred for Semantic Queries"); lines.push("Use `lsp` instead of grep/bash when you need:"); - lines.push("- **Where is X defined?** → `lsp goToDefinition`"); - lines.push("- **What calls X?** → `lsp findReferences` or `lsp incomingCalls`"); - lines.push("- **What does X call?** → `lsp outgoingCalls`"); + lines.push("- **Where is X defined?** → `lsp definition`"); + lines.push("- **What calls X?** → `lsp incoming_calls`"); + lines.push("- **What does X call?** → `lsp outgoing_calls`"); lines.push("- **What type is X?** → `lsp hover`"); - lines.push("- **What symbols are in this file?** → `lsp documentSymbol`"); - lines.push("- **Find symbol across codebase** → `lsp workspaceSymbol`\n"); - lines.push("LSP returns **precise, compiler-verified results**. Grep returns text matches that may include comments, strings, or false positives."); - } - - // Add AST examples if ast tool is available - if (hasAst) { - lines.push("\n### AST Tool Patterns"); - lines.push("Use `ast` for structural patterns that grep cannot express:\n"); - lines.push("**Rust**: `unsafe { $$BODY }`, `impl $TRAIT for $TYPE { $$BODY }`, `$EXPR.unwrap()`"); - lines.push('**JS/TS**: `async ($$PARAMS) => $$BODY`, `import { $$NAMES } from "$MODULE"`\n'); - lines.push("**ast vs grep**: ast for code structure, grep for text/strings/comments"); + lines.push("- **What symbols are in this file?** → `lsp symbols`"); + lines.push("- **Find symbol across codebase** → `lsp workspace_symbols`\n"); + lines.push( + "LSP returns **precise, compiler-verified results**. Grep returns text matches that may include comments, strings, or false positives.", + ); } // Add search-first protocol diff --git a/packages/coding-agent/src/core/tools/ast.ts b/packages/coding-agent/src/core/tools/ast.ts deleted file mode 100644 index 91fad9cb9..000000000 --- a/packages/coding-agent/src/core/tools/ast.ts +++ /dev/null @@ -1,279 +0,0 @@ -import type { AgentTool } from "@oh-my-pi/pi-agent-core"; -import { Type } from "@sinclair/typebox"; -import type { Subprocess } from "bun"; -import { ensureTool } from "../../utils/tools-manager.js"; -import { resolveToCwd } from "./path-utils.js"; -import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.js"; - -const astSchema = Type.Object({ - action: Type.Union([Type.Literal("search"), Type.Literal("preview"), Type.Literal("apply")], { - description: "Action: search (find matches), preview (show proposed changes), apply (make changes)", - }), - pattern: Type.String({ description: "AST pattern to match (e.g., 'console.log($$$)')" }), - replacement: Type.Optional(Type.String({ description: "Replacement pattern (required for preview/apply)" })), - path: Type.Optional(Type.String({ description: "File or directory path (default: current directory)" })), - lang: Type.Optional(Type.String({ description: "Language (rust, typescript, python, etc.)" })), - max_results: Type.Optional(Type.Number({ description: "Limit results (default: 100)" })), -}); - -export interface AstToolDetails { - truncation?: TruncationResult; - matchCount?: number; - fileCount?: number; - mode?: "search" | "preview" | "apply"; - files?: string[]; - truncated?: boolean; - error?: string; -} - -export function createAstTool(cwd: string): AgentTool { - return { - name: "ast", - label: "ast", - description: `AST-level structural search/replace using ast-grep. - -Actions: -- search: Find matches (read-only) -- preview: Show proposed changes without applying (read-only) -- apply: Make changes to files (destructive) - -Safety workflow: search → preview → apply - -Pattern syntax: -- $NAME for single node wildcards (e.g., $FUNC, $ARG) -- $$$ for multiple nodes (variadic match) -- Examples: 'console.log($$$)', 'fn($A, $B)' - -Output truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB.`, - parameters: astSchema, - execute: async ( - _toolCallId: string, - { - action, - pattern, - replacement, - path: targetPath, - lang, - max_results, - }: { - action: "search" | "preview" | "apply"; - pattern: string; - replacement?: string; - path?: string; - lang?: string; - max_results?: number; - }, - signal?: AbortSignal, - ) => { - if (signal?.aborted) { - throw new Error("Operation aborted"); - } - - const sgPath = await ensureTool("sg", true); - if (!sgPath) { - throw new Error("ast-grep (sg) is not available and could not be downloaded"); - } - - if ((action === "preview" || action === "apply") && !replacement) { - throw new Error(`replacement parameter is required for ${action} action`); - } - - const resolvedPath = targetPath ? resolveToCwd(targetPath, cwd) : cwd; - const maxResults = Math.max(1, max_results ?? 100); - - const args: string[] = []; - - // Add pattern - args.push("-p", pattern); - - // Add action-specific flags - if (action === "apply") { - args.push("-r", replacement!, "--update-all", "--json"); - } else if (action === "preview") { - // Preview: rewrite flag but no --update-all - args.push("-r", replacement!, "--json"); - } else { - // search action - args.push("--json"); - } - - // Add language if specified - if (lang) { - args.push("--lang", lang); - } - - // Add path - args.push(resolvedPath); - - const child: Subprocess = Bun.spawn([sgPath, ...args], { - cwd: resolvedPath, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); - - let stdout = ""; - let stderr = ""; - let aborted = false; - - const onAbort = () => { - aborted = true; - child.kill(); - }; - - if (signal) { - signal.addEventListener("abort", onAbort, { once: true }); - } - - // Read streams using Bun's ReadableStream API - const stdoutReader = (child.stdout as ReadableStream).getReader(); - const stderrReader = (child.stderr as ReadableStream).getReader(); - const decoder = new TextDecoder(); - - await Promise.all([ - (async () => { - while (true) { - const { done, value } = await stdoutReader.read(); - if (done) break; - stdout += decoder.decode(value, { stream: true }); - } - })(), - (async () => { - while (true) { - const { done, value } = await stderrReader.read(); - if (done) break; - stderr += decoder.decode(value, { stream: true }); - } - })(), - ]); - - const exitCode = await child.exited; - - // Cleanup - if (signal) { - signal.removeEventListener("abort", onAbort); - } - - if (aborted) { - throw new Error("Operation aborted"); - } - - // Exit code 1 = no matches (not an error), 0 = matches found - if (exitCode !== 0 && exitCode !== 1 && stderr.trim()) { - const errorMsg = stderr.trim() || `ast-grep exited with code ${exitCode}`; - return { - content: [{ type: "text", text: `Error: ${errorMsg}` }], - details: { mode: action, error: errorMsg } as AstToolDetails, - }; - } - - const output = stdout.trim(); - - // Parse JSON lines (each line is a JSON object) - const lines = output.split("\n").filter(Boolean); - const files = new Set(); - const matches: Array<{ file: string; line: number; text: string; replacement?: string }> = []; - let matchCount = 0; - - for (const line of lines) { - try { - const obj = JSON.parse(line); - const filePath = obj.file || obj.path; - if (filePath) { - const relPath = filePath.startsWith(cwd) ? filePath.slice(cwd.length + 1) : filePath; - files.add(relPath); - matchCount++; - if (matches.length < maxResults) { - matches.push({ - file: relPath, - line: obj.range?.start?.line ?? obj.start?.line ?? 0, - text: obj.text || obj.matched || "", - replacement: obj.replacement, - }); - } - } - } catch { - // Skip malformed lines - } - } - - const truncated = matchCount > maxResults; - const fileCount = files.size; - const details: AstToolDetails = { - mode: action, - matchCount, - fileCount, - files: Array.from(files).slice(0, 50), - truncated, - }; - - if (matchCount === 0) { - const noMatchMsg = action === "apply" ? "No changes made" : "No matches found"; - return { - content: [{ type: "text", text: noMatchMsg }], - details, - }; - } - - // Format output based on action - let formattedOutput: string; - if (action === "apply") { - formattedOutput = `Applied ${matchCount} replacement${matchCount !== 1 ? "s" : ""} in ${fileCount} file${ - fileCount !== 1 ? "s" : "" - }:\n`; - formattedOutput += Array.from(files).join("\n"); - } else if (action === "preview") { - formattedOutput = `Preview of ${matchCount} replacement${matchCount !== 1 ? "s" : ""} in ${fileCount} file${ - fileCount !== 1 ? "s" : "" - }:\n\n`; - for (const m of matches) { - formattedOutput += `${m.file}:${m.line}\n`; - formattedOutput += ` - ${m.text}\n`; - if (m.replacement !== undefined) { - formattedOutput += ` + ${m.replacement}\n`; - } - formattedOutput += "\n"; - } - } else { - // search mode - formattedOutput = `Found ${matchCount} match${matchCount !== 1 ? "es" : ""} in ${fileCount} file${ - fileCount !== 1 ? "s" : "" - }:\n\n`; - for (const m of matches) { - formattedOutput += `${m.file}:${m.line}: ${m.text}\n`; - } - } - - if (truncated) { - formattedOutput += `\n... truncated at ${maxResults} results (${matchCount} total)`; - } - - // Apply truncation - const truncation = truncateHead(formattedOutput); - let finalOutput = truncation.content || formattedOutput; - - if (truncation.truncated) { - details.truncation = truncation; - - const startLine = 1; - const endLine = truncation.outputLines; - - if (truncation.truncatedBy === "lines") { - finalOutput += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}]`; - } else { - finalOutput += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize( - DEFAULT_MAX_BYTES, - )} limit)]`; - } - } - - return { - content: [{ type: "text", text: finalOutput }], - details, - }; - }, - }; -} - -/** Default ast tool using process.cwd() - for backwards compatibility */ -export const astTool = createAstTool(process.cwd()); diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index 39907fe6f..bfdcd3b73 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -5,7 +5,7 @@ import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import { Type } from "@sinclair/typebox"; import type { Subprocess } from "bun"; import { getShellConfig, killProcessTree } from "../../utils/shell.js"; -import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js"; +import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateTail } from "./truncate.js"; /** * Generate a unique temp file path for bash output @@ -29,10 +29,45 @@ export interface BashToolDetails { export function createBashTool(cwd: string): AgentTool { return { name: "bash", - label: "bash", - description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${ - DEFAULT_MAX_BYTES / 1024 - }KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`, + label: "Bash", + description: `Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures. + +IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. + +Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`ls\` to verify the parent directory exists and is the correct location + - For example, before running "mkdir foo/bar", first use \`ls foo\` to check that "foo" exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt") + - Examples of proper quoting: + - cd "/Users/name/My Documents" (correct) + - cd /Users/name/My Documents (incorrect - will fail) + - python "/path/with spaces/script.py" (correct) + - python /path/with spaces/script.py (incorrect - will fail) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in seconds. + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds 50KB characters, output will be truncated before being returned to you. + - Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use find (NOT find or ls) + - Content search: Use grep (NOT grep or rg) + - Read files: Use read (NOT cat/head/tail) + - Edit files: Use edit (NOT sed/awk) + - Write files: Use write (NOT echo >/cat < { return { name: "edit", - label: "edit", - description: - "Edit a file by replacing text. High-confidence fuzzy matching for whitespace/indentation differences is always enabled.", + label: "Edit", + description: `Performs string replacements in files with fuzzy whitespace matching. + +Usage: +- You must use your read tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. +- Fuzzy matching handles minor whitespace/indentation differences automatically - you don't need to match indentation exactly. +- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. +- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. +- The edit will FAIL if old_string is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use replace_all to change every instance of old_string. +- Use replace_all for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.`, parameters: editSchema, execute: async ( _toolCallId: string, diff --git a/packages/coding-agent/src/core/tools/exa/mcp-client.ts b/packages/coding-agent/src/core/tools/exa/mcp-client.ts index 742f1be73..919796a90 100644 --- a/packages/coding-agent/src/core/tools/exa/mcp-client.ts +++ b/packages/coding-agent/src/core/tools/exa/mcp-client.ts @@ -82,6 +82,7 @@ export async function callMCP(url: string, method: string, params?: Record { return { name: "find", - label: "find", - description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} results or ${ - DEFAULT_MAX_BYTES / 1024 - }KB (whichever is hit first).`, + label: "Find", + description: `- Fast file pattern matching tool that works with any codebase size +- Supports glob patterns like "**/*.js" or "src/**/*.ts" +- Returns matching file paths sorted by modification time +- Use this tool when you need to find files by name patterns +- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead +- You can call multiple tools in a single response. It is always better to speculatively perform multiple searches in parallel if they are potentially useful.`, parameters: findSchema, execute: async ( _toolCallId: string, diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index 0a9e164c2..7d8745c5e 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -63,10 +63,17 @@ export interface GrepToolDetails { export function createGrepTool(cwd: string): AgentTool { return { name: "grep", - label: "grep", - description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} matches or ${ - DEFAULT_MAX_BYTES / 1024 - }KB (whichever is hit first). Long lines are truncated to ${GREP_MAX_LINE_LENGTH} chars.`, + label: "Grep", + description: `A powerful search tool built on ripgrep + +Usage: +- ALWAYS use grep for search tasks. NEVER invoke \`grep\` or \`rg\` as a bash command. The grep tool has been optimized for correct permissions and access. +- Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+") +- Filter files with glob parameter (e.g., "*.js", "**/*.tsx") or type parameter (e.g., "js", "py", "rust") +- Output modes: "content" shows matching lines, "files_with_matches" shows only file paths (default), "count" shows match counts +- Use task tool for open-ended searches requiring multiple rounds +- Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping (use \`interface\\{\\}\` to find \`interface{}\` in Go code) +- Multiline matching: By default patterns match within single lines only. For cross-line patterns like \`struct \\{[\\s\\S]*?field\`, use \`multiline: true\``, parameters: grepSchema, execute: async ( _toolCallId: string, diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index 90585a4e8..3e0580aa2 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -1,5 +1,4 @@ export { type AskToolDetails, askTool, createAskTool } from "./ask.js"; -export { type AstToolDetails, astTool, createAstTool } from "./ast.js"; export { type BashToolDetails, bashTool, createBashTool } from "./bash.js"; export { createEditTool, editTool } from "./edit.js"; // Exa MCP tools (22 tools) @@ -11,7 +10,6 @@ export { createLsTool, type LsToolDetails, lsTool } from "./ls.js"; export { createLspTool, type LspToolDetails, lspTool } from "./lsp/index.js"; export { createNotebookTool, type NotebookToolDetails, notebookTool } from "./notebook.js"; export { createReadTool, type ReadToolDetails, readTool } from "./read.js"; -export { createReplaceTool, type ReplaceToolDetails, replaceTool } from "./replace.js"; export { BUNDLED_AGENTS, createTaskTool, taskTool } from "./task/index.js"; export type { TruncationResult } from "./truncate.js"; export { createWebFetchTool, type WebFetchToolDetails, webFetchCustomTool, webFetchTool } from "./web-fetch.js"; @@ -26,7 +24,6 @@ export { createWriteTool, writeTool } from "./write.js"; import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import { askTool, createAskTool } from "./ask.js"; -import { astTool, createAstTool } from "./ast.js"; import { bashTool, createBashTool } from "./bash.js"; import { checkBashInterception, checkSimpleLsInterception } from "./bash-interceptor.js"; import { createEditTool, editTool } from "./edit.js"; @@ -36,7 +33,6 @@ import { createLsTool, lsTool } from "./ls.js"; import { createLspTool, lspTool } from "./lsp/index.js"; import { createNotebookTool, notebookTool } from "./notebook.js"; import { createReadTool, readTool } from "./read.js"; -import { createReplaceTool, replaceTool } from "./replace.js"; import { createTaskTool, taskTool } from "./task/index.js"; import { createWebFetchTool, webFetchTool } from "./web-fetch.js"; import { createWebSearchTool, webSearchTool } from "./web-search/index.js"; @@ -56,7 +52,6 @@ type ToolFactory = (cwd: string, sessionContext?: SessionContext) => Tool; // Tool definitions: static tools and their factory functions const toolDefs: Record = { ask: { tool: askTool, create: createAskTool }, - ast: { tool: astTool, create: createAstTool }, read: { tool: readTool, create: createReadTool }, bash: { tool: bashTool, create: createBashTool }, edit: { tool: editTool, create: createEditTool }, @@ -66,7 +61,6 @@ const toolDefs: Record = { ls: { tool: lsTool, create: createLsTool }, lsp: { tool: lspTool, create: createLspTool }, notebook: { tool: notebookTool, create: createNotebookTool }, - replace: { tool: replaceTool, create: createReplaceTool }, task: { tool: taskTool, create: (cwd, ctx) => createTaskTool(cwd, ctx) }, web_fetch: { tool: webFetchTool, create: createWebFetchTool }, web_search: { tool: webSearchTool, create: createWebSearchTool }, @@ -86,10 +80,8 @@ const baseCodingToolNames: ToolName[] = [ "grep", "find", "ls", - "ast", "lsp", "notebook", - "replace", "task", "web_fetch", "web_search", diff --git a/packages/coding-agent/src/core/tools/ls.ts b/packages/coding-agent/src/core/tools/ls.ts index 572f15ff1..569956bda 100644 --- a/packages/coding-agent/src/core/tools/ls.ts +++ b/packages/coding-agent/src/core/tools/ls.ts @@ -20,10 +20,8 @@ export interface LsToolDetails { export function createLsTool(cwd: string): AgentTool { return { name: "ls", - label: "ls", - description: `List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to ${DEFAULT_LIMIT} entries or ${ - DEFAULT_MAX_BYTES / 1024 - }KB (whichever is hit first).`, + label: "Ls", + description: `List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to 500 entries or 50KB (whichever is hit first). List structure helps with directory navigation and finding target files.`, parameters: lsSchema, execute: async ( _toolCallId: string, diff --git a/packages/coding-agent/src/core/tools/lsp/index.ts b/packages/coding-agent/src/core/tools/lsp/index.ts index 026434f59..9b827e1f6 100644 --- a/packages/coding-agent/src/core/tools/lsp/index.ts +++ b/packages/coding-agent/src/core/tools/lsp/index.ts @@ -9,6 +9,9 @@ import { applyWorkspaceEdit } from "./edits.js"; import { renderCall, renderResult } from "./render.js"; import * as rustAnalyzer from "./rust-analyzer.js"; import { + type CallHierarchyIncomingCall, + type CallHierarchyItem, + type CallHierarchyOutgoingCall, type CodeAction, type Command, type Diagnostic, @@ -140,7 +143,7 @@ export function createLspTool(cwd: string): AgentTool { + const loc = { uri: call.from.uri, range: call.from.selectionRange }; + const detail = call.from.detail ? ` (${call.from.detail})` : ""; + return ` ${call.from.name}${detail} @ ${formatLocation(loc, cwd)}`; + }); + output = `Found ${calls.length} caller(s) of "${item.name}":\n${lines.join("\n")}`; + } + } else { + const calls = (await sendRequest(client, "callHierarchy/outgoingCalls", { item })) as + | CallHierarchyOutgoingCall[] + | null; + + if (!calls || calls.length === 0) { + output = `"${item.name}" doesn't call any functions`; + } else { + const lines = calls.map((call) => { + const loc = { uri: call.to.uri, range: call.to.selectionRange }; + const detail = call.to.detail ? ` (${call.to.detail})` : ""; + return ` ${call.to.name}${detail} @ ${formatLocation(loc, cwd)}`; + }); + output = `"${item.name}" calls ${calls.length} function(s):\n${lines.join("\n")}`; + } + } + break; + } + // ===================================================================== // Rust-Analyzer Specific Operations // ===================================================================== diff --git a/packages/coding-agent/src/core/tools/lsp/types.ts b/packages/coding-agent/src/core/tools/lsp/types.ts index c20863baf..f3d0966c3 100644 --- a/packages/coding-agent/src/core/tools/lsp/types.ts +++ b/packages/coding-agent/src/core/tools/lsp/types.ts @@ -17,6 +17,8 @@ export const lspSchema = Type.Object({ Type.Literal("workspace_symbols"), Type.Literal("rename"), Type.Literal("actions"), + Type.Literal("incoming_calls"), + Type.Literal("outgoing_calls"), Type.Literal("status"), // Rust-analyzer specific operations Type.Literal("flycheck"), @@ -377,6 +379,31 @@ export interface LspClient { lastActivity: number; } +// ============================================================================= +// Call Hierarchy Types +// ============================================================================= + +export interface CallHierarchyItem { + name: string; + kind: SymbolKind; + tags?: number[]; + detail?: string; + uri: string; + range: Range; + selectionRange: Range; + data?: unknown; +} + +export interface CallHierarchyIncomingCall { + from: CallHierarchyItem; + fromRanges: Range[]; +} + +export interface CallHierarchyOutgoingCall { + to: CallHierarchyItem; + fromRanges: Range[]; +} + // ============================================================================= // Rust-analyzer Specific Types // ============================================================================= diff --git a/packages/coding-agent/src/core/tools/notebook.ts b/packages/coding-agent/src/core/tools/notebook.ts index 48071513d..63944c545 100644 --- a/packages/coding-agent/src/core/tools/notebook.ts +++ b/packages/coding-agent/src/core/tools/notebook.ts @@ -49,9 +49,9 @@ function splitIntoLines(content: string): string[] { export function createNotebookTool(cwd: string): AgentTool { return { name: "notebook", - label: "notebook", + label: "Notebook", description: - "Edit Jupyter notebook (.ipynb) cells. Actions: edit (replace cell content), insert (add new cell), delete (remove cell). Cell indices are 0-based.", + "Completely replaces the contents of a specific cell in a Jupyter notebook (.ipynb file) with new source. Jupyter notebooks are interactive documents that combine code, text, and visualizations, commonly used for data analysis and scientific computing. The notebook_path parameter must be an absolute path, not a relative path. The cell_number is 0-indexed. Use edit_mode=insert to add a new cell at the index specified by cell_number. Use edit_mode=delete to delete the cell at the index specified by cell_number.", parameters: notebookSchema, execute: async ( _toolCallId: string, diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index 5e3df21ab..60777b82e 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -44,13 +44,23 @@ export interface ReadToolDetails { export function createReadTool(cwd: string): AgentTool { return { name: "read", - label: "read", - description: `Read the contents of a file. Supports: -- Text files (truncated to ${DEFAULT_MAX_LINES} lines or ${ - DEFAULT_MAX_BYTES / 1024 - }KB, use offset/limit for large files) -- Images (jpg, png, gif, webp) - sent as attachments -- Documents (pdf, docx, pptx, xlsx, epub, rtf) - converted to markdown via markitdown if available`, + label: "Read", + description: `Reads a file from the local filesystem. You can access any file directly by using this tool. +Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned. + +Usage: +- The file_path parameter must be an absolute path, not a relative path +- By default, it reads up to ${DEFAULT_MAX_LINES} lines starting from the beginning of the file +- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters +- Any lines longer than 500 characters will be truncated +- Results are returned using cat -n format, with line numbers starting at 1 +- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM. +- This tool can read PDF files (.pdf). PDFs are processed page by page, extracting both text and visual content for analysis. +- This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations. +- This tool can only read files, not directories. To read a directory, use an ls command via the bash tool. +- You can call multiple tools in a single response. It is always better to speculatively read multiple potentially useful files in parallel. +- You will regularly be asked to read screenshots. If the user provides a path to a screenshot, ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths. +- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.`, parameters: readSchema, execute: async ( _toolCallId: string, @@ -103,7 +113,7 @@ export function createReadTool(cwd: string): AgentTool { const base64 = buffer.toString("base64"); content = [ - { type: "text", text: `Read image file [${mimeType}]` }, + { type: "text", text: `Read image file [$mimeType]` }, { type: "image", data: base64, mimeType }, ]; } else if (CONVERTIBLE_EXTENSIONS.has(ext)) { @@ -115,9 +125,9 @@ export function createReadTool(cwd: string): AgentTool { let outputText = truncation.content; if (truncation.truncated) { - outputText += `\n\n[Document converted via markitdown. Output truncated to ${formatSize( + outputText += `\n\n[Document converted via markitdown. Output truncated to $formatSize( DEFAULT_MAX_BYTES, - )}]`; + )]`; details = { truncation }; } diff --git a/packages/coding-agent/src/core/tools/renderers.ts b/packages/coding-agent/src/core/tools/renderers.ts index 99fb16516..2298a0970 100644 --- a/packages/coding-agent/src/core/tools/renderers.ts +++ b/packages/coding-agent/src/core/tools/renderers.ts @@ -9,14 +9,12 @@ import { Text } from "@oh-my-pi/pi-tui"; import type { Theme } from "../../modes/interactive/theme/theme.js"; import type { RenderResultOptions } from "../custom-tools/types.js"; import type { AskToolDetails } from "./ask.js"; -import type { AstToolDetails } from "./ast.js"; import type { FindToolDetails } from "./find.js"; import type { GrepToolDetails } from "./grep.js"; import type { LsToolDetails } from "./ls.js"; import { renderCall as renderLspCall, renderResult as renderLspResult } from "./lsp/render.js"; import type { LspToolDetails } from "./lsp/types.js"; import type { NotebookToolDetails } from "./notebook.js"; -import type { ReplaceToolDetails } from "./replace.js"; import { renderCall as renderTaskCall, renderResult as renderTaskResult } from "./task/render.js"; import type { TaskToolDetails } from "./task/types.js"; import { renderWebFetchCall, renderWebFetchResult, type WebFetchToolDetails } from "./web-fetch.js"; @@ -270,187 +268,6 @@ const findRenderer: ToolRenderer = { }, }; -// ============================================================================ -// Replace Renderer -// ============================================================================ - -interface ReplaceArgs { - pattern: string; - replacement: string; - path?: string; - glob?: string; - literal?: boolean; - dry_run?: boolean; -} - -const replaceRenderer: ToolRenderer = { - renderCall(args, theme) { - let text = theme.fg("toolTitle", theme.bold("replace ")); - text += theme.fg("accent", `'${args.pattern}'`); - text += theme.fg("dim", " → "); - text += theme.fg("accent", `'${args.replacement}'`); - - const meta: string[] = []; - if (args.glob) meta.push(`glob:${args.glob}`); - if (args.path) meta.push(args.path); - if (args.dry_run !== false) meta.push("preview"); - if (args.literal) meta.push("-s"); - - if (meta.length > 0) { - text += ` ${theme.fg("muted", meta.join(" "))}`; - } - - return new Text(text, 0, 0); - }, - - renderResult(result, { expanded }, theme) { - const details = result.details; - - const filesChanged = details?.filesChanged ?? 0; - const filesFailed = details?.filesFailed ?? 0; - const preview = details?.preview ?? false; - const changed = details?.changed ?? []; - const failed = details?.failed ?? []; - const truncated = details?.truncated ?? false; - - // No changes - if (filesChanged === 0 && filesFailed === 0) { - const msg = preview ? "No changes would be made" : "No changes made"; - return new Text(`${theme.fg("warning", ICON_WARNING)} ${theme.fg("muted", msg)}`, 0, 0); - } - - // Build summary - const hasErrors = filesFailed > 0; - const icon = hasErrors ? theme.fg("warning", ICON_WARNING) : theme.fg("success", ICON_SUCCESS); - - const parts: string[] = []; - if (filesChanged > 0) { - const verb = preview ? "would change" : "changed"; - parts.push(`${verb} ${filesChanged} file${filesChanged !== 1 ? "s" : ""}`); - } - if (filesFailed > 0) { - parts.push(theme.fg("error", `${filesFailed} failed`)); - } - - let summary = parts.join(", "); - if (truncated) { - summary += theme.fg("warning", " (truncated)"); - } - const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)"); - let text = `${icon} ${theme.fg("toolTitle", "replace")} ${theme.fg("dim", summary)}${expandHint}`; - - // Show file tree - const allFiles = [...changed, ...failed.map((f) => f.file)]; - const maxFiles = expanded ? allFiles.length : Math.min(allFiles.length, 8); - - for (let i = 0; i < maxFiles; i++) { - const isLast = i === maxFiles - 1 && (expanded || allFiles.length <= 8); - const branch = isLast ? TREE_END : TREE_MID; - const file = allFiles[i]; - const isFailed = i >= changed.length; - const color = isFailed ? "error" : "accent"; - text += `\n ${theme.fg("dim", branch)} ${theme.fg(color, file)}`; - } - - if (!expanded && allFiles.length > 8) { - text += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("muted", `… ${allFiles.length - 8} more files`)}`; - } - - return new Text(text, 0, 0); - }, -}; - -// ============================================================================ -// AST Renderer -// ============================================================================ - -interface AstArgs { - action: string; - pattern: string; - replacement?: string; - path?: string; - lang?: string; -} - -const astRenderer: ToolRenderer = { - renderCall(args, theme) { - let text = theme.fg("toolTitle", theme.bold("ast ")); - text += theme.fg("accent", `'${args.pattern}'`); - - if (args.replacement) { - text += theme.fg("dim", " → "); - text += theme.fg("accent", `'${args.replacement}'`); - } - - const meta: string[] = []; - if (args.lang) meta.push(`lang:${args.lang}`); - if (args.action && args.action !== "search") meta.push(args.action); - if (args.path) meta.push(args.path); - - if (meta.length > 0) { - text += ` ${theme.fg("muted", meta.join(" "))}`; - } - - return new Text(text, 0, 0); - }, - - renderResult(result, { expanded }, theme) { - const details = result.details; - - // Error case - if (details?.error) { - return new Text(`${theme.fg("error", ICON_ERROR)} ${theme.fg("error", details.error)}`, 0, 0); - } - - const matchCount = details?.matchCount ?? 0; - const fileCount = details?.fileCount ?? 0; - const mode = details?.mode ?? "search"; - const truncated = details?.truncated ?? details?.truncation?.truncated ?? false; - const files = details?.files ?? []; - - // No matches - if (matchCount === 0) { - return new Text(`${theme.fg("warning", ICON_WARNING)} ${theme.fg("muted", "No matches found")}`, 0, 0); - } - - // Build summary - const icon = mode === "apply" ? theme.fg("success", ICON_SUCCESS) : theme.fg("accent", ICON_INFO); - let summary: string; - if (mode === "apply") { - summary = `Applied ${matchCount} replacement${matchCount !== 1 ? "s" : ""} in ${fileCount} file${ - fileCount !== 1 ? "s" : "" - }`; - } else if (mode === "preview") { - summary = `Preview: ${matchCount} replacement${matchCount !== 1 ? "s" : ""} in ${fileCount} file${ - fileCount !== 1 ? "s" : "" - }`; - } else { - summary = `${matchCount} match${matchCount !== 1 ? "es" : ""} in ${fileCount} file${fileCount !== 1 ? "s" : ""}`; - } - - if (truncated) { - summary += theme.fg("warning", " (truncated)"); - } - - const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)"); - let text = `${icon} ${theme.fg("toolTitle", "ast")} ${theme.fg("dim", summary)}${expandHint}`; - - // Show file tree - const maxFiles = expanded ? files.length : Math.min(files.length, 8); - for (let i = 0; i < maxFiles; i++) { - const isLast = i === maxFiles - 1 && (expanded || files.length <= 8); - const branch = isLast ? TREE_END : TREE_MID; - text += `\n ${theme.fg("dim", branch)} ${theme.fg("accent", files[i])}`; - } - - if (!expanded && files.length > 8) { - text += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("muted", `… ${files.length - 8} more files`)}`; - } - - return new Text(text, 0, 0); - }, -}; - // ============================================================================ // Notebook Renderer // ============================================================================ @@ -715,8 +532,6 @@ export const toolRenderers: Record< ask: askRenderer, grep: grepRenderer, find: findRenderer, - replace: replaceRenderer, - ast: astRenderer, notebook: notebookRenderer, ls: lsRenderer, lsp: lspRenderer, diff --git a/packages/coding-agent/src/core/tools/replace.ts b/packages/coding-agent/src/core/tools/replace.ts deleted file mode 100644 index 27271403b..000000000 --- a/packages/coding-agent/src/core/tools/replace.ts +++ /dev/null @@ -1,297 +0,0 @@ -import type { AgentTool } from "@oh-my-pi/pi-agent-core"; -import { Type } from "@sinclair/typebox"; -import type { Subprocess } from "bun"; -import { ensureTool } from "../../utils/tools-manager.js"; -import { resolveToCwd } from "./path-utils.js"; - -const replaceSchema = Type.Object({ - pattern: Type.String({ description: "Regex pattern to find" }), - replacement: Type.String({ description: "Replacement string" }), - path: Type.Optional(Type.String({ description: "File or directory path (default: current directory)" })), - glob: Type.Optional(Type.String({ description: "Glob pattern to filter files (e.g., '*.ts', '**/*.tsx')" })), - literal: Type.Optional(Type.Boolean({ description: "Treat pattern as literal string, not regex (default: false)" })), - dry_run: Type.Optional(Type.Boolean({ description: "Preview changes without applying them (default: true)" })), - max_results: Type.Optional(Type.Number({ description: "Limit number of files shown in output (default: 50)" })), -}); - -export interface ReplaceToolDetails { - filesChanged: number; - filesFailed: number; - preview: boolean; - changed: string[]; - failed: Array<{ file: string; error: string }>; - truncated?: boolean; -} - -/** Helper to run a command and collect output */ -async function runCommand( - cmd: string, - args: string[], - cwd: string, - signal?: AbortSignal, -): Promise<{ stdout: string; stderr: string; exitCode: number; aborted: boolean }> { - const child: Subprocess = Bun.spawn([cmd, ...args], { - cwd, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); - - let stdout = ""; - let stderr = ""; - let aborted = false; - - const onAbort = () => { - aborted = true; - child.kill(); - }; - - if (signal) { - signal.addEventListener("abort", onAbort, { once: true }); - } - - const stdoutReader = (child.stdout as ReadableStream).getReader(); - const stderrReader = (child.stderr as ReadableStream).getReader(); - const decoder = new TextDecoder(); - - try { - await Promise.all([ - (async () => { - while (true) { - const { done, value } = await stdoutReader.read(); - if (done) break; - stdout += decoder.decode(value, { stream: true }); - } - })(), - (async () => { - while (true) { - const { done, value } = await stderrReader.read(); - if (done) break; - stderr += decoder.decode(value, { stream: true }); - } - })(), - ]); - } finally { - stdoutReader.releaseLock(); - stderrReader.releaseLock(); - } - - const exitCode = await child.exited; - - if (signal) { - signal.removeEventListener("abort", onAbort); - } - - return { stdout, stderr, exitCode: exitCode ?? -1, aborted }; -} - -export function createReplaceTool(cwd: string): AgentTool { - return { - name: "replace", - label: "replace", - description: - "Find-and-replace across files using sd. Supports regex patterns and glob filtering. Use dry_run=false to apply changes.", - parameters: replaceSchema, - execute: async ( - _toolCallId: string, - { - pattern, - replacement, - path: targetPath, - glob, - literal, - dry_run, - max_results, - }: { - pattern: string; - replacement: string; - path?: string; - glob?: string; - literal?: boolean; - dry_run?: boolean; - max_results?: number; - }, - signal?: AbortSignal, - ) => { - if (signal?.aborted) { - throw new Error("Operation aborted"); - } - - const sdPath = await ensureTool("sd", true); - if (!sdPath) { - throw new Error("sd is not available and could not be downloaded"); - } - - const resolvedPath = targetPath ? resolveToCwd(targetPath, cwd) : cwd; - const preview = dry_run ?? true; - const maxResults = Math.max(1, max_results ?? 50); - - // Build base sd args - const sdBaseArgs: string[] = []; - if (preview) { - sdBaseArgs.push("-p"); // preview mode - } - if (literal) { - sdBaseArgs.push("-s"); // string literal mode - } - sdBaseArgs.push(pattern, replacement); - - const changed: string[] = []; - const failed: Array<{ file: string; error: string }> = []; - let changedCount = 0; - let failedCount = 0; - const outputParts: string[] = []; - - if (glob) { - // Use fd to find files, then process each file individually for error recovery - const fdPath = await ensureTool("fd", true); - if (!fdPath) { - throw new Error("fd is required for glob filtering but is not available"); - } - - // Get file list - const fdResult = await runCommand(fdPath, ["-g", glob, ".", resolvedPath, "-a"], resolvedPath, signal); - - if (fdResult.aborted) { - throw new Error("Operation aborted"); - } - - if (fdResult.exitCode !== 0) { - throw new Error(fdResult.stderr.trim() || `fd exited with code ${fdResult.exitCode}`); - } - - const files = fdResult.stdout - .trim() - .split("\n") - .filter((f) => f.length > 0); - - if (files.length === 0) { - return { - content: [{ type: "text", text: "No files matched the glob pattern" }], - details: { filesChanged: 0, filesFailed: 0, preview, changed: [], failed: [] }, - }; - } - - // Process each file - for (const file of files) { - if (signal?.aborted) { - throw new Error("Operation aborted"); - } - - const sdArgs = [...sdBaseArgs, file]; - const result = await runCommand(sdPath, sdArgs, resolvedPath, signal); - - if (result.aborted) { - throw new Error("Operation aborted"); - } - - const relPath = file.startsWith(cwd) ? file.slice(cwd.length + 1) : file; - - if (result.exitCode !== 0) { - const errorMsg = result.stderr.trim() || `sd exited with code ${result.exitCode}`; - failedCount++; - if (failed.length < maxResults) { - failed.push({ file: relPath, error: errorMsg }); - } - } else { - const output = result.stdout.trim(); - if (output) { - changedCount++; - if (changed.length < maxResults) { - changed.push(relPath); - } - if (outputParts.length < maxResults) { - outputParts.push(output); - } - } - } - } - } else { - // Single path (file or directory) - run sd directly - const sdArgs = [...sdBaseArgs, resolvedPath]; - const result = await runCommand(sdPath, sdArgs, resolvedPath, signal); - - if (result.aborted) { - throw new Error("Operation aborted"); - } - - if (result.exitCode !== 0) { - const errorMsg = - result.stderr.trim() || result.stdout.trim() || `sd exited with code ${result.exitCode}`; - throw new Error(errorMsg); - } - - const output = result.stdout.trim(); - if (output) { - outputParts.push(output); - // Extract changed files from output (sd prefixes lines with file paths) - const fileMatches = output.match(/^[^\s:]+:/gm); - if (fileMatches) { - const seen = new Set(); - for (const match of fileMatches) { - const file = match.slice(0, -1); // Remove trailing colon - if (!seen.has(file)) { - seen.add(file); - changedCount++; - if (changed.length < maxResults) { - changed.push(file); - } - } - } - } - } - } - - const truncated = changedCount > maxResults || failedCount > maxResults; - const details: ReplaceToolDetails = { - filesChanged: changedCount, - filesFailed: failedCount, - preview, - changed, - failed, - truncated, - }; - - // Build output text - let outputText: string; - if (changedCount === 0 && failedCount === 0) { - outputText = preview ? "No changes would be made" : "No changes made"; - } else { - const parts: string[] = []; - - if (changedCount > 0) { - const verb = preview ? "would change" : "changed"; - parts.push(`${verb} ${changedCount} file${changedCount !== 1 ? "s" : ""}`); - } - - if (failedCount > 0) { - parts.push(`${failedCount} file${failedCount !== 1 ? "s" : ""} failed`); - } - - outputText = parts.join(", "); - - if (outputParts.length > 0) { - outputText += `\n\n${outputParts.join("\n\n")}`; - } - - if (failedCount > 0) { - outputText += "\n\nErrors:\n"; - for (const f of failed) { - outputText += ` ${f.file}: ${f.error}\n`; - } - } - if (truncated) { - outputText += `\n... showing first ${maxResults} files`; - } - } - - return { - content: [{ type: "text", text: outputText }], - details, - }; - }, - }; -} - -/** Default replace tool using process.cwd() - for backwards compatibility */ -export const replaceTool = createReplaceTool(process.cwd()); diff --git a/packages/coding-agent/src/core/tools/task/discovery.ts b/packages/coding-agent/src/core/tools/task/discovery.ts index 3ec952cc5..ef1f138d6 100644 --- a/packages/coding-agent/src/core/tools/task/discovery.ts +++ b/packages/coding-agent/src/core/tools/task/discovery.ts @@ -14,7 +14,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { loadBundledAgents } from "./agents.js"; -import type { AgentDefinition, AgentScope, AgentSource } from "./types.js"; +import type { AgentDefinition, AgentSource } from "./types.js"; /** Result of agent discovery */ export interface DiscoveryResult { @@ -153,12 +153,12 @@ function findNearestDir(cwd: string, relPath: string): string | null { /** * Discover agents from filesystem and merge with bundled agents. * - * Precedence: project > user > bundled + * Precedence (highest wins): project > user > bundled + * Within each level: .pi > .claude * * @param cwd - Current working directory for project agent discovery - * @param scope - Which agents to discover: 'user', 'project', or 'both' */ -export function discoverAgents(cwd: string, scope: AgentScope): DiscoveryResult { +export function discoverAgents(cwd: string): DiscoveryResult { // Primary directories (.pi) const userPiDir = path.join(os.homedir(), ".pi", "agent", "agents"); const projectPiDir = findNearestDir(cwd, ".pi/agents"); @@ -169,36 +169,28 @@ export function discoverAgents(cwd: string, scope: AgentScope): DiscoveryResult const agentMap = new Map(); - // Start with bundled agents (lowest priority) + // 1. Bundled agents (lowest priority) for (const agent of loadBundledAgents()) { agentMap.set(agent.name, agent); } - // Load user agents if scope includes user - if (scope === "user" || scope === "both") { - // .claude first (lower priority within user) - for (const agent of loadAgentsFromDir(userClaudeDir, "user")) { - agentMap.set(agent.name, agent); - } - // .pi second (higher priority within user) - for (const agent of loadAgentsFromDir(userPiDir, "user")) { + // 2. User agents (.claude then .pi - .pi overrides .claude) + for (const agent of loadAgentsFromDir(userClaudeDir, "user")) { + agentMap.set(agent.name, agent); + } + for (const agent of loadAgentsFromDir(userPiDir, "user")) { + agentMap.set(agent.name, agent); + } + + // 3. Project agents (highest priority - .claude then .pi) + if (projectClaudeDir) { + for (const agent of loadAgentsFromDir(projectClaudeDir, "project")) { agentMap.set(agent.name, agent); } } - - // Load project agents if scope includes project - if (scope === "project" || scope === "both") { - // .claude first (lower priority within project) - if (projectClaudeDir) { - for (const agent of loadAgentsFromDir(projectClaudeDir, "project")) { - agentMap.set(agent.name, agent); - } - } - // .pi second (higher priority within project) - if (projectPiDir) { - for (const agent of loadAgentsFromDir(projectPiDir, "project")) { - agentMap.set(agent.name, agent); - } + if (projectPiDir) { + for (const agent of loadAgentsFromDir(projectPiDir, "project")) { + agentMap.set(agent.name, agent); } } diff --git a/packages/coding-agent/src/core/tools/task/index.ts b/packages/coding-agent/src/core/tools/task/index.ts index d556be793..e5eccf600 100644 --- a/packages/coding-agent/src/core/tools/task/index.ts +++ b/packages/coding-agent/src/core/tools/task/index.ts @@ -19,10 +19,9 @@ import { cleanupTempDir, createTempArtifactsDir, getArtifactsDir, writeArtifacts import { discoverAgents, getAgent } from "./discovery.js"; import { runSubprocess } from "./executor.js"; import { mapWithConcurrencyLimit } from "./parallel.js"; -import { renderCall, renderResult } from "./render.js"; +import { formatDuration, renderCall, renderResult } from "./render.js"; import { type AgentProgress, - type AgentScope, MAX_AGENTS_IN_DESCRIPTION, MAX_CONCURRENCY, MAX_PARALLEL_TASKS, @@ -41,64 +40,125 @@ interface SessionContext { export { loadBundledAgents as BUNDLED_AGENTS } from "./agents.js"; export { discoverCommands, expandCommand, getCommand } from "./commands.js"; export { discoverAgents, getAgent } from "./discovery.js"; -export type { AgentDefinition, AgentProgress, AgentScope, SingleResult, TaskParams, TaskToolDetails } from "./types.js"; +export type { AgentDefinition, AgentProgress, SingleResult, TaskParams, TaskToolDetails } from "./types.js"; export { taskSchema } from "./types.js"; /** * Build dynamic tool description listing available agents. */ function buildDescription(cwd: string): string { - const { agents, projectAgentsDir } = discoverAgents(cwd, "both"); + const { agents } = discoverAgents(cwd); - // Group agents by source - const bundled = agents.filter((a) => a.source === "bundled"); - const user = agents.filter((a) => a.source === "user"); - const project = agents.filter((a) => a.source === "project"); + const lines: string[] = []; - const lines: string[] = ["Spawn a sub-agent to handle complex tasks. Each agent runs in an isolated context.", ""]; - - // Bundled agents - if (bundled.length > 0) { - lines.push("**Bundled agents:**"); - for (const agent of bundled.slice(0, MAX_AGENTS_IN_DESCRIPTION)) { - const tools = agent.tools ? ` (${agent.tools.join(", ")})` : ""; - lines.push(`- \`${agent.name}\`: ${agent.description}${tools}`); - } - lines.push(""); - } - - // User agents - if (user.length > 0) { - lines.push("**User agents (~/.pi/agent/agents/):**"); - for (const agent of user.slice(0, MAX_AGENTS_IN_DESCRIPTION)) { - lines.push(`- \`${agent.name}\`: ${agent.description}`); - } - if (user.length > MAX_AGENTS_IN_DESCRIPTION) { - lines.push(`- ... and ${user.length - MAX_AGENTS_IN_DESCRIPTION} more`); - } - lines.push(""); - } - - // Project agents - if (project.length > 0) { - const dir = projectAgentsDir || ".pi/agents/"; - lines.push(`**Project agents (${dir}):**`); - for (const agent of project.slice(0, MAX_AGENTS_IN_DESCRIPTION)) { - lines.push(`- \`${agent.name}\`: ${agent.description}`); - } - if (project.length > MAX_AGENTS_IN_DESCRIPTION) { - lines.push(`- ... and ${project.length - MAX_AGENTS_IN_DESCRIPTION} more`); - } - lines.push(""); - } - - // Usage - lines.push("**Usage:**"); - lines.push("- Single: `{ agent: 'explore', prompt: 'find auth code' }`"); - lines.push("- Parallel: `{ tasks: [{ agent: 'explore', task: '...' }, ...] }`"); - lines.push("- With context: `{ context: 'shared info', tasks: [...] }`"); + lines.push("Launch a new agent to handle complex, multi-step tasks autonomously."); lines.push(""); - lines.push("**When NOT to use:** For simple file reads, use Read directly."); + lines.push( + "The Task tool launches specialized agents (subprocesses) that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it.", + ); + lines.push(""); + lines.push("Available agent types and the tools they have access to:"); + + for (const agent of agents.slice(0, MAX_AGENTS_IN_DESCRIPTION)) { + const tools = agent.tools?.join(", ") || "All tools"; + lines.push(`- ${agent.name}: ${agent.description} (Tools: ${tools})`); + } + if (agents.length > MAX_AGENTS_IN_DESCRIPTION) { + lines.push(` ...and ${agents.length - MAX_AGENTS_IN_DESCRIPTION} more agents`); + } + + lines.push(""); + lines.push("When NOT to use the Task tool:"); + lines.push( + "- If you want to read a specific file path, use the Read or Glob tool instead of the Task tool, to find the match more quickly", + ); + lines.push( + '- If you are searching for a specific class definition like "class Foo", use the Glob tool instead, to find the match more quickly', + ); + lines.push( + "- If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead of the Task tool, to find the match more quickly", + ); + lines.push("- Other tasks that are not related to the agent descriptions above"); + lines.push(""); + lines.push(""); + lines.push("Usage notes:"); + lines.push("- Always include a short description of the task in the task parameter"); + lines.push("- Launch multiple agents concurrently whenever possible, to maximize performance"); + lines.push( + "- When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.", + ); + lines.push( + "- Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your task should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.", + ); + lines.push( + "- IMPORTANT: Agent results are intermediate data, not task completions. Use the agent's findings to continue executing the user's request. Do not treat agent reports as 'task complete' signals - they provide context for you to perform the actual work.", + ); + lines.push("- The agent's outputs should generally be trusted"); + lines.push( + "- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent", + ); + lines.push( + "- If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.", + ); + lines.push(""); + lines.push("Parameters:"); + lines.push( + `- tasks: Array of {agent, task, model?} - tasks to run in parallel (max ${MAX_PARALLEL_TASKS}, ${MAX_CONCURRENCY} concurrent)`, + ); + lines.push( + ' - model: (optional) Override the agent\'s default model with fuzzy matching (e.g., "sonnet", "codex", "5.2"). Supports comma-separated fallbacks: "gpt, opus" tries gpt first, then opus. Use "default" for pi\'s default model', + ); + lines.push( + "- context: (optional) Shared context string prepended to all task prompts - use this to avoid repeating instructions", + ); + lines.push(""); + lines.push("Results are always written to {tempdir}/pi-task-{runId}/task_{agent}_{index}.md"); + lines.push(""); + lines.push("Example usage:"); + lines.push(""); + lines.push(""); + lines.push('"code-reviewer": use this agent after you are done writing a significant piece of code'); + lines.push('"explore": use this agent for fast codebase exploration and research'); + lines.push(""); + lines.push(""); + lines.push(""); + lines.push('user: "Please write a function that checks if a number is prime"'); + lines.push("assistant: Sure let me write a function that checks if a number is prime"); + lines.push("assistant: I'm going to use the Write tool to write the following code:"); + lines.push(""); + lines.push("function isPrime(n) {"); + lines.push(" if (n <= 1) return false"); + lines.push(" for (let i = 2; i * i <= n; i++) {"); + lines.push(" if (n % i === 0) return false"); + lines.push(" }"); + lines.push(" return true"); + lines.push("}"); + lines.push(""); + lines.push(""); + lines.push( + "Since a significant piece of code was written and the task was completed, now use the code-reviewer agent to review the code", + ); + lines.push(""); + lines.push("assistant: Now let me use the code-reviewer agent to review the code"); + lines.push( + 'assistant: Uses the Task tool: { tasks: [{ agent: "code-reviewer", task: "Review the isPrime function" }] }', + ); + lines.push(""); + lines.push(""); + lines.push(""); + lines.push('user: "Find all TODO comments in the codebase"'); + lines.push("assistant: I'll use multiple explore agents to search different directories in parallel"); + lines.push("assistant: Uses the Task tool:"); + lines.push("{"); + lines.push(' "context": "Find all TODO comments. Return file:line:content format.",'); + lines.push(' "tasks": ['); + lines.push(' { "agent": "explore", "task": "Search in src/" },'); + lines.push(' { "agent": "explore", "task": "Search in lib/" },'); + lines.push(' { "agent": "explore", "task": "Search in tests/" }'); + lines.push(" ]"); + lines.push("}"); + lines.push("Results → {tempdir}/pi-task-{runId}/task_explore_*.md"); + lines.push(""); return lines.join("\n"); } @@ -120,8 +180,6 @@ export function createTaskTool( execute: async () => ({ content: [{ type: "text", text: "Sub-agents are disabled for this agent (recursion prevention)." }], details: { - mode: "single", - agentScope: "both", projectAgentsDir: null, results: [], totalDurationMs: 0, @@ -139,8 +197,43 @@ export function createTaskTool( renderResult, execute: async (_toolCallId, params, signal, onUpdate) => { const startTime = Date.now(); - const agentScope: AgentScope = (params.agentScope as AgentScope) || "both"; - const { agents, projectAgentsDir } = discoverAgents(cwd, agentScope); + const { agents, projectAgentsDir } = discoverAgents(cwd); + const context = params.context; + + // Handle empty or missing tasks + if (!params.tasks || params.tasks.length === 0) { + const available = agents.map((a) => a.name).join(", ") || "none"; + return { + content: [ + { + type: "text", + text: `No tasks provided. Use: { tasks: [{agent, task}, ...] }\nAvailable agents: ${available}`, + }, + ], + details: { + projectAgentsDir, + results: [], + totalDurationMs: 0, + }, + }; + } + + // Validate task count + if (params.tasks.length > MAX_PARALLEL_TASKS) { + return { + content: [ + { + type: "text", + text: `Too many tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`, + }, + ], + details: { + projectAgentsDir, + results: [], + totalDurationMs: 0, + }, + }; + } // Derive artifacts directory const sessionFile = sessionContext?.getSessionFile() ?? null; @@ -148,9 +241,6 @@ export function createTaskTool( const tempArtifactsDir = artifactsDir ? null : createTempArtifactsDir(); const effectiveArtifactsDir = artifactsDir || tempArtifactsDir!; - // Determine mode - const isParallel = params.tasks && params.tasks.length > 0; - // Initialize progress tracking const progressMap = new Map(); @@ -158,10 +248,8 @@ export function createTaskTool( const emitProgress = () => { const progress = Array.from(progressMap.values()).sort((a, b) => a.index - b.index); onUpdate?.({ - content: [{ type: "text", text: "Running..." }], + content: [{ type: "text", text: `Running ${params.tasks.length} agents...` }], details: { - mode: isParallel ? "parallel" : "single", - agentScope, projectAgentsDir, results: [], totalDurationMs: Date.now() - startTime, @@ -171,158 +259,74 @@ export function createTaskTool( }; try { - let results: SingleResult[]; + const tasks = params.tasks; - if (isParallel) { - // Parallel mode - const tasks = params.tasks!; - - // Validate task count - if (tasks.length > MAX_PARALLEL_TASKS) { - return { - content: [ - { - type: "text", - text: `Error: Maximum ${MAX_PARALLEL_TASKS} tasks allowed, got ${tasks.length}`, - }, - ], - details: { - mode: "parallel", - agentScope, - projectAgentsDir, - results: [], - totalDurationMs: Date.now() - startTime, - }, - }; - } - - // Validate all agents exist - for (const task of tasks) { - if (!getAgent(agents, task.agent)) { - const available = agents.map((a) => a.name).join(", "); - return { - content: [{ type: "text", text: `Unknown agent: ${task.agent}. Available: ${available}` }], - details: { - mode: "parallel", - agentScope, - projectAgentsDir, - results: [], - totalDurationMs: Date.now() - startTime, - }, - }; - } - } - - // Initialize progress for all tasks - for (let i = 0; i < tasks.length; i++) { - progressMap.set(i, { - index: i, - agent: tasks[i].agent, - agentSource: getAgent(agents, tasks[i].agent)!.source, - status: "pending", - task: tasks[i].task, - recentTools: [], - recentOutput: [], - toolCount: 0, - tokens: 0, - durationMs: 0, - modelOverride: tasks[i].model, - }); - } - emitProgress(); - - // Execute in parallel with concurrency limit - results = await mapWithConcurrencyLimit(tasks, MAX_CONCURRENCY, async (task, index) => { - const agent = getAgent(agents, task.agent)!; - return runSubprocess({ - cwd, - agent, - task: task.task, - index, - context: params.context, - modelOverride: task.model, - sessionFile, - persistArtifacts: !!artifactsDir, - artifactsDir: effectiveArtifactsDir, - signal, - onProgress: (progress) => { - progressMap.set(index, progress); - emitProgress(); - }, - }); - }); - } else { - // Single mode - const agentName = params.agent || "task"; - const agent = getAgent(agents, agentName); - - if (!agent) { + // Validate all agents exist + for (const task of tasks) { + if (!getAgent(agents, task.agent)) { const available = agents.map((a) => a.name).join(", "); return { - content: [{ type: "text", text: `Unknown agent: ${agentName}. Available: ${available}` }], + content: [{ type: "text", text: `Unknown agent: ${task.agent}. Available: ${available}` }], details: { - mode: "single", - agentScope, projectAgentsDir, results: [], totalDurationMs: Date.now() - startTime, }, }; } + } - if (!params.prompt) { - return { - content: [{ type: "text", text: "Error: 'prompt' is required for single agent mode" }], - details: { - mode: "single", - agentScope, - projectAgentsDir, - results: [], - totalDurationMs: Date.now() - startTime, - }, - }; - } - - // Initialize progress - progressMap.set(0, { - index: 0, - agent: agentName, - agentSource: agent.source, + // Initialize progress for all tasks + for (let i = 0; i < tasks.length; i++) { + const agentCfg = getAgent(agents, tasks[i].agent); + progressMap.set(i, { + index: i, + agent: tasks[i].agent, + agentSource: agentCfg?.source ?? "user", status: "pending", - task: params.prompt, + task: tasks[i].task, recentTools: [], recentOutput: [], toolCount: 0, tokens: 0, durationMs: 0, - modelOverride: params.model, + modelOverride: tasks[i].model, }); - emitProgress(); + } + emitProgress(); - const result = await runSubprocess({ + // Build full prompts with context prepended + const tasksWithContext = tasks.map((t) => ({ + agent: t.agent, + task: context ? `${context}\n\n${t.task}` : t.task, + model: t.model, + })); + + // Execute in parallel with concurrency limit + const results = await mapWithConcurrencyLimit(tasksWithContext, MAX_CONCURRENCY, async (task, index) => { + const agent = getAgent(agents, task.agent)!; + return runSubprocess({ cwd, agent, - task: params.prompt, - index: 0, - context: params.context, - modelOverride: params.model, + task: task.task, + index, + context: undefined, // Already prepended above + modelOverride: task.model, sessionFile, persistArtifacts: !!artifactsDir, artifactsDir: effectiveArtifactsDir, signal, onProgress: (progress) => { - progressMap.set(0, progress); + progressMap.set(index, progress); emitProgress(); }, }); - - results = [result]; - } + }); // Write artifacts const outputPaths: string[] = []; for (const result of results) { - const fullTask = params.context ? `${params.context}\n\n${result.task}` : result.task; + const fullTask = context ? `${context}\n\n${result.task}` : result.task; const paths = await writeArtifacts( effectiveArtifactsDir, result.agent, @@ -335,25 +339,18 @@ export function createTaskTool( result.artifactPaths = paths; } - // Build final output + // Build final output - match plugin format const successCount = results.filter((r) => r.exitCode === 0).length; - const failCount = results.length - successCount; + const totalDuration = Date.now() - startTime; - let summary: string; - if (results.length === 1) { - const r = results[0]; - summary = r.exitCode === 0 ? r.output : `Error: ${r.error || r.stderr || "Unknown error"}`; - } else { - summary = `Completed ${successCount}/${results.length} tasks`; - if (failCount > 0) { - summary += ` (${failCount} failed)`; - } - summary += "\n\n"; - for (const r of results) { - const status = r.exitCode === 0 ? "✓" : "✗"; - summary += `${status} ${r.agent}: ${r.output.split("\n")[0] || "(no output)"}\n`; - } - } + const summaries = results.map((r, i) => { + const status = r.exitCode === 0 ? "completed" : `failed (exit ${r.exitCode})`; + const output = r.output.trim() || r.stderr.trim() || "(no output)"; + const preview = output.split("\n").slice(0, 5).join("\n"); + return `[${r.agent}] ${status} → ${outputPaths[i]}\n${preview}`; + }); + + const summary = `${successCount}/${results.length} succeeded [${formatDuration(totalDuration)}]\n\n${summaries.join("\n\n---\n\n")}`; // Cleanup temp directory if used if (tempArtifactsDir) { @@ -363,11 +360,9 @@ export function createTaskTool( return { content: [{ type: "text", text: summary }], details: { - mode: isParallel ? "parallel" : "single", - agentScope, projectAgentsDir, results, - totalDurationMs: Date.now() - startTime, + totalDurationMs: totalDuration, outputPaths, }, }; @@ -380,8 +375,6 @@ export function createTaskTool( return { content: [{ type: "text", text: `Task execution failed: ${err}` }], details: { - mode: isParallel ? "parallel" : "single", - agentScope, projectAgentsDir, results: [], totalDurationMs: Date.now() - startTime, diff --git a/packages/coding-agent/src/core/tools/task/render.ts b/packages/coding-agent/src/core/tools/task/render.ts index a49c43cc5..b9856d095 100644 --- a/packages/coding-agent/src/core/tools/task/render.ts +++ b/packages/coding-agent/src/core/tools/task/render.ts @@ -25,7 +25,7 @@ function formatTokens(tokens: number): string { /** * Format duration for display. */ -function formatDuration(ms: number): string { +export function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; return `${(ms / 60000).toFixed(1)}m`; diff --git a/packages/coding-agent/src/core/tools/task/types.ts b/packages/coding-agent/src/core/tools/task/types.ts index 43daf65d2..fd6a795df 100644 --- a/packages/coding-agent/src/core/tools/task/types.ts +++ b/packages/coding-agent/src/core/tools/task/types.ts @@ -1,9 +1,5 @@ -import { StringEnum } from "@oh-my-pi/pi-ai"; import { type Static, Type } from "@sinclair/typebox"; -/** Scope for agent discovery */ -export type AgentScope = "user" | "project" | "both"; - /** Source of an agent definition */ export type AgentSource = "bundled" | "user" | "project"; @@ -36,27 +32,11 @@ export const PI_NO_SUBAGENTS_ENV = "PI_NO_SUBAGENTS"; /** Task tool parameters */ export const taskSchema = Type.Object({ - // Single mode - prompt: Type.Optional(Type.String({ description: "Task description for the sub-agent (single mode)" })), - agent: Type.Optional(Type.String({ description: "Agent name (defaults to 'task')" })), - model: Type.Optional(Type.String({ description: "Model override (fuzzy pattern like 'haiku' or 'opus')" })), - - // Parallel mode - tasks: Type.Optional( - Type.Array(taskItemSchema, { - description: "Array of tasks to run in parallel", - maxItems: MAX_PARALLEL_TASKS, - }), - ), - - // Common context: Type.Optional(Type.String({ description: "Shared context prepended to all task prompts" })), - agentScope: Type.Optional( - StringEnum(["user", "project", "both"], { - description: "Agent discovery scope: user (~/.pi), project (.pi), or both", - }), - ), - background: Type.Optional(Type.Boolean({ description: "Run in background" })), + tasks: Type.Array(taskItemSchema, { + description: "Tasks to run in parallel", + maxItems: MAX_PARALLEL_TASKS, + }), }); export type TaskParams = Static; @@ -111,8 +91,6 @@ export interface SingleResult { /** Tool details for TUI rendering */ export interface TaskToolDetails { - mode: "single" | "parallel"; - agentScope: AgentScope; projectAgentsDir: string | null; results: SingleResult[]; totalDurationMs: number; diff --git a/packages/coding-agent/src/core/tools/web-fetch.ts b/packages/coding-agent/src/core/tools/web-fetch.ts index 1000ee62f..088eb26c2 100644 --- a/packages/coding-agent/src/core/tools/web-fetch.ts +++ b/packages/coding-agent/src/core/tools/web-fetch.ts @@ -823,6 +823,67 @@ async function renderGitHubIssuesList(gh: GitHubUrl, timeout: number): Promise<{ return { content: md, ok: true }; } +/** + * Render GitHub tree (directory) to markdown + */ +async function renderGitHubTree(gh: GitHubUrl, timeout: number): Promise<{ content: string; ok: boolean }> { + // Fetch repo info first to get default branch if ref not specified + const repoResult = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}`, timeout); + if (!repoResult.ok) return { content: "", ok: false }; + + const repo = repoResult.data as { + full_name: string; + default_branch: string; + }; + + const ref = gh.ref || repo.default_branch; + const dirPath = gh.path || ""; + + let md = `# ${repo.full_name}/${dirPath || "(root)"}\n\n`; + md += `**Branch:** ${ref}\n\n`; + + // Fetch directory contents + const contentsResult = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}/contents/${dirPath}?ref=${ref}`, timeout); + + if (contentsResult.ok && Array.isArray(contentsResult.data)) { + const items = contentsResult.data as Array<{ + name: string; + type: "file" | "dir" | "symlink" | "submodule"; + size?: number; + path: string; + }>; + + // Sort: directories first, then files, alphabetically + items.sort((a, b) => { + if (a.type === "dir" && b.type !== "dir") return -1; + if (a.type !== "dir" && b.type === "dir") return 1; + return a.name.localeCompare(b.name); + }); + + md += `## Contents\n\n`; + md += "```\n"; + for (const item of items) { + const prefix = item.type === "dir" ? "[dir] " : " "; + const size = item.size ? ` (${item.size} bytes)` : ""; + md += `${prefix}${item.name}${item.type === "file" ? size : ""}\n`; + } + md += "```\n\n"; + + // Look for README in this directory + const readmeFile = items.find((item) => item.type === "file" && /^readme\.md$/i.test(item.name)); + if (readmeFile) { + const readmePath = dirPath ? `${dirPath}/${readmeFile.name}` : readmeFile.name; + const rawUrl = `https://raw.githubusercontent.com/${gh.owner}/${gh.repo}/refs/heads/${ref}/${readmePath}`; + const readmeResult = await loadPage(rawUrl, { timeout }); + if (readmeResult.ok) { + md += `---\n\n## README\n\n${readmeResult.content}`; + } + } + } + + return { content: md, ok: true }; +} + /** * Render GitHub repo to markdown (file list + README) */ @@ -913,6 +974,25 @@ async function handleGitHub(url: string, timeout: number): Promise perplexity > anthropic) */ async function detectProvider(): Promise { - // Perplexity takes priority if key exists (more specialized) + // Exa takes highest priority if key exists + const exaKey = await findExaKey(); + if (exaKey) return "exa"; + + // Perplexity second priority const perplexityKey = await findPerplexityKey(); if (perplexityKey) return "perplexity"; @@ -125,13 +130,18 @@ function formatForLLM(response: WebSearchResponse): string { /** Execute web search */ async function executeWebSearch( _toolCallId: string, - params: WebSearchParams, + params: WebSearchParams ): Promise<{ content: Array<{ type: "text"; text: string }>; details: WebSearchRenderDetails }> { try { const provider = params.provider ?? (await detectProvider()); let response: WebSearchResponse; - if (provider === "anthropic") { + if (provider === "exa") { + response = await searchExa({ + query: params.query, + num_results: params.num_results, + }); + } else if (provider === "anthropic") { response = await searchAnthropic({ query: params.query, system_prompt: params.system_prompt, @@ -166,13 +176,16 @@ async function executeWebSearch( } } -const WEB_SEARCH_DESCRIPTION = `Search the web using Anthropic or Perplexity. Returns synthesized answers with citations. - -Provider auto-detected by API key presence, or specify explicitly. +const WEB_SEARCH_DESCRIPTION = `Allows Pi to search the web and use the results to inform responses +- Provides up-to-date information for current events and recent data +- Returns search result information formatted as search result blocks, including links as markdown hyperlinks +- Use this tool for accessing information beyond Claude's knowledge cutoff +- Searches are performed automatically within a single API call Common: system_prompt (guides response style) Anthropic-specific: max_tokens -Perplexity-specific: model (sonar/sonar-pro), search_recency_filter, search_domain_filter, search_context_size, return_related_questions`; +Perplexity-specific: model (sonar/sonar-pro), search_recency_filter, search_domain_filter, search_context_size, return_related_questions +Exa-specific: num_results`; /** Web search tool as AgentTool (for allTools export) */ export const webSearchTool: AgentTool = { @@ -197,7 +210,7 @@ export const webSearchCustomTool: CustomTool> { + const result: Record = {}; + try { + const file = Bun.file(filePath); + if (!(await file.exists())) return result; + + const content = await file.text(); + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const eqIndex = trimmed.indexOf("="); + if (eqIndex === -1) continue; + + const key = trimmed.slice(0, eqIndex).trim(); + let value = trimmed.slice(eqIndex + 1).trim(); + + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + + result[key] = value; + } + } catch { + // Ignore read errors + } + return result; +} + +/** Find EXA_API_KEY from environment or .env files */ +export async function findApiKey(): Promise { + // 1. Check environment variable + if (process.env.EXA_API_KEY) { + return process.env.EXA_API_KEY; + } + + // 2. Check .env in current directory + const localEnv = await parseEnvFile(`${process.cwd()}/.env`); + if (localEnv.EXA_API_KEY) { + return localEnv.EXA_API_KEY; + } + + // 3. Check ~/.env + const homeEnv = await parseEnvFile(`${os.homedir()}/.env`); + if (homeEnv.EXA_API_KEY) { + return homeEnv.EXA_API_KEY; + } + + return null; +} + +/** Parse SSE response format */ +function parseSSE(text: string): unknown { + const lines = text.split("\n"); + for (const line of lines) { + if (line.startsWith("data: ")) { + const data = line.slice(6).trim(); + if (data === "[DONE]") continue; + try { + return JSON.parse(data); + } catch { + // Try next line + } + } + } + // Fallback: try parsing entire response as JSON + try { + return JSON.parse(text); + } catch { + return null; + } +} + +interface MCPCallResponse { + result?: { + content?: Array<{ type: string; text?: string }>; + }; + error?: { + code: number; + message: string; + }; +} + +interface ExaSearchResult { + title?: string; + url?: string; + author?: string; + publishedDate?: string; + text?: string; + highlights?: string[]; +} + +interface ExaSearchResponse { + results?: ExaSearchResult[]; + costDollars?: { total: number }; + searchTime?: number; +} + +/** Call Exa MCP API */ +async function callExaMCP(apiKey: string, toolName: string, args: Record): Promise { + const url = `${EXA_MCP_URL}?exaApiKey=${encodeURIComponent(apiKey)}&tools=${encodeURIComponent(toolName)}`; + + const body = { + jsonrpc: "2.0", + id: Math.random().toString(36).slice(2), + method: "tools/call", + params: { + name: toolName, + arguments: args, + }, + }; + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Exa MCP error (${response.status}): ${errorText}`); + } + + const text = await response.text(); + const result = parseSSE(text); + + if (!result) { + throw new Error("Failed to parse Exa MCP response"); + } + + return result as MCPCallResponse; +} + +/** Parse MCP response content into ExaSearchResponse */ +function parseMCPContent(content: Array<{ type: string; text?: string }>): ExaSearchResponse | null { + for (const block of content) { + if (block.type === "text" && block.text) { + // Try to parse as JSON first + try { + return JSON.parse(block.text) as ExaSearchResponse; + } catch { + // Parse markdown format + return parseExaMarkdown(block.text); + } + } + } + return null; +} + +/** Parse Exa markdown format into ExaSearchResponse */ +function parseExaMarkdown(text: string): ExaSearchResponse | null { + const results: ExaSearchResult[] = []; + const lines = text.split("\n"); + let currentResult: Partial | null = null; + + for (const line of lines) { + const trimmed = line.trim(); + + // Match result header: ## Title + if (trimmed.startsWith("## ")) { + if (currentResult?.title) { + results.push(currentResult as ExaSearchResult); + } + currentResult = { title: trimmed.slice(3).trim() }; + continue; + } + + if (!currentResult) continue; + + // Match URL: **URL:** ... + if (trimmed.startsWith("**URL:**")) { + currentResult.url = trimmed.slice(8).trim(); + continue; + } + + // Match Author: **Author:** ... + if (trimmed.startsWith("**Author:**")) { + currentResult.author = trimmed.slice(11).trim(); + continue; + } + + // Match Published Date: **Published Date:** ... + if (trimmed.startsWith("**Published Date:**")) { + currentResult.publishedDate = trimmed.slice(19).trim(); + continue; + } + + // Match Text: **Text:** ... + if (trimmed.startsWith("**Text:**")) { + currentResult.text = trimmed.slice(9).trim(); + } + } + + // Add last result + if (currentResult?.title) { + results.push(currentResult as ExaSearchResult); + } + + if (results.length === 0) return null; + + return { results }; +} + +/** Calculate age in seconds from ISO date string */ +function dateToAgeSeconds(dateStr: string | undefined): number | undefined { + if (!dateStr) return undefined; + try { + const date = new Date(dateStr); + if (Number.isNaN(date.getTime())) return undefined; + return Math.floor((Date.now() - date.getTime()) / 1000); + } catch { + return undefined; + } +} + +/** Execute Exa web search */ +export async function searchExa(params: ExaSearchParams): Promise { + const apiKey = await findApiKey(); + if (!apiKey) { + throw new Error("EXA_API_KEY not found. Set it in environment or .env file."); + } + + const args: Record = { + query: params.query, + num_results: params.num_results ?? 10, + type: params.type ?? "auto", + text: true, // Include text for richer results + highlights: true, + }; + + if (params.include_domains?.length) { + args.include_domains = params.include_domains; + } + if (params.exclude_domains?.length) { + args.exclude_domains = params.exclude_domains; + } + if (params.start_published_date) { + args.start_published_date = params.start_published_date; + } + if (params.end_published_date) { + args.end_published_date = params.end_published_date; + } + + const response = await callExaMCP(apiKey, "web_search", args); + + if (response.error) { + throw new Error(`Exa MCP error: ${response.error.message}`); + } + + const exaResponse = response.result?.content ? parseMCPContent(response.result.content) : null; + + // Convert to unified WebSearchResponse + const sources: WebSearchSource[] = []; + + if (exaResponse?.results) { + for (const result of exaResponse.results) { + if (!result.url) continue; + sources.push({ + title: result.title ?? result.url, + url: result.url, + snippet: result.text ?? result.highlights?.join(" "), + publishedDate: result.publishedDate, + ageSeconds: dateToAgeSeconds(result.publishedDate), + author: result.author, + }); + } + } + + // Apply num_results limit if specified + const limitedSources = params.num_results ? sources.slice(0, params.num_results) : sources; + + return { + provider: "exa", + sources: limitedSources, + }; +} diff --git a/packages/coding-agent/src/core/tools/web-search/types.ts b/packages/coding-agent/src/core/tools/web-search/types.ts index d791b9ecb..81d9a9a5f 100644 --- a/packages/coding-agent/src/core/tools/web-search/types.ts +++ b/packages/coding-agent/src/core/tools/web-search/types.ts @@ -5,7 +5,7 @@ */ /** Supported web search providers */ -export type WebSearchProvider = "anthropic" | "perplexity"; +export type WebSearchProvider = "exa" | "anthropic" | "perplexity"; /** Source returned by search (all providers) */ export interface WebSearchSource { diff --git a/packages/coding-agent/src/core/tools/write.ts b/packages/coding-agent/src/core/tools/write.ts index b34545260..a1d5e980c 100644 --- a/packages/coding-agent/src/core/tools/write.ts +++ b/packages/coding-agent/src/core/tools/write.ts @@ -12,9 +12,15 @@ const writeSchema = Type.Object({ export function createWriteTool(cwd: string): AgentTool { return { name: "write", - label: "write", - description: - "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.", + label: "Write", + description: `Writes a file to the local filesystem. + +Usage: +- This tool will overwrite the existing file if there is one at the provided path. +- If this is an existing file, you MUST use the read tool first to read the file's contents. This tool will fail if you did not read the file first. +- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. +- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. +- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.`, parameters: writeSchema, execute: async ( _toolCallId: string,