refactor(coding-agent): simplified toolset and enhanced navigation

- Removed ast and replace tools to simplify toolset and reduce maintenance overhead.
- Added LSP call hierarchy support (incoming_calls, outgoing_calls) for better code navigation.
- Added Exa as web search provider alongside Anthropic and Perplexity for broader search options.
- Enhanced tool descriptions with detailed usage guidelines to improve agent tool selection.
- Simplified task tool by removing single-mode execution and agentScope parameter.
- Added GitHub tree rendering and improved web-fetch documentation for better URL handling.
This commit is contained in:
can1357
2026-01-02 22:13:05 +01:00
parent 8ca26c58a1
commit fbae5cbb0d
25 changed files with 852 additions and 1173 deletions
+9
View File
@@ -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):")}
+10 -90
View File
@@ -63,7 +63,6 @@ ${commitsText}`;
/** Tool descriptions for system prompt */
const toolDescriptions: Record<ToolName, string> = {
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<ToolName, string> = {
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 <<EOF | edit or replace |
### Tool Preference Ladder (highest → lowest priority)
1. **lsp** — Go-to-definition, find references, hover info — DETERMINISTIC
2. **ast** — Structural code search/replace (function shapes, impl blocks, patterns)
3. **grep** — Text/regex search in file contents
4. **find** — Locate files by glob pattern
5. **read** — Read file contents (with offset/limit for large files)
6. **edit** — Precise text replacement (old text must exist exactly)
7. **replace** — Multi-file regex find/replace
8. **bash** — ONLY for commands that have no tool equivalent (git, npm, docker, make, cargo, etc.)
### LSP — Preferred for Semantic Queries
Use \`lsp\` instead of grep/bash when you need:
- **Where is X defined?** → \`lsp goToDefinition\`
- **What calls X?** → \`lsp findReferences\` or \`lsp incomingCalls\`
- **What does X call?** → \`lsp outgoingCalls\`
- **What type is X?** → \`lsp hover\`
- **What symbols are in this file?** → \`lsp documentSymbol\`
- **Find symbol across codebase** → \`lsp workspaceSymbol\`
LSP returns **precise, compiler-verified results**. Grep returns text matches that may include comments, strings, or false positives.
### AST Tool Patterns (ast-grep)
Use \`ast\` for patterns that grep cannot express:
**Rust examples:**
- Find unsafe blocks: \`unsafe { $$BODY }\`
- Find trait impls: \`impl $TRAIT for $TYPE { $$BODY }\`
- Find unwrap calls: \`$EXPR.unwrap()\`
- Find function signatures: \`fn $NAME($$PARAMS) -> 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 <<EOF");
if (hasEdit) lines.push("- **File editing**: Use `edit` instead of sed/awk/perl -pi/echo >/cat <<EOF");
lines.push("\n### Tool Preference (highest → lowest priority)");
const ladder: string[] = [];
if (hasLsp) ladder.push("lsp (go-to-definition, references, type info) — DETERMINISTIC");
if (hasAst) ladder.push("ast (structural code patterns)");
if (hasGrep) ladder.push("grep (text/regex search)");
if (hasFind) ladder.push("find (locate files by pattern)");
if (hasRead) ladder.push("read (view file contents)");
if (hasEdit) ladder.push("edit (precise text replacement)");
if (hasReplace) ladder.push("replace (multi-file find/replace)");
ladder.push("bash (ONLY for git, npm, docker, make, cargo, etc.)");
lines.push(ladder.map((t, i) => `${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
-279
View File
@@ -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<typeof astSchema> {
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<Uint8Array>).getReader();
const stderrReader = (child.stderr as ReadableStream<Uint8Array>).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<string>();
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());
+40 -5
View File
@@ -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<typeof bashSchema> {
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 <<EOF)
- Communication: Output text directly (NOT echo/printf)
- When issuing multiple commands:
- If the commands are independent and can run in parallel, make multiple bash tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two bash tool calls in parallel.
- If the commands depend on each other and must run sequentially, use a single bash call with '&&' to chain them together (e.g., \`git add . && git commit -m "message" && git push\`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead.
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of \`cd\`. You may use \`cd\` if the User explicitly requests it.`,
parameters: bashSchema,
execute: async (
_toolCallId: string,
+10 -3
View File
@@ -32,9 +32,16 @@ export interface EditToolDetails {
export function createEditTool(cwd: string): AgentTool<typeof editSchema> {
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,
@@ -82,6 +82,7 @@ export async function callMCP(url: string, method: string, params?: Record<strin
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
},
body: JSON.stringify(body),
});
+7 -4
View File
@@ -40,10 +40,13 @@ export interface FindToolDetails {
export function createFindTool(cwd: string): AgentTool<typeof findSchema> {
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,
+11 -4
View File
@@ -63,10 +63,17 @@ export interface GrepToolDetails {
export function createGrepTool(cwd: string): AgentTool<typeof grepSchema> {
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,
@@ -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<string, { tool: Tool; create: ToolFactory }> = {
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<string, { tool: Tool; create: ToolFactory }> = {
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",
+2 -4
View File
@@ -20,10 +20,8 @@ export interface LsToolDetails {
export function createLsTool(cwd: string): AgentTool<typeof lsSchema> {
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,
@@ -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<typeof lspSchema, LspToolD
return {
name: "lsp",
label: "LSP",
description: `Language server integration for code intelligence.
description: `Interact with Language Server Protocol (LSP) servers to get code intelligence features.
Standard operations:
- diagnostics: Get errors/warnings for a file
@@ -151,6 +154,8 @@ Standard operations:
- workspace_symbols: Search for symbols across the project
- rename: Rename a symbol across the codebase
- actions: List and apply code actions (quick fixes, refactors)
- incoming_calls: Find all callers of a function
- outgoing_calls: Find all functions called by a function
- status: Show active language servers
Rust-analyzer specific (require rust-analyzer):
@@ -573,6 +578,55 @@ Rust-analyzer specific (require rust-analyzer):
break;
}
case "incoming_calls":
case "outgoing_calls": {
// First, prepare the call hierarchy item at the cursor position
const prepareResult = (await sendRequest(client, "textDocument/prepareCallHierarchy", {
textDocument: { uri },
position,
})) as CallHierarchyItem[] | null;
if (!prepareResult || prepareResult.length === 0) {
output = "No callable symbol found at this position";
break;
}
const item = prepareResult[0];
if (action === "incoming_calls") {
const calls = (await sendRequest(client, "callHierarchy/incomingCalls", { item })) as
| CallHierarchyIncomingCall[]
| null;
if (!calls || calls.length === 0) {
output = `No callers found for "${item.name}"`;
} else {
const lines = calls.map((call) => {
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
// =====================================================================
@@ -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
// =============================================================================
@@ -49,9 +49,9 @@ function splitIntoLines(content: string): string[] {
export function createNotebookTool(cwd: string): AgentTool<typeof notebookSchema> {
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,
+20 -10
View File
@@ -44,13 +44,23 @@ export interface ReadToolDetails {
export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
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<typeof readSchema> {
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<typeof readSchema> {
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 };
}
@@ -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<FindArgs, FindToolDetails> = {
},
};
// ============================================================================
// Replace Renderer
// ============================================================================
interface ReplaceArgs {
pattern: string;
replacement: string;
path?: string;
glob?: string;
literal?: boolean;
dry_run?: boolean;
}
const replaceRenderer: ToolRenderer<ReplaceArgs, ReplaceToolDetails> = {
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<AstArgs, AstToolDetails> = {
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,
@@ -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<Uint8Array>).getReader();
const stderrReader = (child.stderr as ReadableStream<Uint8Array>).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<typeof replaceSchema> {
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<string>();
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());
@@ -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<string, AgentDefinition>();
// 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);
}
}
+195 -202
View File
@@ -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("<example_agent_descriptions>");
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("</example_agent_descriptions>");
lines.push("");
lines.push("<example>");
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("<code>");
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("</code>");
lines.push("<commentary>");
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("</commentary>");
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("</example>");
lines.push("");
lines.push("<example>");
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("</example>");
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<number, AgentProgress>();
@@ -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,
@@ -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`;
@@ -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<typeof taskSchema>;
@@ -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;
@@ -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<RenderResult
break;
}
case "tree": {
notes.push(`Fetched via GitHub API`);
const result = await renderGitHubTree(gh, timeout);
if (result.ok) {
const output = finalizeOutput(result.content);
return {
url,
finalUrl: url,
contentType: "text/markdown",
method: "github-tree",
content: output.content,
fetchedAt,
truncated: output.truncated,
notes,
};
}
break;
}
case "issue":
case "pull": {
notes.push(`Fetched via GitHub API`);
@@ -2049,7 +2129,12 @@ export function createWebFetchTool(_cwd: string): AgentTool<typeof webFetchSchem
return {
name: "web_fetch",
label: "web_fetch",
description: `Fetch and render a URL into clean, readable text optimized for LLM consumption.
description: `Fetches content from a specified URL and processes it using an AI model
- Takes a URL and a prompt as input
- Fetches the URL content, converts HTML to markdown
- Processes the content with the prompt using a small, fast model
- Returns the model's response about the content
- Use this tool when you need to retrieve and analyze web content
Features:
- Site-specific handlers for GitHub (issues, PRs, repos, gists), Stack Overflow, Wikipedia, Reddit, NPM, arXiv, IACR, and Twitter/X
@@ -2059,7 +2144,15 @@ Features:
- RSS/Atom feed parsing
- JSON pretty-printing
Returns structured markdown content with metadata about how the content was fetched.`,
Usage notes:
- IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions.
- The URL must be a fully-formed valid URL
- HTTP URLs will be automatically upgraded to HTTPS
- The prompt should describe what information you want to extract from the page
- This tool is read-only and does not modify any files
- Results may be summarized if the content is very large
- Includes a self-cleaning 15-minute cache for faster responses when repeatedly accessing the same URL
- When a URL redirects to a different host, the tool will inform you and provide the redirect URL in a special format. You should then make a new WebFetch request with the redirect URL to fetch the content.`,
parameters: webFetchSchema,
execute: async (
_toolCallId: string,
@@ -10,6 +10,7 @@ import { Type } from "@sinclair/typebox";
import type { Theme } from "../../../modes/interactive/theme/theme.js";
import type { CustomTool, CustomToolContext, RenderResultOptions } from "../../custom-tools/types.js";
import { searchAnthropic } from "./providers/anthropic.js";
import { findApiKey as findExaKey, searchExa } from "./providers/exa.js";
import { findApiKey as findPerplexityKey, searchPerplexity } from "./providers/perplexity.js";
import { formatAge, renderWebSearchCall, renderWebSearchResult, type WebSearchRenderDetails } from "./render.js";
import type { WebSearchProvider, WebSearchResponse } from "./types.js";
@@ -19,9 +20,9 @@ export const webSearchSchema = Type.Object({
// Common
query: Type.String({ description: "Search query" }),
provider: Type.Optional(
Type.Union([Type.Literal("anthropic"), Type.Literal("perplexity")], {
Type.Union([Type.Literal("exa"), Type.Literal("anthropic"), Type.Literal("perplexity")], {
description: "Search provider (auto-detected if omitted based on API keys)",
}),
})
),
num_results: Type.Optional(Type.Number({ description: "Maximum number of results to return" })),
@@ -29,47 +30,47 @@ export const webSearchSchema = Type.Object({
system_prompt: Type.Optional(
Type.String({
description: "System prompt to guide response style",
}),
})
),
max_tokens: Type.Optional(
Type.Number({
description: "Maximum tokens in response, 1-16384, default 4096 (Anthropic only)",
minimum: 1,
maximum: 16384,
}),
})
),
// Perplexity-specific
model: Type.Optional(
Type.Union([Type.Literal("sonar"), Type.Literal("sonar-pro")], {
description: "Perplexity model - sonar (fast) or sonar-pro (comprehensive research)",
}),
})
),
search_recency_filter: Type.Optional(
Type.Union([Type.Literal("day"), Type.Literal("week"), Type.Literal("month"), Type.Literal("year")], {
description: "Filter results by recency (Perplexity only)",
}),
})
),
search_domain_filter: Type.Optional(
Type.Array(Type.String(), {
description: "Domain filter - include domains, prefix with - to exclude (Perplexity only)",
}),
})
),
search_context_size: Type.Optional(
Type.Union([Type.Literal("low"), Type.Literal("medium"), Type.Literal("high")], {
description: "Context size for cost control (Perplexity only)",
}),
})
),
return_related_questions: Type.Optional(
Type.Boolean({
description: "Include follow-up question suggestions, default true (Perplexity only)",
}),
})
),
});
export type WebSearchParams = {
query: string;
provider?: "anthropic" | "perplexity";
provider?: "exa" | "anthropic" | "perplexity";
num_results?: number;
// Anthropic
system_prompt?: string;
@@ -82,9 +83,13 @@ export type WebSearchParams = {
return_related_questions?: boolean;
};
/** Detect provider based on available API keys */
/** Detect provider based on available API keys (priority: exa > perplexity > anthropic) */
async function detectProvider(): Promise<WebSearchProvider> {
// 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<typeof webSearchSchema> = {
@@ -197,7 +210,7 @@ export const webSearchCustomTool: CustomTool<typeof webSearchSchema, WebSearchRe
params: WebSearchParams,
_onUpdate,
_ctx: CustomToolContext,
_signal?: AbortSignal,
_signal?: AbortSignal
) {
return executeWebSearch(toolCallId, params);
},
@@ -0,0 +1,302 @@
/**
* Exa Web Search Provider
*
* High-quality neural search via Exa MCP.
* Returns structured search results with optional content extraction.
*/
import * as os from "node:os";
import type { WebSearchResponse, WebSearchSource } from "../types.js";
const EXA_MCP_URL = "https://mcp.exa.ai/mcp";
export interface ExaSearchParams {
query: string;
num_results?: number;
type?: "neural" | "keyword" | "auto";
include_domains?: string[];
exclude_domains?: string[];
start_published_date?: string;
end_published_date?: string;
}
/** Parse a .env file and return key-value pairs */
async function parseEnvFile(filePath: string): Promise<Record<string, string>> {
const result: Record<string, string> = {};
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<string | null> {
// 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<string, unknown>): Promise<MCPCallResponse> {
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<ExaSearchResult> | 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<WebSearchResponse> {
const apiKey = await findApiKey();
if (!apiKey) {
throw new Error("EXA_API_KEY not found. Set it in environment or .env file.");
}
const args: Record<string, unknown> = {
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,
};
}
@@ -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 {
@@ -12,9 +12,15 @@ const writeSchema = Type.Object({
export function createWriteTool(cwd: string): AgentTool<typeof writeSchema> {
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,