mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 09:01:14 +00:00
feat(coding-agent): add pluggable operations for remote tool execution
Adds optional operations parameter to create*Tool functions enabling delegation to remote systems (SSH, containers, etc.): - ReadOperations: readFile, access, detectImageMimeType - WriteOperations: writeFile, mkdir - EditOperations: readFile, writeFile, access - BashOperations: exec (with streaming, signal, timeout) Add ssh.ts example demonstrating --ssh flag for remote execution. Built-in renderers used automatically for overrides without custom renderers. fixes #564
This commit is contained in:
parent
ba46a52415
commit
9ed88646a8
13 changed files with 782 additions and 264 deletions
|
|
@ -5,6 +5,10 @@
|
|||
### Added
|
||||
|
||||
- `--no-tools` flag to disable all built-in tools, allowing extension-only tool setups ([#557](https://github.com/badlogic/pi-mono/pull/557) by [@cv](https://github.com/cv))
|
||||
- Pluggable operations for built-in tools enabling remote execution via SSH or other transports ([#564](https://github.com/badlogic/pi-mono/issues/564)). Interfaces: `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `LsOperations`, `GrepOperations`, `FindOperations`
|
||||
- `setActiveTools()` in ExtensionAPI for dynamic tool management
|
||||
- Built-in renderers used automatically for tool overrides without custom `renderCall`/`renderResult`
|
||||
- `ssh.ts` example: remote tool execution via `--ssh user@host:/path`
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
|
|||
|
|
@ -972,6 +972,39 @@ Built-in tool implementations:
|
|||
- [find.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/tools/find.ts) - `FindToolDetails`
|
||||
- [ls.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/tools/ls.ts) - `LsToolDetails`
|
||||
|
||||
### Remote Execution
|
||||
|
||||
Built-in tools support pluggable operations for delegating to remote systems (SSH, containers, etc.):
|
||||
|
||||
```typescript
|
||||
import { createReadTool, createBashTool, type ReadOperations } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Create tool with custom operations
|
||||
const remoteRead = createReadTool(cwd, {
|
||||
operations: {
|
||||
readFile: (path) => sshExec(remote, `cat ${path}`),
|
||||
access: (path) => sshExec(remote, `test -r ${path}`).then(() => {}),
|
||||
}
|
||||
});
|
||||
|
||||
// Register, checking flag at execution time
|
||||
pi.registerTool({
|
||||
...remoteRead,
|
||||
async execute(id, params, onUpdate, _ctx, signal) {
|
||||
const ssh = getSshConfig();
|
||||
if (ssh) {
|
||||
const tool = createReadTool(cwd, { operations: createRemoteOps(ssh) });
|
||||
return tool.execute(id, params, signal, onUpdate);
|
||||
}
|
||||
return localRead.execute(id, params, signal, onUpdate);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Operations interfaces:** `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `LsOperations`, `GrepOperations`, `FindOperations`
|
||||
|
||||
See [examples/extensions/ssh.ts](../examples/extensions/ssh.ts) for a complete SSH example with `--ssh` flag.
|
||||
|
||||
### Output Truncation
|
||||
|
||||
**Tools MUST truncate their output** to avoid overwhelming the LLM context. Large outputs can cause:
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
|
|||
| `hello.ts` | Minimal custom tool example |
|
||||
| `question.ts` | Demonstrates `ctx.ui.select()` for asking the user questions |
|
||||
| `tool-override.ts` | Override built-in tools (e.g., add logging/access control to `read`) |
|
||||
| `ssh.ts` | Delegate all tools to a remote machine via SSH using pluggable operations |
|
||||
| `subagent/` | Delegate tasks to specialized subagents with isolated context windows |
|
||||
|
||||
### Commands & UI
|
||||
|
|
|
|||
194
packages/coding-agent/examples/extensions/ssh.ts
Normal file
194
packages/coding-agent/examples/extensions/ssh.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
/**
|
||||
* SSH Remote Execution Example
|
||||
*
|
||||
* Demonstrates delegating tool operations to a remote machine via SSH.
|
||||
* When --ssh is provided, read/write/edit/bash run on the remote.
|
||||
*
|
||||
* Usage:
|
||||
* pi -e ./ssh.ts --ssh user@host
|
||||
* pi -e ./ssh.ts --ssh user@host:/remote/path
|
||||
*
|
||||
* Requirements:
|
||||
* - SSH key-based auth (no password prompts)
|
||||
* - bash on remote
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { basename } from "node:path";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
type BashOperations,
|
||||
createBashTool,
|
||||
createEditTool,
|
||||
createReadTool,
|
||||
createWriteTool,
|
||||
type EditOperations,
|
||||
type ReadOperations,
|
||||
type WriteOperations,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
function sshExec(remote: string, command: string): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("ssh", [remote, command], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
const chunks: Buffer[] = [];
|
||||
const errChunks: Buffer[] = [];
|
||||
child.stdout.on("data", (data) => chunks.push(data));
|
||||
child.stderr.on("data", (data) => errChunks.push(data));
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`SSH failed (${code}): ${Buffer.concat(errChunks).toString()}`));
|
||||
} else {
|
||||
resolve(Buffer.concat(chunks));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createRemoteReadOps(remote: string, remoteCwd: string, localCwd: string): ReadOperations {
|
||||
const toRemote = (p: string) => p.replace(localCwd, remoteCwd);
|
||||
return {
|
||||
readFile: (p) => sshExec(remote, `cat ${JSON.stringify(toRemote(p))}`),
|
||||
access: (p) => sshExec(remote, `test -r ${JSON.stringify(toRemote(p))}`).then(() => {}),
|
||||
detectImageMimeType: async (p) => {
|
||||
try {
|
||||
const r = await sshExec(remote, `file --mime-type -b ${JSON.stringify(toRemote(p))}`);
|
||||
const m = r.toString().trim();
|
||||
return ["image/jpeg", "image/png", "image/gif", "image/webp"].includes(m) ? m : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createRemoteWriteOps(remote: string, remoteCwd: string, localCwd: string): WriteOperations {
|
||||
const toRemote = (p: string) => p.replace(localCwd, remoteCwd);
|
||||
return {
|
||||
writeFile: async (p, content) => {
|
||||
const b64 = Buffer.from(content).toString("base64");
|
||||
await sshExec(remote, `echo ${JSON.stringify(b64)} | base64 -d > ${JSON.stringify(toRemote(p))}`);
|
||||
},
|
||||
mkdir: (dir) => sshExec(remote, `mkdir -p ${JSON.stringify(toRemote(dir))}`).then(() => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createRemoteEditOps(remote: string, remoteCwd: string, localCwd: string): EditOperations {
|
||||
const r = createRemoteReadOps(remote, remoteCwd, localCwd);
|
||||
const w = createRemoteWriteOps(remote, remoteCwd, localCwd);
|
||||
return { readFile: r.readFile, access: r.access, writeFile: w.writeFile };
|
||||
}
|
||||
|
||||
function createRemoteBashOps(remote: string, remoteCwd: string, localCwd: string): BashOperations {
|
||||
const toRemote = (p: string) => p.replace(localCwd, remoteCwd);
|
||||
return {
|
||||
exec: (command, cwd, { onData, signal, timeout }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const cmd = `cd ${JSON.stringify(toRemote(cwd))} && ${command}`;
|
||||
const child = spawn("ssh", [remote, cmd], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let timedOut = false;
|
||||
const timer = timeout
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill();
|
||||
}, timeout * 1000)
|
||||
: undefined;
|
||||
child.stdout.on("data", onData);
|
||||
child.stderr.on("data", onData);
|
||||
child.on("error", (e) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
reject(e);
|
||||
});
|
||||
const onAbort = () => child.kill();
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
child.on("close", (code) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
if (signal?.aborted) reject(new Error("aborted"));
|
||||
else if (timedOut) reject(new Error(`timeout:${timeout}`));
|
||||
else resolve({ exitCode: code });
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerFlag("ssh", { description: "SSH remote: user@host or user@host:/path", type: "string" });
|
||||
|
||||
const localCwd = process.cwd();
|
||||
const localRead = createReadTool(localCwd);
|
||||
const localWrite = createWriteTool(localCwd);
|
||||
const localEdit = createEditTool(localCwd);
|
||||
const localBash = createBashTool(localCwd);
|
||||
|
||||
const getSsh = () => {
|
||||
const arg = pi.getFlag("ssh") as string | undefined;
|
||||
if (!arg) return null;
|
||||
const [remote, path] = arg.includes(":") ? arg.split(":") : [arg, `~/${basename(localCwd)}`];
|
||||
return { remote, remoteCwd: path };
|
||||
};
|
||||
|
||||
pi.registerTool({
|
||||
...localRead,
|
||||
async execute(id, params, onUpdate, _ctx, signal) {
|
||||
const ssh = getSsh();
|
||||
if (ssh) {
|
||||
const tool = createReadTool(localCwd, {
|
||||
operations: createRemoteReadOps(ssh.remote, ssh.remoteCwd, localCwd),
|
||||
});
|
||||
return tool.execute(id, params, signal, onUpdate);
|
||||
}
|
||||
return localRead.execute(id, params, signal, onUpdate);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
...localWrite,
|
||||
async execute(id, params, onUpdate, _ctx, signal) {
|
||||
const ssh = getSsh();
|
||||
if (ssh) {
|
||||
const tool = createWriteTool(localCwd, {
|
||||
operations: createRemoteWriteOps(ssh.remote, ssh.remoteCwd, localCwd),
|
||||
});
|
||||
return tool.execute(id, params, signal, onUpdate);
|
||||
}
|
||||
return localWrite.execute(id, params, signal, onUpdate);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
...localEdit,
|
||||
async execute(id, params, onUpdate, _ctx, signal) {
|
||||
const ssh = getSsh();
|
||||
if (ssh) {
|
||||
const tool = createEditTool(localCwd, {
|
||||
operations: createRemoteEditOps(ssh.remote, ssh.remoteCwd, localCwd),
|
||||
});
|
||||
return tool.execute(id, params, signal, onUpdate);
|
||||
}
|
||||
return localEdit.execute(id, params, signal, onUpdate);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
...localBash,
|
||||
async execute(id, params, onUpdate, _ctx, signal) {
|
||||
const ssh = getSsh();
|
||||
if (ssh) {
|
||||
const tool = createBashTool(localCwd, {
|
||||
operations: createRemoteBashOps(ssh.remote, ssh.remoteCwd, localCwd),
|
||||
});
|
||||
return tool.execute(id, params, signal, onUpdate);
|
||||
}
|
||||
return localBash.execute(id, params, signal, onUpdate);
|
||||
},
|
||||
});
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const ssh = getSsh();
|
||||
if (ssh) {
|
||||
ctx.ui.setStatus("ssh", `SSH: ${ssh.remote}:${ssh.remoteCwd}`);
|
||||
ctx.ui.notify(`SSH mode: ${ssh.remote}:${ssh.remoteCwd}`, "info");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -26,7 +26,120 @@ export interface BashToolDetails {
|
|||
fullOutputPath?: string;
|
||||
}
|
||||
|
||||
export function createBashTool(cwd: string): AgentTool<typeof bashSchema> {
|
||||
/**
|
||||
* Pluggable operations for the bash tool.
|
||||
* Override these to delegate command execution to remote systems (e.g., SSH).
|
||||
*/
|
||||
export interface BashOperations {
|
||||
/**
|
||||
* Execute a command and stream output.
|
||||
* @param command - The command to execute
|
||||
* @param cwd - Working directory
|
||||
* @param options - Execution options
|
||||
* @returns Promise resolving to exit code (null if killed)
|
||||
*/
|
||||
exec: (
|
||||
command: string,
|
||||
cwd: string,
|
||||
options: {
|
||||
onData: (data: Buffer) => void;
|
||||
signal?: AbortSignal;
|
||||
timeout?: number;
|
||||
},
|
||||
) => Promise<{ exitCode: number | null }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default bash operations using local shell
|
||||
*/
|
||||
const defaultBashOperations: BashOperations = {
|
||||
exec: (command, cwd, { onData, signal, timeout }) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { shell, args } = getShellConfig();
|
||||
|
||||
if (!existsSync(cwd)) {
|
||||
reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`));
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(shell, [...args, command], {
|
||||
cwd,
|
||||
detached: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let timedOut = false;
|
||||
|
||||
// Set timeout if provided
|
||||
let timeoutHandle: NodeJS.Timeout | undefined;
|
||||
if (timeout !== undefined && timeout > 0) {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
timedOut = true;
|
||||
if (child.pid) {
|
||||
killProcessTree(child.pid);
|
||||
}
|
||||
}, timeout * 1000);
|
||||
}
|
||||
|
||||
// Stream stdout and stderr
|
||||
if (child.stdout) {
|
||||
child.stdout.on("data", onData);
|
||||
}
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", onData);
|
||||
}
|
||||
|
||||
// Handle shell spawn errors
|
||||
child.on("error", (err) => {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// Handle abort signal - kill entire process tree
|
||||
const onAbort = () => {
|
||||
if (child.pid) {
|
||||
killProcessTree(child.pid);
|
||||
}
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle process exit
|
||||
child.on("close", (code) => {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
reject(new Error(`timeout:${timeout}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({ exitCode: code });
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export interface BashToolOptions {
|
||||
/** Custom operations for command execution. Default: local shell */
|
||||
operations?: BashOperations;
|
||||
}
|
||||
|
||||
export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool<typeof bashSchema> {
|
||||
const ops = options?.operations ?? defaultBashOperations;
|
||||
|
||||
return {
|
||||
name: "bash",
|
||||
label: "bash",
|
||||
|
|
@ -39,18 +152,6 @@ export function createBashTool(cwd: string): AgentTool<typeof bashSchema> {
|
|||
onUpdate?,
|
||||
) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { shell, args } = getShellConfig();
|
||||
|
||||
if (!existsSync(cwd)) {
|
||||
throw new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`);
|
||||
}
|
||||
|
||||
const child = spawn(shell, [...args, command], {
|
||||
cwd,
|
||||
detached: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
// We'll stream to a temp file if output gets large
|
||||
let tempFilePath: string | undefined;
|
||||
let tempFileStream: ReturnType<typeof createWriteStream> | undefined;
|
||||
|
|
@ -62,17 +163,6 @@ export function createBashTool(cwd: string): AgentTool<typeof bashSchema> {
|
|||
// Keep more than we need so we have enough for truncation
|
||||
const maxChunksBytes = DEFAULT_MAX_BYTES * 2;
|
||||
|
||||
let timedOut = false;
|
||||
|
||||
// Set timeout if provided
|
||||
let timeoutHandle: NodeJS.Timeout | undefined;
|
||||
if (timeout !== undefined && timeout > 0) {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
timedOut = true;
|
||||
onAbort();
|
||||
}, timeout * 1000);
|
||||
}
|
||||
|
||||
const handleData = (data: Buffer) => {
|
||||
totalBytes += data.length;
|
||||
|
||||
|
|
@ -116,109 +206,75 @@ export function createBashTool(cwd: string): AgentTool<typeof bashSchema> {
|
|||
}
|
||||
};
|
||||
|
||||
// Collect stdout and stderr together
|
||||
if (child.stdout) {
|
||||
child.stdout.on("data", handleData);
|
||||
}
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", handleData);
|
||||
}
|
||||
|
||||
// Handle shell spawn errors to prevent session from crashing
|
||||
child.on("error", (err) => {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// Handle process exit
|
||||
child.on("close", (code) => {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
// Close temp file stream
|
||||
if (tempFileStream) {
|
||||
tempFileStream.end();
|
||||
}
|
||||
|
||||
// Combine all buffered chunks
|
||||
const fullBuffer = Buffer.concat(chunks);
|
||||
const fullOutput = fullBuffer.toString("utf-8");
|
||||
|
||||
if (signal?.aborted) {
|
||||
let output = fullOutput;
|
||||
if (output) output += "\n\n";
|
||||
output += "Command aborted";
|
||||
reject(new Error(output));
|
||||
return;
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
let output = fullOutput;
|
||||
if (output) output += "\n\n";
|
||||
output += `Command timed out after ${timeout} seconds`;
|
||||
reject(new Error(output));
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply tail truncation
|
||||
const truncation = truncateTail(fullOutput);
|
||||
let outputText = truncation.content || "(no output)";
|
||||
|
||||
// Build details with truncation info
|
||||
let details: BashToolDetails | undefined;
|
||||
|
||||
if (truncation.truncated) {
|
||||
details = {
|
||||
truncation,
|
||||
fullOutputPath: tempFilePath,
|
||||
};
|
||||
|
||||
// Build actionable notice
|
||||
const startLine = truncation.totalLines - truncation.outputLines + 1;
|
||||
const endLine = truncation.totalLines;
|
||||
|
||||
if (truncation.lastLinePartial) {
|
||||
// Edge case: last line alone > 30KB
|
||||
const lastLineSize = formatSize(Buffer.byteLength(fullOutput.split("\n").pop() || "", "utf-8"));
|
||||
outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${tempFilePath}]`;
|
||||
} else if (truncation.truncatedBy === "lines") {
|
||||
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${tempFilePath}]`;
|
||||
} else {
|
||||
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${tempFilePath}]`;
|
||||
ops.exec(command, cwd, { onData: handleData, signal, timeout })
|
||||
.then(({ exitCode }) => {
|
||||
// Close temp file stream
|
||||
if (tempFileStream) {
|
||||
tempFileStream.end();
|
||||
}
|
||||
}
|
||||
|
||||
if (code !== 0 && code !== null) {
|
||||
outputText += `\n\nCommand exited with code ${code}`;
|
||||
reject(new Error(outputText));
|
||||
} else {
|
||||
resolve({ content: [{ type: "text", text: outputText }], details });
|
||||
}
|
||||
});
|
||||
// Combine all buffered chunks
|
||||
const fullBuffer = Buffer.concat(chunks);
|
||||
const fullOutput = fullBuffer.toString("utf-8");
|
||||
|
||||
// Handle abort signal - kill entire process tree
|
||||
const onAbort = () => {
|
||||
if (child.pid) {
|
||||
killProcessTree(child.pid);
|
||||
}
|
||||
};
|
||||
// Apply tail truncation
|
||||
const truncation = truncateTail(fullOutput);
|
||||
let outputText = truncation.content || "(no output)";
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
// Build details with truncation info
|
||||
let details: BashToolDetails | undefined;
|
||||
|
||||
if (truncation.truncated) {
|
||||
details = {
|
||||
truncation,
|
||||
fullOutputPath: tempFilePath,
|
||||
};
|
||||
|
||||
// Build actionable notice
|
||||
const startLine = truncation.totalLines - truncation.outputLines + 1;
|
||||
const endLine = truncation.totalLines;
|
||||
|
||||
if (truncation.lastLinePartial) {
|
||||
// Edge case: last line alone > 30KB
|
||||
const lastLineSize = formatSize(Buffer.byteLength(fullOutput.split("\n").pop() || "", "utf-8"));
|
||||
outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${tempFilePath}]`;
|
||||
} else if (truncation.truncatedBy === "lines") {
|
||||
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${tempFilePath}]`;
|
||||
} else {
|
||||
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${tempFilePath}]`;
|
||||
}
|
||||
}
|
||||
|
||||
if (exitCode !== 0 && exitCode !== null) {
|
||||
outputText += `\n\nCommand exited with code ${exitCode}`;
|
||||
reject(new Error(outputText));
|
||||
} else {
|
||||
resolve({ content: [{ type: "text", text: outputText }], details });
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
// Close temp file stream
|
||||
if (tempFileStream) {
|
||||
tempFileStream.end();
|
||||
}
|
||||
|
||||
// Combine all buffered chunks for error output
|
||||
const fullBuffer = Buffer.concat(chunks);
|
||||
let output = fullBuffer.toString("utf-8");
|
||||
|
||||
if (err.message === "aborted") {
|
||||
if (output) output += "\n\n";
|
||||
output += "Command aborted";
|
||||
reject(new Error(output));
|
||||
} else if (err.message.startsWith("timeout:")) {
|
||||
const timeoutSecs = err.message.split(":")[1];
|
||||
if (output) output += "\n\n";
|
||||
output += `Command timed out after ${timeoutSecs} seconds`;
|
||||
reject(new Error(output));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { constants } from "fs";
|
||||
import { access, readFile, writeFile } from "fs/promises";
|
||||
import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises";
|
||||
import { detectLineEnding, generateDiffString, normalizeToLF, restoreLineEndings, stripBom } from "./edit-diff.js";
|
||||
import { resolveToCwd } from "./path-utils.js";
|
||||
|
||||
|
|
@ -18,7 +18,33 @@ export interface EditToolDetails {
|
|||
firstChangedLine?: number;
|
||||
}
|
||||
|
||||
export function createEditTool(cwd: string): AgentTool<typeof editSchema> {
|
||||
/**
|
||||
* Pluggable operations for the edit tool.
|
||||
* Override these to delegate file editing to remote systems (e.g., SSH).
|
||||
*/
|
||||
export interface EditOperations {
|
||||
/** Read file contents as a Buffer */
|
||||
readFile: (absolutePath: string) => Promise<Buffer>;
|
||||
/** Write content to a file */
|
||||
writeFile: (absolutePath: string, content: string) => Promise<void>;
|
||||
/** Check if file is readable and writable (throw if not) */
|
||||
access: (absolutePath: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const defaultEditOperations: EditOperations = {
|
||||
readFile: (path) => fsReadFile(path),
|
||||
writeFile: (path, content) => fsWriteFile(path, content, "utf-8"),
|
||||
access: (path) => fsAccess(path, constants.R_OK | constants.W_OK),
|
||||
};
|
||||
|
||||
export interface EditToolOptions {
|
||||
/** Custom operations for file editing. Default: local filesystem */
|
||||
operations?: EditOperations;
|
||||
}
|
||||
|
||||
export function createEditTool(cwd: string, options?: EditToolOptions): AgentTool<typeof editSchema> {
|
||||
const ops = options?.operations ?? defaultEditOperations;
|
||||
|
||||
return {
|
||||
name: "edit",
|
||||
label: "edit",
|
||||
|
|
@ -59,7 +85,7 @@ export function createEditTool(cwd: string): AgentTool<typeof editSchema> {
|
|||
try {
|
||||
// Check if file exists
|
||||
try {
|
||||
await access(absolutePath, constants.R_OK | constants.W_OK);
|
||||
await ops.access(absolutePath);
|
||||
} catch {
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
|
|
@ -74,7 +100,8 @@ export function createEditTool(cwd: string): AgentTool<typeof editSchema> {
|
|||
}
|
||||
|
||||
// Read the file
|
||||
const rawContent = await readFile(absolutePath, "utf-8");
|
||||
const buffer = await ops.readFile(absolutePath);
|
||||
const rawContent = buffer.toString("utf-8");
|
||||
|
||||
// Check if aborted after reading
|
||||
if (aborted) {
|
||||
|
|
@ -144,7 +171,7 @@ export function createEditTool(cwd: string): AgentTool<typeof editSchema> {
|
|||
}
|
||||
|
||||
const finalContent = bom + restoreLineEndings(normalizedNewContent, originalEnding);
|
||||
await writeFile(absolutePath, finalContent, "utf-8");
|
||||
await ops.writeFile(absolutePath, finalContent);
|
||||
|
||||
// Check if aborted after writing
|
||||
if (aborted) {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,33 @@ export interface FindToolDetails {
|
|||
resultLimitReached?: number;
|
||||
}
|
||||
|
||||
export function createFindTool(cwd: string): AgentTool<typeof findSchema> {
|
||||
/**
|
||||
* Pluggable operations for the find tool.
|
||||
* Override these to delegate file search to remote systems (e.g., SSH).
|
||||
*/
|
||||
export interface FindOperations {
|
||||
/** Check if path exists */
|
||||
exists: (absolutePath: string) => Promise<boolean> | boolean;
|
||||
/** Find files matching glob pattern. Returns relative paths. */
|
||||
glob: (pattern: string, cwd: string, options: { ignore: string[]; limit: number }) => Promise<string[]> | string[];
|
||||
}
|
||||
|
||||
const defaultFindOperations: FindOperations = {
|
||||
exists: existsSync,
|
||||
glob: (_pattern, _searchCwd, _options) => {
|
||||
// This is a placeholder - actual fd execution happens in execute
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
export interface FindToolOptions {
|
||||
/** Custom operations for find. Default: local filesystem + fd */
|
||||
operations?: FindOperations;
|
||||
}
|
||||
|
||||
export function createFindTool(cwd: string, options?: FindToolOptions): AgentTool<typeof findSchema> {
|
||||
const customOps = options?.operations;
|
||||
|
||||
return {
|
||||
name: "find",
|
||||
label: "find",
|
||||
|
|
@ -45,26 +71,86 @@ export function createFindTool(cwd: string): AgentTool<typeof findSchema> {
|
|||
|
||||
(async () => {
|
||||
try {
|
||||
// Ensure fd is available
|
||||
const searchPath = resolveToCwd(searchDir || ".", cwd);
|
||||
const effectiveLimit = limit ?? DEFAULT_LIMIT;
|
||||
const ops = customOps ?? defaultFindOperations;
|
||||
|
||||
// If custom operations provided with glob, use that
|
||||
if (customOps?.glob) {
|
||||
if (!(await ops.exists(searchPath))) {
|
||||
reject(new Error(`Path not found: ${searchPath}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await ops.glob(pattern, searchPath, {
|
||||
ignore: ["**/node_modules/**", "**/.git/**"],
|
||||
limit: effectiveLimit,
|
||||
});
|
||||
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
|
||||
if (results.length === 0) {
|
||||
resolve({
|
||||
content: [{ type: "text", text: "No files found matching pattern" }],
|
||||
details: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Relativize paths
|
||||
const relativized = results.map((p) => {
|
||||
if (p.startsWith(searchPath)) {
|
||||
return p.slice(searchPath.length + 1);
|
||||
}
|
||||
return path.relative(searchPath, p);
|
||||
});
|
||||
|
||||
const resultLimitReached = relativized.length >= effectiveLimit;
|
||||
const rawOutput = relativized.join("\n");
|
||||
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
||||
|
||||
let resultOutput = truncation.content;
|
||||
const details: FindToolDetails = {};
|
||||
const notices: string[] = [];
|
||||
|
||||
if (resultLimitReached) {
|
||||
notices.push(`${effectiveLimit} results limit reached`);
|
||||
details.resultLimitReached = effectiveLimit;
|
||||
}
|
||||
|
||||
if (truncation.truncated) {
|
||||
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
||||
details.truncation = truncation;
|
||||
}
|
||||
|
||||
if (notices.length > 0) {
|
||||
resultOutput += `\n\n[${notices.join(". ")}]`;
|
||||
}
|
||||
|
||||
resolve({
|
||||
content: [{ type: "text", text: resultOutput }],
|
||||
details: Object.keys(details).length > 0 ? details : undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: use fd
|
||||
const fdPath = await ensureTool("fd", true);
|
||||
if (!fdPath) {
|
||||
reject(new Error("fd is not available and could not be downloaded"));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchPath = resolveToCwd(searchDir || ".", cwd);
|
||||
const effectiveLimit = limit ?? DEFAULT_LIMIT;
|
||||
|
||||
// Build fd arguments
|
||||
const args: string[] = [
|
||||
"--glob", // Use glob pattern
|
||||
"--color=never", // No ANSI colors
|
||||
"--hidden", // Search hidden files (but still respect .gitignore)
|
||||
"--glob",
|
||||
"--color=never",
|
||||
"--hidden",
|
||||
"--max-results",
|
||||
String(effectiveLimit),
|
||||
];
|
||||
|
||||
// Include .gitignore files (root + nested) so fd respects them even outside git repos
|
||||
// Include .gitignore files
|
||||
const gitignoreFiles = new Set<string>();
|
||||
const rootGitignore = path.join(searchPath, ".gitignore");
|
||||
if (existsSync(rootGitignore)) {
|
||||
|
|
@ -89,13 +175,11 @@ export function createFindTool(cwd: string): AgentTool<typeof findSchema> {
|
|||
args.push("--ignore-file", gitignorePath);
|
||||
}
|
||||
|
||||
// Pattern and path
|
||||
args.push(pattern, searchPath);
|
||||
|
||||
// Run fd
|
||||
const result = spawnSync(fdPath, args, {
|
||||
encoding: "utf-8",
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
|
|
@ -109,7 +193,6 @@ export function createFindTool(cwd: string): AgentTool<typeof findSchema> {
|
|||
|
||||
if (result.status !== 0) {
|
||||
const errorMsg = result.stderr?.trim() || `fd exited with code ${result.status}`;
|
||||
// fd returns non-zero for some errors but may still have partial output
|
||||
if (!output) {
|
||||
reject(new Error(errorMsg));
|
||||
return;
|
||||
|
|
@ -129,14 +212,12 @@ export function createFindTool(cwd: string): AgentTool<typeof findSchema> {
|
|||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.replace(/\r$/, "").trim();
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
if (!line) continue;
|
||||
|
||||
const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
|
||||
let relativePath = line;
|
||||
if (line.startsWith(searchPath)) {
|
||||
relativePath = line.slice(searchPath.length + 1); // +1 for the /
|
||||
relativePath = line.slice(searchPath.length + 1);
|
||||
} else {
|
||||
relativePath = path.relative(searchPath, line);
|
||||
}
|
||||
|
|
@ -148,17 +229,12 @@ export function createFindTool(cwd: string): AgentTool<typeof findSchema> {
|
|||
relativized.push(relativePath);
|
||||
}
|
||||
|
||||
// Check if we hit the result limit
|
||||
const resultLimitReached = relativized.length >= effectiveLimit;
|
||||
|
||||
// Apply byte truncation (no line limit since we already have result limit)
|
||||
const rawOutput = relativized.join("\n");
|
||||
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
||||
|
||||
let resultOutput = truncation.content;
|
||||
const details: FindToolDetails = {};
|
||||
|
||||
// Build notices
|
||||
const notices: string[] = [];
|
||||
|
||||
if (resultLimitReached) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { createInterface } from "node:readline";
|
|||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { spawn } from "child_process";
|
||||
import { readFileSync, type Stats, statSync } from "fs";
|
||||
import { readFileSync, statSync } from "fs";
|
||||
import path from "path";
|
||||
import { ensureTool } from "../../utils/tools-manager.js";
|
||||
import { resolveToCwd } from "./path-utils.js";
|
||||
|
|
@ -37,7 +37,30 @@ export interface GrepToolDetails {
|
|||
linesTruncated?: boolean;
|
||||
}
|
||||
|
||||
export function createGrepTool(cwd: string): AgentTool<typeof grepSchema> {
|
||||
/**
|
||||
* Pluggable operations for the grep tool.
|
||||
* Override these to delegate search to remote systems (e.g., SSH).
|
||||
*/
|
||||
export interface GrepOperations {
|
||||
/** Check if path is a directory. Throws if path doesn't exist. */
|
||||
isDirectory: (absolutePath: string) => Promise<boolean> | boolean;
|
||||
/** Read file contents for context lines */
|
||||
readFile: (absolutePath: string) => Promise<string> | string;
|
||||
}
|
||||
|
||||
const defaultGrepOperations: GrepOperations = {
|
||||
isDirectory: (p) => statSync(p).isDirectory(),
|
||||
readFile: (p) => readFileSync(p, "utf-8"),
|
||||
};
|
||||
|
||||
export interface GrepToolOptions {
|
||||
/** Custom operations for grep. Default: local filesystem + ripgrep */
|
||||
operations?: GrepOperations;
|
||||
}
|
||||
|
||||
export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentTool<typeof grepSchema> {
|
||||
const customOps = options?.operations;
|
||||
|
||||
return {
|
||||
name: "grep",
|
||||
label: "grep",
|
||||
|
|
@ -87,15 +110,15 @@ export function createGrepTool(cwd: string): AgentTool<typeof grepSchema> {
|
|||
}
|
||||
|
||||
const searchPath = resolveToCwd(searchDir || ".", cwd);
|
||||
let searchStat: Stats;
|
||||
const ops = customOps ?? defaultGrepOperations;
|
||||
|
||||
let isDirectory: boolean;
|
||||
try {
|
||||
searchStat = statSync(searchPath);
|
||||
isDirectory = await ops.isDirectory(searchPath);
|
||||
} catch (_err) {
|
||||
settle(() => reject(new Error(`Path not found: ${searchPath}`)));
|
||||
return;
|
||||
}
|
||||
|
||||
const isDirectory = searchStat.isDirectory();
|
||||
const contextValue = context && context > 0 ? context : 0;
|
||||
const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
|
||||
|
||||
|
|
@ -110,11 +133,11 @@ export function createGrepTool(cwd: string): AgentTool<typeof grepSchema> {
|
|||
};
|
||||
|
||||
const fileCache = new Map<string, string[]>();
|
||||
const getFileLines = (filePath: string): string[] => {
|
||||
const getFileLines = async (filePath: string): Promise<string[]> => {
|
||||
let lines = fileCache.get(filePath);
|
||||
if (!lines) {
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const content = await ops.readFile(filePath);
|
||||
lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
} catch {
|
||||
lines = [];
|
||||
|
|
@ -173,9 +196,9 @@ export function createGrepTool(cwd: string): AgentTool<typeof grepSchema> {
|
|||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
const formatBlock = (filePath: string, lineNumber: number): string[] => {
|
||||
const formatBlock = async (filePath: string, lineNumber: number): Promise<string[]> => {
|
||||
const relativePath = formatPath(filePath);
|
||||
const lines = getFileLines(filePath);
|
||||
const lines = await getFileLines(filePath);
|
||||
if (!lines.length) {
|
||||
return [`${relativePath}:${lineNumber}: (unable to read file)`];
|
||||
}
|
||||
|
|
@ -205,6 +228,9 @@ export function createGrepTool(cwd: string): AgentTool<typeof grepSchema> {
|
|||
return block;
|
||||
};
|
||||
|
||||
// Collect matches during streaming, format after
|
||||
const matches: Array<{ filePath: string; lineNumber: number }> = [];
|
||||
|
||||
rl.on("line", (line) => {
|
||||
if (!line.trim() || matchCount >= effectiveLimit) {
|
||||
return;
|
||||
|
|
@ -223,7 +249,7 @@ export function createGrepTool(cwd: string): AgentTool<typeof grepSchema> {
|
|||
const lineNumber = event.data?.line_number;
|
||||
|
||||
if (filePath && typeof lineNumber === "number") {
|
||||
outputLines.push(...formatBlock(filePath, lineNumber));
|
||||
matches.push({ filePath, lineNumber });
|
||||
}
|
||||
|
||||
if (matchCount >= effectiveLimit) {
|
||||
|
|
@ -238,7 +264,7 @@ export function createGrepTool(cwd: string): AgentTool<typeof grepSchema> {
|
|||
settle(() => reject(new Error(`Failed to run ripgrep: ${error.message}`)));
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
child.on("close", async (code) => {
|
||||
cleanup();
|
||||
|
||||
if (aborted) {
|
||||
|
|
@ -259,6 +285,12 @@ export function createGrepTool(cwd: string): AgentTool<typeof grepSchema> {
|
|||
return;
|
||||
}
|
||||
|
||||
// Format matches (async to support remote file reading)
|
||||
for (const match of matches) {
|
||||
const block = await formatBlock(match.filePath, match.lineNumber);
|
||||
outputLines.push(...block);
|
||||
}
|
||||
|
||||
// Apply byte truncation (no line limit since we already have match limit)
|
||||
const rawOutput = outputLines.join("\n");
|
||||
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
export { type BashToolDetails, bashTool, createBashTool } from "./bash.js";
|
||||
export { createEditTool, editTool } from "./edit.js";
|
||||
export { createFindTool, type FindToolDetails, findTool } from "./find.js";
|
||||
export { createGrepTool, type GrepToolDetails, grepTool } from "./grep.js";
|
||||
export { createLsTool, type LsToolDetails, lsTool } from "./ls.js";
|
||||
export { createReadTool, type ReadToolDetails, type ReadToolOptions, readTool } from "./read.js";
|
||||
export { type BashOperations, type BashToolDetails, type BashToolOptions, bashTool, createBashTool } from "./bash.js";
|
||||
export { createEditTool, type EditOperations, type EditToolDetails, type EditToolOptions, editTool } from "./edit.js";
|
||||
export { createFindTool, type FindOperations, type FindToolDetails, type FindToolOptions, findTool } from "./find.js";
|
||||
export { createGrepTool, type GrepOperations, type GrepToolDetails, type GrepToolOptions, grepTool } from "./grep.js";
|
||||
export { createLsTool, type LsOperations, type LsToolDetails, type LsToolOptions, lsTool } from "./ls.js";
|
||||
export {
|
||||
createReadTool,
|
||||
type ReadOperations,
|
||||
type ReadToolDetails,
|
||||
type ReadToolOptions,
|
||||
readTool,
|
||||
} from "./read.js";
|
||||
export {
|
||||
DEFAULT_MAX_BYTES,
|
||||
DEFAULT_MAX_LINES,
|
||||
|
|
@ -14,7 +20,7 @@ export {
|
|||
truncateLine,
|
||||
truncateTail,
|
||||
} from "./truncate.js";
|
||||
export { createWriteTool, writeTool } from "./write.js";
|
||||
export { createWriteTool, type WriteOperations, type WriteToolOptions, writeTool } from "./write.js";
|
||||
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { bashTool, createBashTool } from "./bash.js";
|
||||
|
|
|
|||
|
|
@ -17,7 +17,33 @@ export interface LsToolDetails {
|
|||
entryLimitReached?: number;
|
||||
}
|
||||
|
||||
export function createLsTool(cwd: string): AgentTool<typeof lsSchema> {
|
||||
/**
|
||||
* Pluggable operations for the ls tool.
|
||||
* Override these to delegate directory listing to remote systems (e.g., SSH).
|
||||
*/
|
||||
export interface LsOperations {
|
||||
/** Check if path exists */
|
||||
exists: (absolutePath: string) => Promise<boolean> | boolean;
|
||||
/** Get file/directory stats. Throws if not found. */
|
||||
stat: (absolutePath: string) => Promise<{ isDirectory: () => boolean }> | { isDirectory: () => boolean };
|
||||
/** Read directory entries */
|
||||
readdir: (absolutePath: string) => Promise<string[]> | string[];
|
||||
}
|
||||
|
||||
const defaultLsOperations: LsOperations = {
|
||||
exists: existsSync,
|
||||
stat: statSync,
|
||||
readdir: readdirSync,
|
||||
};
|
||||
|
||||
export interface LsToolOptions {
|
||||
/** Custom operations for directory listing. Default: local filesystem */
|
||||
operations?: LsOperations;
|
||||
}
|
||||
|
||||
export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<typeof lsSchema> {
|
||||
const ops = options?.operations ?? defaultLsOperations;
|
||||
|
||||
return {
|
||||
name: "ls",
|
||||
label: "ls",
|
||||
|
|
@ -37,100 +63,102 @@ export function createLsTool(cwd: string): AgentTool<typeof lsSchema> {
|
|||
const onAbort = () => reject(new Error("Operation aborted"));
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
try {
|
||||
const dirPath = resolveToCwd(path || ".", cwd);
|
||||
const effectiveLimit = limit ?? DEFAULT_LIMIT;
|
||||
|
||||
// Check if path exists
|
||||
if (!existsSync(dirPath)) {
|
||||
reject(new Error(`Path not found: ${dirPath}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if path is a directory
|
||||
const stat = statSync(dirPath);
|
||||
if (!stat.isDirectory()) {
|
||||
reject(new Error(`Not a directory: ${dirPath}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Read directory entries
|
||||
let entries: string[];
|
||||
(async () => {
|
||||
try {
|
||||
entries = readdirSync(dirPath);
|
||||
} catch (e: any) {
|
||||
reject(new Error(`Cannot read directory: ${e.message}`));
|
||||
return;
|
||||
}
|
||||
const dirPath = resolveToCwd(path || ".", cwd);
|
||||
const effectiveLimit = limit ?? DEFAULT_LIMIT;
|
||||
|
||||
// Sort alphabetically (case-insensitive)
|
||||
entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
|
||||
// Format entries with directory indicators
|
||||
const results: string[] = [];
|
||||
let entryLimitReached = false;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (results.length >= effectiveLimit) {
|
||||
entryLimitReached = true;
|
||||
break;
|
||||
// Check if path exists
|
||||
if (!(await ops.exists(dirPath))) {
|
||||
reject(new Error(`Path not found: ${dirPath}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const fullPath = nodePath.join(dirPath, entry);
|
||||
let suffix = "";
|
||||
// Check if path is a directory
|
||||
const stat = await ops.stat(dirPath);
|
||||
if (!stat.isDirectory()) {
|
||||
reject(new Error(`Not a directory: ${dirPath}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Read directory entries
|
||||
let entries: string[];
|
||||
try {
|
||||
const entryStat = statSync(fullPath);
|
||||
if (entryStat.isDirectory()) {
|
||||
suffix = "/";
|
||||
}
|
||||
} catch {
|
||||
// Skip entries we can't stat
|
||||
continue;
|
||||
entries = await ops.readdir(dirPath);
|
||||
} catch (e: any) {
|
||||
reject(new Error(`Cannot read directory: ${e.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
results.push(entry + suffix);
|
||||
// Sort alphabetically (case-insensitive)
|
||||
entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
|
||||
// Format entries with directory indicators
|
||||
const results: string[] = [];
|
||||
let entryLimitReached = false;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (results.length >= effectiveLimit) {
|
||||
entryLimitReached = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const fullPath = nodePath.join(dirPath, entry);
|
||||
let suffix = "";
|
||||
|
||||
try {
|
||||
const entryStat = await ops.stat(fullPath);
|
||||
if (entryStat.isDirectory()) {
|
||||
suffix = "/";
|
||||
}
|
||||
} catch {
|
||||
// Skip entries we can't stat
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push(entry + suffix);
|
||||
}
|
||||
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
|
||||
if (results.length === 0) {
|
||||
resolve({ content: [{ type: "text", text: "(empty directory)" }], details: undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply byte truncation (no line limit since we already have entry limit)
|
||||
const rawOutput = results.join("\n");
|
||||
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
||||
|
||||
let output = truncation.content;
|
||||
const details: LsToolDetails = {};
|
||||
|
||||
// Build notices
|
||||
const notices: string[] = [];
|
||||
|
||||
if (entryLimitReached) {
|
||||
notices.push(`${effectiveLimit} entries limit reached. Use limit=${effectiveLimit * 2} for more`);
|
||||
details.entryLimitReached = effectiveLimit;
|
||||
}
|
||||
|
||||
if (truncation.truncated) {
|
||||
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
||||
details.truncation = truncation;
|
||||
}
|
||||
|
||||
if (notices.length > 0) {
|
||||
output += `\n\n[${notices.join(". ")}]`;
|
||||
}
|
||||
|
||||
resolve({
|
||||
content: [{ type: "text", text: output }],
|
||||
details: Object.keys(details).length > 0 ? details : undefined,
|
||||
});
|
||||
} catch (e: any) {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
reject(e);
|
||||
}
|
||||
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
|
||||
if (results.length === 0) {
|
||||
resolve({ content: [{ type: "text", text: "(empty directory)" }], details: undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply byte truncation (no line limit since we already have entry limit)
|
||||
const rawOutput = results.join("\n");
|
||||
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
||||
|
||||
let output = truncation.content;
|
||||
const details: LsToolDetails = {};
|
||||
|
||||
// Build notices
|
||||
const notices: string[] = [];
|
||||
|
||||
if (entryLimitReached) {
|
||||
notices.push(`${effectiveLimit} entries limit reached. Use limit=${effectiveLimit * 2} for more`);
|
||||
details.entryLimitReached = effectiveLimit;
|
||||
}
|
||||
|
||||
if (truncation.truncated) {
|
||||
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
||||
details.truncation = truncation;
|
||||
}
|
||||
|
||||
if (notices.length > 0) {
|
||||
output += `\n\n[${notices.join(". ")}]`;
|
||||
}
|
||||
|
||||
resolve({
|
||||
content: [{ type: "text", text: output }],
|
||||
details: Object.keys(details).length > 0 ? details : undefined,
|
||||
});
|
||||
} catch (e: any) {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
reject(e);
|
||||
}
|
||||
})();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { AgentTool } from "@mariozechner/pi-agent-core";
|
|||
import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { constants } from "fs";
|
||||
import { access, readFile } from "fs/promises";
|
||||
import { access as fsAccess, readFile as fsReadFile } from "fs/promises";
|
||||
import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js";
|
||||
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js";
|
||||
import { resolveReadPath } from "./path-utils.js";
|
||||
|
|
@ -18,13 +18,36 @@ export interface ReadToolDetails {
|
|||
truncation?: TruncationResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluggable operations for the read tool.
|
||||
* Override these to delegate file reading to remote systems (e.g., SSH).
|
||||
*/
|
||||
export interface ReadOperations {
|
||||
/** Read file contents as a Buffer */
|
||||
readFile: (absolutePath: string) => Promise<Buffer>;
|
||||
/** Check if file is readable (throw if not) */
|
||||
access: (absolutePath: string) => Promise<void>;
|
||||
/** Detect image MIME type, return null/undefined for non-images */
|
||||
detectImageMimeType?: (absolutePath: string) => Promise<string | null | undefined>;
|
||||
}
|
||||
|
||||
const defaultReadOperations: ReadOperations = {
|
||||
readFile: (path) => fsReadFile(path),
|
||||
access: (path) => fsAccess(path, constants.R_OK),
|
||||
detectImageMimeType: detectSupportedImageMimeTypeFromFile,
|
||||
};
|
||||
|
||||
export interface ReadToolOptions {
|
||||
/** Whether to auto-resize images to 2000x2000 max. Default: true */
|
||||
autoResizeImages?: boolean;
|
||||
/** Custom operations for file reading. Default: local filesystem */
|
||||
operations?: ReadOperations;
|
||||
}
|
||||
|
||||
export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool<typeof readSchema> {
|
||||
const autoResizeImages = options?.autoResizeImages ?? true;
|
||||
const ops = options?.operations ?? defaultReadOperations;
|
||||
|
||||
return {
|
||||
name: "read",
|
||||
label: "read",
|
||||
|
|
@ -61,14 +84,14 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo
|
|||
(async () => {
|
||||
try {
|
||||
// Check if file exists
|
||||
await access(absolutePath, constants.R_OK);
|
||||
await ops.access(absolutePath);
|
||||
|
||||
// Check if aborted before reading
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath);
|
||||
const mimeType = ops.detectImageMimeType ? await ops.detectImageMimeType(absolutePath) : undefined;
|
||||
|
||||
// Read the file based on type
|
||||
let content: (TextContent | ImageContent)[];
|
||||
|
|
@ -76,7 +99,7 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo
|
|||
|
||||
if (mimeType) {
|
||||
// Read as image (binary)
|
||||
const buffer = await readFile(absolutePath);
|
||||
const buffer = await ops.readFile(absolutePath);
|
||||
const base64 = buffer.toString("base64");
|
||||
|
||||
if (autoResizeImages) {
|
||||
|
|
@ -101,7 +124,8 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo
|
|||
}
|
||||
} else {
|
||||
// Read as text
|
||||
const textContent = await readFile(absolutePath, "utf-8");
|
||||
const buffer = await ops.readFile(absolutePath);
|
||||
const textContent = buffer.toString("utf-8");
|
||||
const allLines = textContent.split("\n");
|
||||
const totalFileLines = allLines.length;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { mkdir, writeFile } from "fs/promises";
|
||||
import { mkdir as fsMkdir, writeFile as fsWriteFile } from "fs/promises";
|
||||
import { dirname } from "path";
|
||||
import { resolveToCwd } from "./path-utils.js";
|
||||
|
||||
|
|
@ -9,7 +9,30 @@ const writeSchema = Type.Object({
|
|||
content: Type.String({ description: "Content to write to the file" }),
|
||||
});
|
||||
|
||||
export function createWriteTool(cwd: string): AgentTool<typeof writeSchema> {
|
||||
/**
|
||||
* Pluggable operations for the write tool.
|
||||
* Override these to delegate file writing to remote systems (e.g., SSH).
|
||||
*/
|
||||
export interface WriteOperations {
|
||||
/** Write content to a file */
|
||||
writeFile: (absolutePath: string, content: string) => Promise<void>;
|
||||
/** Create directory (recursively) */
|
||||
mkdir: (dir: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const defaultWriteOperations: WriteOperations = {
|
||||
writeFile: (path, content) => fsWriteFile(path, content, "utf-8"),
|
||||
mkdir: (dir) => fsMkdir(dir, { recursive: true }).then(() => {}),
|
||||
};
|
||||
|
||||
export interface WriteToolOptions {
|
||||
/** Custom operations for file writing. Default: local filesystem */
|
||||
operations?: WriteOperations;
|
||||
}
|
||||
|
||||
export function createWriteTool(cwd: string, options?: WriteToolOptions): AgentTool<typeof writeSchema> {
|
||||
const ops = options?.operations ?? defaultWriteOperations;
|
||||
|
||||
return {
|
||||
name: "write",
|
||||
label: "write",
|
||||
|
|
@ -48,7 +71,7 @@ export function createWriteTool(cwd: string): AgentTool<typeof writeSchema> {
|
|||
(async () => {
|
||||
try {
|
||||
// Create parent directories if needed
|
||||
await mkdir(dir, { recursive: true });
|
||||
await ops.mkdir(dir);
|
||||
|
||||
// Check if aborted before writing
|
||||
if (aborted) {
|
||||
|
|
@ -56,7 +79,7 @@ export function createWriteTool(cwd: string): AgentTool<typeof writeSchema> {
|
|||
}
|
||||
|
||||
// Write the file
|
||||
await writeFile(absolutePath, content, "utf-8");
|
||||
await ops.writeFile(absolutePath, content);
|
||||
|
||||
// Check if aborted after writing
|
||||
if (aborted) {
|
||||
|
|
|
|||
|
|
@ -176,19 +176,31 @@ export {
|
|||
} from "./core/skills.js";
|
||||
// Tools
|
||||
export {
|
||||
type BashOperations,
|
||||
type BashToolDetails,
|
||||
type BashToolOptions,
|
||||
bashTool,
|
||||
codingTools,
|
||||
DEFAULT_MAX_BYTES,
|
||||
DEFAULT_MAX_LINES,
|
||||
type EditOperations,
|
||||
type EditToolDetails,
|
||||
type EditToolOptions,
|
||||
editTool,
|
||||
type FindOperations,
|
||||
type FindToolDetails,
|
||||
type FindToolOptions,
|
||||
findTool,
|
||||
formatSize,
|
||||
type GrepOperations,
|
||||
type GrepToolDetails,
|
||||
type GrepToolOptions,
|
||||
grepTool,
|
||||
type LsOperations,
|
||||
type LsToolDetails,
|
||||
type LsToolOptions,
|
||||
lsTool,
|
||||
type ReadOperations,
|
||||
type ReadToolDetails,
|
||||
type ReadToolOptions,
|
||||
readTool,
|
||||
|
|
@ -198,6 +210,8 @@ export {
|
|||
truncateHead,
|
||||
truncateLine,
|
||||
truncateTail,
|
||||
type WriteOperations,
|
||||
type WriteToolOptions,
|
||||
writeTool,
|
||||
} from "./core/tools/index.js";
|
||||
// Main entry point
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue