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

@ -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,151 +106,156 @@ const editSchema = Type.Object({
newText: Type.String({ description: "New text to replace the old text with" }),
});
export const editTool: AgentTool<typeof editSchema> = {
name: "edit",
label: "edit",
description:
"Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
parameters: editSchema,
execute: async (
_toolCallId: string,
{ path, oldText, newText }: { path: string; oldText: string; newText: string },
signal?: AbortSignal,
) => {
const absolutePath = resolvePath(expandPath(path));
export function createEditTool(cwd: string): AgentTool<typeof editSchema> {
return {
name: "edit",
label: "edit",
description:
"Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
parameters: editSchema,
execute: async (
_toolCallId: string,
{ path, oldText, newText }: { path: string; oldText: string; newText: string },
signal?: AbortSignal,
) => {
const absolutePath = resolveToCwd(path, cwd);
return new Promise<{
content: Array<{ type: "text"; text: string }>;
details: { diff: string } | undefined;
}>((resolve, reject) => {
// Check if already aborted
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
}
let aborted = false;
// Set up abort handler
const onAbort = () => {
aborted = true;
reject(new Error("Operation aborted"));
};
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
// Perform the edit operation
(async () => {
try {
// Check if file exists
try {
await access(absolutePath, constants.R_OK | constants.W_OK);
} catch {
if (signal) {
signal.removeEventListener("abort", onAbort);
}
reject(new Error(`File not found: ${path}`));
return;
}
// Check if aborted before reading
if (aborted) {
return;
}
// Read the file
const content = await readFile(absolutePath, "utf-8");
// Check if aborted after reading
if (aborted) {
return;
}
// Check if old text exists
if (!content.includes(oldText)) {
if (signal) {
signal.removeEventListener("abort", onAbort);
}
reject(
new Error(
`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,
),
);
return;
}
// Count occurrences
const occurrences = content.split(oldText).length - 1;
if (occurrences > 1) {
if (signal) {
signal.removeEventListener("abort", onAbort);
}
reject(
new Error(
`Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
),
);
return;
}
// Check if aborted before writing
if (aborted) {
return;
}
// Perform replacement using indexOf + substring (raw string replace, no special character interpretation)
// String.replace() interprets $ in the replacement string, so we do manual replacement
const index = content.indexOf(oldText);
const newContent = content.substring(0, index) + newText + content.substring(index + oldText.length);
// Verify the replacement actually changed something
if (content === newContent) {
if (signal) {
signal.removeEventListener("abort", onAbort);
}
reject(
new Error(
`No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`,
),
);
return;
}
await writeFile(absolutePath, newContent, "utf-8");
// Check if aborted after writing
if (aborted) {
return;
}
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
resolve({
content: [
{
type: "text",
text: `Successfully replaced text in ${path}. Changed ${oldText.length} characters to ${newText.length} characters.`,
},
],
details: { diff: generateDiffString(content, newContent) },
});
} catch (error: any) {
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
if (!aborted) {
reject(error);
}
return new Promise<{
content: Array<{ type: "text"; text: string }>;
details: { diff: string } | undefined;
}>((resolve, reject) => {
// Check if already aborted
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
}
})();
});
},
};
let aborted = false;
// Set up abort handler
const onAbort = () => {
aborted = true;
reject(new Error("Operation aborted"));
};
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
// Perform the edit operation
(async () => {
try {
// Check if file exists
try {
await access(absolutePath, constants.R_OK | constants.W_OK);
} catch {
if (signal) {
signal.removeEventListener("abort", onAbort);
}
reject(new Error(`File not found: ${path}`));
return;
}
// Check if aborted before reading
if (aborted) {
return;
}
// Read the file
const content = await readFile(absolutePath, "utf-8");
// Check if aborted after reading
if (aborted) {
return;
}
// Check if old text exists
if (!content.includes(oldText)) {
if (signal) {
signal.removeEventListener("abort", onAbort);
}
reject(
new Error(
`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,
),
);
return;
}
// Count occurrences
const occurrences = content.split(oldText).length - 1;
if (occurrences > 1) {
if (signal) {
signal.removeEventListener("abort", onAbort);
}
reject(
new Error(
`Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
),
);
return;
}
// Check if aborted before writing
if (aborted) {
return;
}
// Perform replacement using indexOf + substring (raw string replace, no special character interpretation)
// String.replace() interprets $ in the replacement string, so we do manual replacement
const index = content.indexOf(oldText);
const newContent = content.substring(0, index) + newText + content.substring(index + oldText.length);
// Verify the replacement actually changed something
if (content === newContent) {
if (signal) {
signal.removeEventListener("abort", onAbort);
}
reject(
new Error(
`No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`,
),
);
return;
}
await writeFile(absolutePath, newContent, "utf-8");
// Check if aborted after writing
if (aborted) {
return;
}
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
resolve({
content: [
{
type: "text",
text: `Successfully replaced text in ${path}. Changed ${oldText.length} characters to ${newText.length} characters.`,
},
],
details: { diff: generateDiffString(content, newContent) },
});
} catch (error: any) {
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
if (!aborted) {
reject(error);
}
}
})();
});
},
};
}
/** Default edit tool using process.cwd() - for backwards compatibility */
export const editTool = createEditTool(process.cwd());