Fix SDK tools to respect cwd option

Core tools now properly use the cwd passed to createAgentSession().
Added tool factory functions for SDK users who specify custom cwd with explicit tools.

Fixes #279
This commit is contained in:
Mario Zechner 2025-12-22 16:17:55 +01:00
parent 42bc368e70
commit face745f3d
16 changed files with 1243 additions and 1044 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- **SDK tools respect cwd**: Core tools (bash, read, edit, write, grep, find, ls) now properly use the `cwd` option from `createAgentSession()`. Added tool factory functions (`createBashTool`, `createReadTool`, etc.) for SDK users who specify custom `cwd` with explicit tools. ([#279](https://github.com/badlogic/pi-mono/issues/279))
## [0.26.0] - 2025-12-22
### Added

View file

@ -307,7 +307,7 @@ const { session } = await createAgentSession({
```typescript
import {
codingTools, // read, bash, edit, write (default)
readOnlyTools, // read, bash
readOnlyTools, // read, grep, find, ls
readTool, bashTool, editTool, writeTool,
grepTool, findTool, lsTool,
} from "@mariozechner/pi-coding-agent";
@ -323,6 +323,45 @@ const { session } = await createAgentSession({
});
```
#### Tools with Custom cwd
**Important:** The pre-built tool instances (`readTool`, `bashTool`, etc.) use `process.cwd()` for path resolution. When you specify a custom `cwd` AND provide explicit `tools`, you must use the tool factory functions to ensure paths resolve correctly:
```typescript
import {
createCodingTools, // Creates [read, bash, edit, write] for specific cwd
createReadOnlyTools, // Creates [read, grep, find, ls] for specific cwd
createReadTool,
createBashTool,
createEditTool,
createWriteTool,
createGrepTool,
createFindTool,
createLsTool,
} from "@mariozechner/pi-coding-agent";
const cwd = "/path/to/project";
// Use factory for tool sets
const { session } = await createAgentSession({
cwd,
tools: createCodingTools(cwd), // Tools resolve paths relative to cwd
});
// Or pick specific tools
const { session } = await createAgentSession({
cwd,
tools: [createReadTool(cwd), createBashTool(cwd), createGrepTool(cwd)],
});
```
**When you don't need factories:**
- If you omit `tools`, pi automatically creates them with the correct `cwd`
- If you use `process.cwd()` as your `cwd`, the pre-built instances work fine
**When you must use factories:**
- When you specify both `cwd` (different from `process.cwd()`) AND `tools`
> See [examples/sdk/05-tools.ts](../examples/sdk/05-tools.ts)
### Custom Tools
@ -788,12 +827,18 @@ buildSystemPrompt
SessionManager
SettingsManager
// Built-in tools
// Built-in tools (use process.cwd())
codingTools
readOnlyTools
readTool, bashTool, editTool, writeTool
grepTool, findTool, lsTool
// Tool factories (for custom cwd)
createCodingTools
createReadOnlyTools
createReadTool, createBashTool, createEditTool, createWriteTool
createGrepTool, createFindTool, createLsTool
// Types
type CreateAgentSessionOptions
type CreateAgentSessionResult

View file

@ -2,6 +2,10 @@
* Tools Configuration
*
* Use built-in tool sets, individual tools, or add custom tools.
*
* IMPORTANT: When using a custom `cwd`, you must use the tool factory functions
* (createCodingTools, createReadOnlyTools, createReadTool, etc.) to ensure
* tools resolve paths relative to your cwd, not process.cwd().
*/
import { Type } from "@sinclair/typebox";
@ -9,28 +13,50 @@ import {
createAgentSession,
discoverCustomTools,
SessionManager,
codingTools, // read, bash, edit, write (default)
readOnlyTools, // read, bash
codingTools, // read, bash, edit, write - uses process.cwd()
readOnlyTools, // read, grep, find, ls - uses process.cwd()
createCodingTools, // Factory: creates tools for specific cwd
createReadOnlyTools, // Factory: creates tools for specific cwd
createReadTool,
createBashTool,
createGrepTool,
readTool,
bashTool,
grepTool,
type CustomAgentTool,
} from "../../src/index.js";
// Read-only mode (no edit/write)
// Read-only mode (no edit/write) - uses process.cwd()
const { session: readOnly } = await createAgentSession({
tools: readOnlyTools,
sessionManager: SessionManager.inMemory(),
});
console.log("Read-only session created");
// Custom tool selection
// Custom tool selection - uses process.cwd()
const { session: custom } = await createAgentSession({
tools: [readTool, bashTool, grepTool],
sessionManager: SessionManager.inMemory(),
});
console.log("Custom tools session created");
// With custom cwd - MUST use factory functions!
const customCwd = "/path/to/project";
const { session: customCwdSession } = await createAgentSession({
cwd: customCwd,
tools: createCodingTools(customCwd), // Tools resolve paths relative to customCwd
sessionManager: SessionManager.inMemory(),
});
console.log("Custom cwd session created");
// Or pick specific tools for custom cwd
const { session: specificTools } = await createAgentSession({
cwd: customCwd,
tools: [createReadTool(customCwd), createBashTool(customCwd), createGrepTool(customCwd)],
sessionManager: SessionManager.inMemory(),
});
console.log("Specific tools with custom cwd session created");
// Inline custom tool (needs TypeBox schema)
const weatherTool: CustomAgentTool = {
name: "get_weather",

View file

@ -3,6 +3,10 @@
*
* Replace everything - no discovery, explicit configuration.
* Still uses OAuth from ~/.pi/agent for convenience.
*
* IMPORTANT: When providing `tools` with a custom `cwd`, use the tool factory
* functions (createReadTool, createBashTool, etc.) to ensure tools resolve
* paths relative to your cwd.
*/
import { Type } from "@sinclair/typebox";
@ -13,8 +17,8 @@ import {
findModel,
SessionManager,
SettingsManager,
readTool,
bashTool,
createReadTool,
createBashTool,
type HookFactory,
type CustomAgentTool,
} from "../../src/index.js";
@ -60,8 +64,11 @@ const settingsManager = SettingsManager.inMemory({
retry: { enabled: true, maxRetries: 2 },
});
// When using a custom cwd with explicit tools, use the factory functions
const cwd = process.cwd();
const { session } = await createAgentSession({
cwd: process.cwd(),
cwd,
agentDir: "/tmp/my-agent",
model,
@ -71,7 +78,8 @@ const { session } = await createAgentSession({
systemPrompt: `You are a minimal assistant.
Available: read, bash, status. Be concise.`,
tools: [readTool, bashTool],
// Use factory functions with the same cwd to ensure path resolution works correctly
tools: [createReadTool(cwd), createBashTool(cwd)],
customTools: [{ tool: statusTool }],
hooks: [{ factory: auditHook }],
skills: [],

View file

@ -21,7 +21,7 @@ export async function processFileArguments(fileArgs: string[]): Promise<Processe
for (const fileArg of fileArgs) {
// Expand and resolve path (handles ~ expansion and macOS screenshot Unicode spaces)
const absolutePath = resolve(resolveReadPath(fileArg));
const absolutePath = resolve(resolveReadPath(fileArg, process.cwd()));
// Check if file exists
try {

View file

@ -58,6 +58,15 @@ import {
allTools,
bashTool,
codingTools,
createBashTool,
createCodingTools,
createEditTool,
createFindTool,
createGrepTool,
createLsTool,
createReadOnlyTools,
createReadTool,
createWriteTool,
editTool,
findTool,
grepTool,
@ -138,6 +147,7 @@ export type { FileSlashCommand } from "./slash-commands.js";
export type { Tool } from "./tools/index.js";
export {
// Pre-built tools (use process.cwd())
readTool,
bashTool,
editTool,
@ -148,6 +158,16 @@ export {
codingTools,
readOnlyTools,
allTools as allBuiltInTools,
// Tool factories (for custom cwd)
createCodingTools,
createReadOnlyTools,
createReadTool,
createBashTool,
createEditTool,
createWriteTool,
createGrepTool,
createFindTool,
createLsTool,
};
// Helper Functions
@ -526,7 +546,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir);
const builtInTools = options.tools ?? codingTools;
const builtInTools = options.tools ?? createCodingTools(cwd);
let customToolsResult: { tools: LoadedCustomTool[]; setUIContext: (ctx: any, hasUI: boolean) => void };
if (options.customTools !== undefined) {

View file

@ -26,7 +26,8 @@ export interface BashToolDetails {
fullOutputPath?: string;
}
export const bashTool: AgentTool<typeof bashSchema> = {
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.`,
@ -40,6 +41,7 @@ export const bashTool: AgentTool<typeof bashSchema> = {
return new Promise((resolve, reject) => {
const { shell, args } = getShellConfig();
const child = spawn(shell, [...args, command], {
cwd,
detached: true,
stdio: ["ignore", "pipe", "pipe"],
});
@ -204,3 +206,7 @@ export const bashTool: AgentTool<typeof bashSchema> = {
});
},
};
}
/** Default bash tool using process.cwd() - for backwards compatibility */
export const bashTool = createBashTool(process.cwd());

View file

@ -3,8 +3,7 @@ import { Type } from "@sinclair/typebox";
import * as Diff from "diff";
import { constants } from "fs";
import { access, readFile, writeFile } from "fs/promises";
import { resolve as resolvePath } from "path";
import { expandPath } from "./path-utils.js";
import { resolveToCwd } from "./path-utils.js";
/**
* Generate a unified diff string with line numbers and context
@ -107,7 +106,8 @@ const editSchema = Type.Object({
newText: Type.String({ description: "New text to replace the old text with" }),
});
export const editTool: AgentTool<typeof editSchema> = {
export function createEditTool(cwd: string): AgentTool<typeof editSchema> {
return {
name: "edit",
label: "edit",
description:
@ -118,7 +118,7 @@ export const editTool: AgentTool<typeof editSchema> = {
{ path, oldText, newText }: { path: string; oldText: string; newText: string },
signal?: AbortSignal,
) => {
const absolutePath = resolvePath(expandPath(path));
const absolutePath = resolveToCwd(path, cwd);
return new Promise<{
content: Array<{ type: "text"; text: string }>;
@ -255,3 +255,7 @@ export const editTool: AgentTool<typeof editSchema> = {
});
},
};
}
/** Default edit tool using process.cwd() - for backwards compatibility */
export const editTool = createEditTool(process.cwd());

View file

@ -5,7 +5,7 @@ import { existsSync } from "fs";
import { globSync } from "glob";
import path from "path";
import { ensureTool } from "../../utils/tools-manager.js";
import { expandPath } from "./path-utils.js";
import { resolveToCwd } from "./path-utils.js";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
const findSchema = Type.Object({
@ -23,7 +23,8 @@ export interface FindToolDetails {
resultLimitReached?: number;
}
export const findTool: AgentTool<typeof findSchema> = {
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).`,
@ -51,7 +52,7 @@ export const findTool: AgentTool<typeof findSchema> = {
return;
}
const searchPath = path.resolve(expandPath(searchDir || "."));
const searchPath = resolveToCwd(searchDir || ".", cwd);
const effectiveLimit = limit ?? DEFAULT_LIMIT;
// Build fd arguments
@ -188,3 +189,7 @@ export const findTool: AgentTool<typeof findSchema> = {
});
},
};
}
/** Default find tool using process.cwd() - for backwards compatibility */
export const findTool = createFindTool(process.cwd());

View file

@ -5,7 +5,7 @@ import { spawn } from "child_process";
import { readFileSync, type Stats, statSync } from "fs";
import path from "path";
import { ensureTool } from "../../utils/tools-manager.js";
import { expandPath } from "./path-utils.js";
import { resolveToCwd } from "./path-utils.js";
import {
DEFAULT_MAX_BYTES,
formatSize,
@ -37,7 +37,8 @@ export interface GrepToolDetails {
linesTruncated?: boolean;
}
export const grepTool: AgentTool<typeof grepSchema> = {
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.`,
@ -85,7 +86,7 @@ export const grepTool: AgentTool<typeof grepSchema> = {
return;
}
const searchPath = path.resolve(expandPath(searchDir || "."));
const searchPath = resolveToCwd(searchDir || ".", cwd);
let searchStat: Stats;
try {
searchStat = statSync(searchPath);
@ -305,3 +306,7 @@ export const grepTool: AgentTool<typeof grepSchema> = {
});
},
};
}
/** Default grep tool using process.cwd() - for backwards compatibility */
export const grepTool = createGrepTool(process.cwd());

View file

@ -1,32 +1,32 @@
import type { AgentTool } from "@mariozechner/pi-ai";
export { type BashToolDetails, bashTool } from "./bash.js";
export { editTool } from "./edit.js";
export { type FindToolDetails, findTool } from "./find.js";
export { type GrepToolDetails, grepTool } from "./grep.js";
export { type LsToolDetails, lsTool } from "./ls.js";
export { type ReadToolDetails, readTool } from "./read.js";
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, readTool } from "./read.js";
export type { TruncationResult } from "./truncate.js";
export { writeTool } from "./write.js";
export { createWriteTool, writeTool } from "./write.js";
import { bashTool } from "./bash.js";
import { editTool } from "./edit.js";
import { findTool } from "./find.js";
import { grepTool } from "./grep.js";
import { lsTool } from "./ls.js";
import { readTool } from "./read.js";
import { writeTool } from "./write.js";
import { bashTool, createBashTool } from "./bash.js";
import { createEditTool, editTool } from "./edit.js";
import { createFindTool, findTool } from "./find.js";
import { createGrepTool, grepTool } from "./grep.js";
import { createLsTool, lsTool } from "./ls.js";
import { createReadTool, readTool } from "./read.js";
import { createWriteTool, writeTool } from "./write.js";
/** Tool type (AgentTool from pi-ai) */
export type Tool = AgentTool<any>;
// Default tools for full access mode
// Default tools for full access mode (using process.cwd())
export const codingTools: Tool[] = [readTool, bashTool, editTool, writeTool];
// Read-only tools for exploration without modification
// Read-only tools for exploration without modification (using process.cwd())
export const readOnlyTools: Tool[] = [readTool, grepTool, findTool, lsTool];
// All available tools (including read-only exploration tools)
// All available tools (using process.cwd())
export const allTools = {
read: readTool,
bash: bashTool,
@ -38,3 +38,32 @@ export const allTools = {
};
export type ToolName = keyof typeof allTools;
/**
* Create coding tools configured for a specific working directory.
*/
export function createCodingTools(cwd: string): Tool[] {
return [createReadTool(cwd), createBashTool(cwd), createEditTool(cwd), createWriteTool(cwd)];
}
/**
* Create read-only tools configured for a specific working directory.
*/
export function createReadOnlyTools(cwd: string): Tool[] {
return [createReadTool(cwd), createGrepTool(cwd), createFindTool(cwd), createLsTool(cwd)];
}
/**
* Create all tools configured for a specific working directory.
*/
export function createAllTools(cwd: string): Record<ToolName, Tool> {
return {
read: createReadTool(cwd),
bash: createBashTool(cwd),
edit: createEditTool(cwd),
write: createWriteTool(cwd),
grep: createGrepTool(cwd),
find: createFindTool(cwd),
ls: createLsTool(cwd),
};
}

View file

@ -2,7 +2,7 @@ import type { AgentTool } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { existsSync, readdirSync, statSync } from "fs";
import nodePath from "path";
import { expandPath } from "./path-utils.js";
import { resolveToCwd } from "./path-utils.js";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
const lsSchema = Type.Object({
@ -17,12 +17,17 @@ export interface LsToolDetails {
entryLimitReached?: number;
}
export const lsTool: AgentTool<typeof lsSchema> = {
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).`,
parameters: lsSchema,
execute: async (_toolCallId: string, { path, limit }: { path?: string; limit?: number }, signal?: AbortSignal) => {
execute: async (
_toolCallId: string,
{ path, limit }: { path?: string; limit?: number },
signal?: AbortSignal,
) => {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Operation aborted"));
@ -33,7 +38,7 @@ export const lsTool: AgentTool<typeof lsSchema> = {
signal?.addEventListener("abort", onAbort, { once: true });
try {
const dirPath = nodePath.resolve(expandPath(path || "."));
const dirPath = resolveToCwd(path || ".", cwd);
const effectiveLimit = limit ?? DEFAULT_LIMIT;
// Check if path exists
@ -129,3 +134,7 @@ export const lsTool: AgentTool<typeof lsSchema> = {
});
},
};
}
/** Default ls tool using process.cwd() - for backwards compatibility */
export const lsTool = createLsTool(process.cwd());

View file

@ -1,5 +1,6 @@
import { accessSync, constants } from "node:fs";
import * as os from "node:os";
import { isAbsolute, resolve as resolvePath } from "node:path";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
const NARROW_NO_BREAK_SPACE = "\u202F";
@ -32,17 +33,29 @@ export function expandPath(filePath: string): string {
return normalized;
}
export function resolveReadPath(filePath: string): string {
/**
* Resolve a path relative to the given cwd.
* Handles ~ expansion and absolute paths.
*/
export function resolveToCwd(filePath: string, cwd: string): string {
const expanded = expandPath(filePath);
if (fileExists(expanded)) {
if (isAbsolute(expanded)) {
return expanded;
}
return resolvePath(cwd, expanded);
}
const macOSVariant = tryMacOSScreenshotPath(expanded);
if (macOSVariant !== expanded && fileExists(macOSVariant)) {
export function resolveReadPath(filePath: string, cwd: string): string {
const resolved = resolveToCwd(filePath, cwd);
if (fileExists(resolved)) {
return resolved;
}
const macOSVariant = tryMacOSScreenshotPath(resolved);
if (macOSVariant !== resolved && fileExists(macOSVariant)) {
return macOSVariant;
}
return expanded;
return resolved;
}

View file

@ -2,7 +2,6 @@ import type { AgentTool, ImageContent, TextContent } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { constants } from "fs";
import { access, readFile } from "fs/promises";
import { resolve as resolvePath } from "path";
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js";
import { resolveReadPath } from "./path-utils.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
@ -17,7 +16,8 @@ export interface ReadToolDetails {
truncation?: TruncationResult;
}
export const readTool: AgentTool<typeof readSchema> = {
export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
return {
name: "read",
label: "read",
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files.`,
@ -27,7 +27,7 @@ export const readTool: AgentTool<typeof readSchema> = {
{ path, offset, limit }: { path: string; offset?: number; limit?: number },
signal?: AbortSignal,
) => {
const absolutePath = resolvePath(resolveReadPath(path));
const absolutePath = resolveReadPath(path, cwd);
return new Promise<{ content: (TextContent | ImageContent)[]; details: ReadToolDetails | undefined }>(
(resolve, reject) => {
@ -165,3 +165,7 @@ export const readTool: AgentTool<typeof readSchema> = {
);
},
};
}
/** Default read tool using process.cwd() - for backwards compatibility */
export const readTool = createReadTool(process.cwd());

View file

@ -1,25 +1,31 @@
import type { AgentTool } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { mkdir, writeFile } from "fs/promises";
import { dirname, resolve as resolvePath } from "path";
import { expandPath } from "./path-utils.js";
import { dirname } from "path";
import { resolveToCwd } from "./path-utils.js";
const writeSchema = Type.Object({
path: Type.String({ description: "Path to the file to write (relative or absolute)" }),
content: Type.String({ description: "Content to write to the file" }),
});
export const writeTool: AgentTool<typeof writeSchema> = {
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.",
parameters: writeSchema,
execute: async (_toolCallId: string, { path, content }: { path: string; content: string }, signal?: AbortSignal) => {
const absolutePath = resolvePath(expandPath(path));
execute: async (
_toolCallId: string,
{ path, content }: { path: string; content: string },
signal?: AbortSignal,
) => {
const absolutePath = resolveToCwd(path, cwd);
const dir = dirname(absolutePath);
return new Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }>((resolve, reject) => {
return new Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }>(
(resolve, reject) => {
// Check if already aborted
if (signal?.aborted) {
reject(new Error("Operation aborted"));
@ -77,6 +83,11 @@ export const writeTool: AgentTool<typeof writeSchema> = {
}
}
})();
});
},
);
},
};
}
/** Default write tool using process.cwd() - for backwards compatibility */
export const writeTool = createWriteTool(process.cwd());

View file

@ -93,6 +93,16 @@ export {
configureOAuthStorage,
// Factory
createAgentSession,
createBashTool,
// Tool factories (for custom cwd)
createCodingTools,
createEditTool,
createFindTool,
createGrepTool,
createLsTool,
createReadOnlyTools,
createReadTool,
createWriteTool,
// Helpers
defaultGetApiKey,
discoverAvailableModels,
@ -106,7 +116,7 @@ export {
type FileSlashCommand,
findModel as findModelByProviderAndId,
loadSettings,
// Tools
// Pre-built tools (use process.cwd())
readOnlyTools,
} from "./core/sdk.js";
export {