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:
@@ -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):")}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user