fix(coding-agent): use async operations in tools
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { constants } from "node:fs";
|
||||
import { access as fsAccess } from "node:fs/promises";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
||||
import { spawn } from "child_process";
|
||||
@@ -66,62 +67,70 @@ export function createLocalBashOperations(options?: { shellPath?: string }): Bas
|
||||
return {
|
||||
exec: (command, cwd, { onData, signal, timeout, env }) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { shell, args } = getShellConfig(options?.shellPath);
|
||||
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: process.platform !== "win32",
|
||||
env: env ?? getShellEnv(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (child.pid) trackDetachedChildPid(child.pid);
|
||||
let timedOut = false;
|
||||
let timeoutHandle: NodeJS.Timeout | undefined;
|
||||
// Set timeout if provided.
|
||||
if (timeout !== undefined && timeout > 0) {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
timedOut = true;
|
||||
if (child.pid) killProcessTree(child.pid);
|
||||
}, timeout * 1000);
|
||||
}
|
||||
// Stream stdout and stderr.
|
||||
child.stdout?.on("data", onData);
|
||||
child.stderr?.on("data", onData);
|
||||
// Handle abort signal by killing the 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 shell spawn errors and wait for the process to terminate without hanging
|
||||
// on inherited stdio handles held by detached descendants.
|
||||
waitForChildProcess(child)
|
||||
.then((code) => {
|
||||
if (child.pid) untrackDetachedChildPid(child.pid);
|
||||
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 });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (child.pid) untrackDetachedChildPid(child.pid);
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
reject(err);
|
||||
void (async () => {
|
||||
const { shell, args } = getShellConfig(options?.shellPath);
|
||||
try {
|
||||
await fsAccess(cwd, constants.F_OK);
|
||||
} catch {
|
||||
reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`));
|
||||
return;
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("aborted"));
|
||||
return;
|
||||
}
|
||||
const child = spawn(shell, [...args, command], {
|
||||
cwd,
|
||||
detached: process.platform !== "win32",
|
||||
env: env ?? getShellEnv(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (child.pid) trackDetachedChildPid(child.pid);
|
||||
let timedOut = false;
|
||||
let timeoutHandle: NodeJS.Timeout | undefined;
|
||||
// Set timeout if provided.
|
||||
if (timeout !== undefined && timeout > 0) {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
timedOut = true;
|
||||
if (child.pid) killProcessTree(child.pid);
|
||||
}, timeout * 1000);
|
||||
}
|
||||
// Stream stdout and stderr.
|
||||
child.stdout?.on("data", onData);
|
||||
child.stderr?.on("data", onData);
|
||||
// Handle abort signal by killing the 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 shell spawn errors and wait for the process to terminate without hanging
|
||||
// on inherited stdio handles held by detached descendants.
|
||||
waitForChildProcess(child)
|
||||
.then((code) => {
|
||||
if (child.pid) untrackDetachedChildPid(child.pid);
|
||||
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 });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (child.pid) untrackDetachedChildPid(child.pid);
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
reject(err);
|
||||
});
|
||||
})().catch((err: unknown) => reject(err instanceof Error ? err : new Error(String(err))));
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -313,113 +313,63 @@ export function createEditToolDefinition(
|
||||
const { path, edits } = validateEditInput(input);
|
||||
const absolutePath = resolveToCwd(path, cwd);
|
||||
|
||||
return withFileMutationQueue(
|
||||
absolutePath,
|
||||
() =>
|
||||
new Promise<{
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
details: EditToolDetails | undefined;
|
||||
}>((resolve, reject) => {
|
||||
// Check if already aborted.
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Operation aborted"));
|
||||
return;
|
||||
}
|
||||
return withFileMutationQueue(absolutePath, async () => {
|
||||
let aborted = signal?.aborted ?? false;
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
};
|
||||
const throwIfAborted = (): void => {
|
||||
if (aborted || signal?.aborted) {
|
||||
throw new Error("Operation aborted");
|
||||
}
|
||||
};
|
||||
|
||||
let aborted = false;
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
throwIfAborted();
|
||||
|
||||
// Set up abort handler.
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
reject(new Error("Operation aborted"));
|
||||
};
|
||||
// Check if file exists.
|
||||
try {
|
||||
await ops.access(absolutePath);
|
||||
} catch (error: unknown) {
|
||||
throwIfAborted();
|
||||
const errorMessage =
|
||||
error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error);
|
||||
throw new Error(`Could not edit file: ${path}. ${errorMessage}.`);
|
||||
}
|
||||
throwIfAborted();
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
// Read the file.
|
||||
const buffer = await ops.readFile(absolutePath);
|
||||
throwIfAborted();
|
||||
|
||||
// Perform the edit operation.
|
||||
void (async () => {
|
||||
try {
|
||||
// Check if file exists.
|
||||
try {
|
||||
await ops.access(absolutePath);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error);
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
reject(new Error(`Could not edit file: ${path}. ${errorMessage}.`));
|
||||
return;
|
||||
}
|
||||
// Strip BOM before matching. The model will not include an invisible BOM in oldText.
|
||||
const rawContent = buffer.toString("utf-8");
|
||||
const { bom, text: content } = stripBom(rawContent);
|
||||
const originalEnding = detectLineEnding(content);
|
||||
const normalizedContent = normalizeToLF(content);
|
||||
const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);
|
||||
throwIfAborted();
|
||||
|
||||
// Check if aborted before reading.
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
|
||||
await ops.writeFile(absolutePath, finalContent);
|
||||
throwIfAborted();
|
||||
|
||||
// Read the file.
|
||||
const buffer = await ops.readFile(absolutePath);
|
||||
const rawContent = buffer.toString("utf-8");
|
||||
|
||||
// Check if aborted after reading.
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip BOM before matching. The model will not include an invisible BOM in oldText.
|
||||
const { bom, text: content } = stripBom(rawContent);
|
||||
const originalEnding = detectLineEnding(content);
|
||||
const normalizedContent = normalizeToLF(content);
|
||||
const { baseContent, newContent } = applyEditsToNormalizedContent(
|
||||
normalizedContent,
|
||||
edits,
|
||||
path,
|
||||
);
|
||||
|
||||
// Check if aborted before writing.
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
|
||||
await ops.writeFile(absolutePath, finalContent);
|
||||
|
||||
// Check if aborted after writing.
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up abort handler.
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
const diffResult = generateDiffString(baseContent, newContent);
|
||||
const patch = generateUnifiedPatch(path, baseContent, newContent);
|
||||
resolve({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Successfully replaced ${edits.length} block(s) in ${path}.`,
|
||||
},
|
||||
],
|
||||
details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
// Clean up abort handler.
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
if (!aborted) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
})();
|
||||
}),
|
||||
);
|
||||
const diffResult = generateDiffString(baseContent, newContent);
|
||||
const patch = generateUnifiedPatch(path, baseContent, newContent);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Successfully replaced ${edits.length} block(s) in ${path}.`,
|
||||
},
|
||||
],
|
||||
details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
|
||||
};
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
});
|
||||
},
|
||||
renderCall(args, theme, context) {
|
||||
const component = getEditCallRenderComponent(context.state, context.lastComponent);
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const fileMutationQueues = new Map<string, Promise<void>>();
|
||||
let registrationQueue = Promise.resolve();
|
||||
|
||||
function getMutationQueueKey(filePath: string): string {
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
(error.code === "ENOENT" || error.code === "ENOTDIR")
|
||||
);
|
||||
}
|
||||
|
||||
async function getMutationQueueKey(filePath: string): Promise<string> {
|
||||
const resolvedPath = resolve(filePath);
|
||||
try {
|
||||
return realpathSync.native(resolvedPath);
|
||||
} catch {
|
||||
return resolvedPath;
|
||||
return await realpath(resolvedPath);
|
||||
} catch (error) {
|
||||
if (isMissingPathError(error)) {
|
||||
return resolvedPath;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,16 +30,25 @@ function getMutationQueueKey(filePath: string): string {
|
||||
* Operations for different files still run in parallel.
|
||||
*/
|
||||
export async function withFileMutationQueue<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
|
||||
const key = getMutationQueueKey(filePath);
|
||||
const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
|
||||
const registration = registrationQueue.then(async () => {
|
||||
const key = await getMutationQueueKey(filePath);
|
||||
const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
|
||||
|
||||
let releaseNext!: () => void;
|
||||
const nextQueue = new Promise<void>((resolveQueue) => {
|
||||
releaseNext = resolveQueue;
|
||||
let releaseNext!: () => void;
|
||||
const nextQueue = new Promise<void>((resolveQueue) => {
|
||||
releaseNext = resolveQueue;
|
||||
});
|
||||
const chainedQueue = currentQueue.then(() => nextQueue);
|
||||
fileMutationQueues.set(key, chainedQueue);
|
||||
|
||||
return { key, currentQueue, chainedQueue, releaseNext };
|
||||
});
|
||||
const chainedQueue = currentQueue.then(() => nextQueue);
|
||||
fileMutationQueues.set(key, chainedQueue);
|
||||
registrationQueue = registration.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
const { key, currentQueue, chainedQueue, releaseNext } = await registration;
|
||||
await currentQueue;
|
||||
try {
|
||||
return await fn();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { constants } from "node:fs";
|
||||
import { access as fsAccess } from "node:fs/promises";
|
||||
import { createInterface } from "node:readline";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
import { spawn } from "child_process";
|
||||
import { existsSync } from "fs";
|
||||
import path from "path";
|
||||
import { type Static, Type } from "typebox";
|
||||
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
|
||||
@@ -46,7 +47,14 @@ export interface FindOperations {
|
||||
}
|
||||
|
||||
const defaultFindOperations: FindOperations = {
|
||||
exists: existsSync,
|
||||
exists: async (absolutePath) => {
|
||||
try {
|
||||
await fsAccess(absolutePath, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
// This is a placeholder. Actual fd execution happens in execute() when no custom glob is provided.
|
||||
glob: () => [],
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
|
||||
import { createInterface } from "node:readline";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
import { spawn } from "child_process";
|
||||
import { readFileSync, statSync } from "fs";
|
||||
import path from "path";
|
||||
import { type Static, Type } from "typebox";
|
||||
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
|
||||
@@ -55,8 +55,8 @@ export interface GrepOperations {
|
||||
}
|
||||
|
||||
const defaultGrepOperations: GrepOperations = {
|
||||
isDirectory: (p) => statSync(p).isDirectory(),
|
||||
readFile: (p) => readFileSync(p, "utf-8"),
|
||||
isDirectory: async (p) => (await fsStat(p)).isDirectory(),
|
||||
readFile: (p) => fsReadFile(p, "utf-8"),
|
||||
};
|
||||
|
||||
export interface GrepToolOptions {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { constants } from "node:fs";
|
||||
import { access as fsAccess, readdir as fsReaddir, stat as fsStat } from "node:fs/promises";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
import { existsSync, readdirSync, statSync } from "fs";
|
||||
import nodePath from "path";
|
||||
import { type Static, Type } from "typebox";
|
||||
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
|
||||
@@ -38,9 +39,16 @@ export interface LsOperations {
|
||||
}
|
||||
|
||||
const defaultLsOperations: LsOperations = {
|
||||
exists: existsSync,
|
||||
stat: statSync,
|
||||
readdir: readdirSync,
|
||||
exists: async (absolutePath) => {
|
||||
try {
|
||||
await fsAccess(absolutePath, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
stat: fsStat,
|
||||
readdir: fsReaddir,
|
||||
};
|
||||
|
||||
export interface LsToolOptions {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { accessSync, constants } from "node:fs";
|
||||
import { access } from "node:fs/promises";
|
||||
import { normalizePath, resolvePath } from "../../utils/paths.ts";
|
||||
|
||||
const NARROW_NO_BREAK_SPACE = "\u202F";
|
||||
@@ -27,6 +28,15 @@ function fileExists(filePath: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExistsAsync(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(filePath, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function expandPath(filePath: string): string {
|
||||
return normalizePath(filePath, { normalizeUnicodeSpaces: true, stripAtPrefix: true });
|
||||
}
|
||||
@@ -72,3 +82,37 @@ export function resolveReadPath(filePath: string, cwd: string): string {
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export async function resolveReadPathAsync(filePath: string, cwd: string): Promise<string> {
|
||||
const resolved = resolveToCwd(filePath, cwd);
|
||||
|
||||
if (await fileExistsAsync(resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Try macOS AM/PM variant (narrow no-break space before AM/PM)
|
||||
const amPmVariant = tryMacOSScreenshotPath(resolved);
|
||||
if (amPmVariant !== resolved && (await fileExistsAsync(amPmVariant))) {
|
||||
return amPmVariant;
|
||||
}
|
||||
|
||||
// Try NFD variant (macOS stores filenames in NFD form)
|
||||
const nfdVariant = tryNFDVariant(resolved);
|
||||
if (nfdVariant !== resolved && (await fileExistsAsync(nfdVariant))) {
|
||||
return nfdVariant;
|
||||
}
|
||||
|
||||
// Try curly quote variant (macOS uses U+2019 in screenshot names)
|
||||
const curlyVariant = tryCurlyQuoteVariant(resolved);
|
||||
if (curlyVariant !== resolved && (await fileExistsAsync(curlyVariant))) {
|
||||
return curlyVariant;
|
||||
}
|
||||
|
||||
// Try combined NFD + curly quote (for French macOS screenshots like "Capture d'écran")
|
||||
const nfdCurlyVariant = tryCurlyQuoteVariant(nfdVariant);
|
||||
if (nfdCurlyVariant !== resolved && (await fileExistsAsync(nfdCurlyVariant))) {
|
||||
return nfdCurlyVariant;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { formatDimensionNote, resizeImage } from "../../utils/image-resize.ts";
|
||||
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.ts";
|
||||
import { formatPathRelativeToCwdOrAbsolute } from "../../utils/paths.ts";
|
||||
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
|
||||
import { resolveReadPath } from "./path-utils.ts";
|
||||
import { resolveReadPathAsync, resolveToCwd } from "./path-utils.ts";
|
||||
import { getTextOutput, invalidArgText, replaceTabs, shortenPath, str } from "./render-utils.ts";
|
||||
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
|
||||
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";
|
||||
@@ -124,7 +124,7 @@ function getCompactReadClassification(
|
||||
const rawPath = str(args?.file_path ?? args?.path);
|
||||
if (!rawPath) return undefined;
|
||||
|
||||
const absolutePath = resolveReadPath(rawPath, cwd);
|
||||
const absolutePath = resolveToCwd(rawPath, cwd);
|
||||
const fileName = basename(absolutePath);
|
||||
if (fileName === "SKILL.md") {
|
||||
return { kind: "skill", label: basename(dirname(absolutePath)) || fileName };
|
||||
@@ -223,7 +223,6 @@ export function createReadToolDefinition(
|
||||
_onUpdate?,
|
||||
ctx?,
|
||||
) {
|
||||
const absolutePath = resolveReadPath(path, cwd);
|
||||
return new Promise<{ content: (TextContent | ImageContent)[]; details: ReadToolDetails | undefined }>(
|
||||
(resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
@@ -239,6 +238,8 @@ export function createReadToolDefinition(
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const absolutePath = await resolveReadPathAsync(path, cwd);
|
||||
if (aborted) return;
|
||||
// Check if file exists and is readable.
|
||||
await ops.access(absolutePath);
|
||||
if (aborted) return;
|
||||
@@ -249,10 +250,9 @@ export function createReadToolDefinition(
|
||||
if (mimeType) {
|
||||
// Read image as binary.
|
||||
const buffer = await ops.readFile(absolutePath);
|
||||
const base64 = buffer.toString("base64");
|
||||
if (autoResizeImages) {
|
||||
// Resize image if needed before sending it back to the model.
|
||||
const resized = await resizeImage({ type: "image", data: base64, mimeType });
|
||||
const resized = await resizeImage(buffer, mimeType);
|
||||
if (!resized) {
|
||||
let textNote = `Read image file [${mimeType}]\n[Image omitted: could not be resized below the inline image size limit.]`;
|
||||
if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`;
|
||||
@@ -272,7 +272,7 @@ export function createReadToolDefinition(
|
||||
if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`;
|
||||
content = [
|
||||
{ type: "text", text: textNote },
|
||||
{ type: "image", data: base64, mimeType },
|
||||
{ type: "image", data: buffer.toString("base64"), mimeType },
|
||||
];
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -200,44 +200,36 @@ export function createWriteToolDefinition(
|
||||
) {
|
||||
const absolutePath = resolveToCwd(path, cwd);
|
||||
const dir = dirname(absolutePath);
|
||||
return withFileMutationQueue(
|
||||
absolutePath,
|
||||
() =>
|
||||
new Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }>(
|
||||
(resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Operation aborted"));
|
||||
return;
|
||||
}
|
||||
let aborted = false;
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
reject(new Error("Operation aborted"));
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
(async () => {
|
||||
try {
|
||||
// Create parent directories if needed.
|
||||
await ops.mkdir(dir);
|
||||
if (aborted) return;
|
||||
// Write the file contents.
|
||||
await ops.writeFile(absolutePath, content);
|
||||
if (aborted) return;
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve({
|
||||
content: [
|
||||
{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` },
|
||||
],
|
||||
details: undefined,
|
||||
});
|
||||
} catch (error: any) {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
if (!aborted) reject(error);
|
||||
}
|
||||
})();
|
||||
},
|
||||
),
|
||||
);
|
||||
return withFileMutationQueue(absolutePath, async () => {
|
||||
let aborted = signal?.aborted ?? false;
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
};
|
||||
const throwIfAborted = (): void => {
|
||||
if (aborted || signal?.aborted) {
|
||||
throw new Error("Operation aborted");
|
||||
}
|
||||
};
|
||||
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
throwIfAborted();
|
||||
// Create parent directories if needed.
|
||||
await ops.mkdir(dir);
|
||||
throwIfAborted();
|
||||
|
||||
// Write the file contents.
|
||||
await ops.writeFile(absolutePath, content);
|
||||
throwIfAborted();
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }],
|
||||
details: undefined,
|
||||
};
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
});
|
||||
},
|
||||
renderCall(args, theme, context) {
|
||||
const renderArgs = args as { path?: string; file_path?: string; content?: string } | undefined;
|
||||
|
||||
Reference in New Issue
Block a user