refactor(coding-agent/core): restructured tool parameter naming to snake_case convention

- Standardized tool parameter naming from camelCase to snake_case across all tools.
- Updated edit tool parameters oldText/newText to old_text/new_text.
- Updated grep tool parameters ignoreCase/headLimit to ignore_case/head_limit.
- Updated python tool parameter timeoutMs to timeout_ms.
- Updated todo-write tool parameter activeForm to active_form.
- Marked read tool as non-abortable to improve performance.
This commit is contained in:
can1357
2026-01-23 05:52:38 +01:00
parent 19a7e33b4e
commit 02a74a60ef
19 changed files with 330 additions and 317 deletions
+7
View File
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
### Added
- Added `isolated` option to run tasks in isolated git worktrees
@@ -9,6 +10,12 @@
### Changed
- Updated edit tool parameters from camelCase to snake_case (oldText → old_text, newText → new_text)
- Updated grep tool parameters from camelCase to snake_case (ignoreCase → ignore_case, caseSensitive → case_sensitive, outputMode → output_mode, headLimit → head_limit)
- Updated python tool parameters from camelCase to snake_case (timeoutMs → timeout_ms)
- Updated todo-write tool parameters from camelCase to snake_case (activeForm → active_form)
- Updated MCP tool name parsing to handle redundant server name prefixes
- Marked read tool as non-abortable to improve performance
- Simplified tool parameter descriptions across all tools for brevity
- Updated find tool to always sort results by modification time
- Changed web-fetch timeout default from 20s to 45s maximum
+2 -2
View File
@@ -345,10 +345,10 @@ Tool inputs:
- `bash`: `{ command, timeout? }`
- `read`: `{ path, offset?, limit? }`
- `write`: `{ path, content }`
- `edit`: `{ path, oldText, newText }`
- `edit`: `{ path, old_text, new_text }`
- `ls`: `{ path?, limit? }`
- `find`: `{ pattern, path?, limit? }`
- `grep`: `{ pattern, path?, glob?, ignoreCase?, literal?, context?, limit? }`
- `grep`: `{ pattern, path?, glob?, ignore_case?, literal?, context?, limit? }`
#### tool_result
@@ -587,7 +587,7 @@ export class AgentSession {
const args = toolCall.arguments;
if (!args || typeof args !== "object" || Array.isArray(args)) return;
if ("oldText" in args || "newText" in args) return;
if ("old_text" in args || "new_text" in args) return;
const path = typeof args.path === "string" ? args.path : undefined;
if (!path) return;
@@ -632,7 +632,7 @@ export class AgentSession {
const args = toolCall.arguments;
if (!args || typeof args !== "object" || Array.isArray(args)) return;
if ("oldText" in args || "newText" in args) return;
if ("old_text" in args || "new_text" in args) return;
const path = typeof args.path === "string" ? args.path : undefined;
const diff = typeof args.diff === "string" ? args.diff : undefined;
@@ -164,11 +164,11 @@ export class CursorExecHandlers implements ICursorExecHandlers {
pattern: args.pattern,
path: args.path || undefined,
glob: args.glob || undefined,
outputMode: args.outputMode || undefined,
output_mode: args.outputMode || undefined,
context: args.context ?? args.contextBefore ?? args.contextAfter ?? undefined,
ignoreCase: args.caseInsensitive || undefined,
ignore_case: args.caseInsensitive || undefined,
type: args.type || undefined,
headLimit: args.headLimit ?? undefined,
head_limit: args.headLimit ?? undefined,
multiline: args.multiline || undefined,
});
return toolResultMessage;
@@ -66,21 +66,38 @@ function formatMCPContent(content: MCPContent[]): string {
/**
* Create a unique tool name for an MCP tool.
* Prefixes with server name to avoid conflicts.
*
* Prefixes with server name to avoid conflicts. If the tool name already
* starts with the server name (e.g., server "puppeteer" with tool
* "puppeteer_screenshot"), strips the redundant prefix to produce
* "mcp_puppeteer_screenshot" instead of "mcp_puppeteer_puppeteer_screenshot".
*/
export function createMCPToolName(serverName: string, toolName: string): string {
// Use underscore separator since tool names can't have special chars
return `mcp_${serverName}_${toolName}`;
// Strip redundant server name prefix from tool name if present
const prefixWithUnderscore = `${serverName}_`;
const prefixWithHyphen = `${serverName}-`;
let normalizedToolName = toolName;
if (toolName.startsWith(prefixWithUnderscore)) {
normalizedToolName = toolName.slice(prefixWithUnderscore.length);
} else if (toolName.startsWith(prefixWithHyphen)) {
normalizedToolName = toolName.slice(prefixWithHyphen.length);
}
return `mcp_${serverName}_${normalizedToolName}`;
}
/**
* Parse an MCP tool name back to server and tool components.
*
* Note: This returns the normalized tool name (with server prefix stripped).
* The original MCP tool name may have had the server name as a prefix.
*/
export function parseMCPToolName(name: string): { serverName: string; toolName: string } | null {
if (!name.startsWith("mcp_")) return null;
const rest = name.slice(4);
const underscoreIdx = rest.lastIndexOf("_");
const underscoreIdx = rest.indexOf("_");
if (underscoreIdx === -1) return null;
return {
@@ -120,7 +120,7 @@ export interface PythonSettings {
export interface EditSettings {
fuzzyMatch?: boolean; // default: true (accept high-confidence fuzzy matches for whitespace/indentation)
fuzzyThreshold?: number; // default: 0.95 (similarity threshold for fuzzy matching)
patchMode?: boolean; // default: true (use codex-style apply-patch format instead of oldText/newText)
patchMode?: boolean; // default: true (use codex-style apply-patch format instead of old_text/new_text)
streamingAbort?: boolean; // default: false (abort streaming edit tool calls when patch preview fails)
}
+29 -29
View File
@@ -29,18 +29,18 @@ const grepSchema = Type.Object({
path: Type.Optional(Type.String({ description: "Directory or file to search (default: cwd)" })),
glob: Type.Optional(Type.String({ description: "Glob filter, e.g. '*.ts', '**/*.spec.ts'" })),
type: Type.Optional(Type.String({ description: "File type filter, e.g. 'ts', 'py', 'rust'" })),
ignoreCase: Type.Optional(Type.Boolean({ description: "Force case-insensitive (default: smart-case)" })),
caseSensitive: Type.Optional(Type.Boolean({ description: "Force case-sensitive (default: smart-case)" })),
ignore_case: Type.Optional(Type.Boolean({ description: "Force case-insensitive (default: smart-case)" })),
case_sensitive: Type.Optional(Type.Boolean({ description: "Force case-sensitive (default: smart-case)" })),
literal: Type.Optional(Type.Boolean({ description: "Treat pattern as literal, not regex (default: false)" })),
multiline: Type.Optional(Type.Boolean({ description: "Match across line boundaries (default: false)" })),
context: Type.Optional(Type.Number({ description: "Lines of context before/after match (default: 0)" })),
limit: Type.Optional(Type.Number({ description: "Max matches to return (default: 100)" })),
outputMode: Type.Optional(
output_mode: Type.Optional(
StringEnum(["content", "files_with_matches", "count"], {
description: "Output format (default: content)",
}),
),
headLimit: Type.Optional(Type.Number({ description: "Truncate output to first N results" })),
head_limit: Type.Optional(Type.Number({ description: "Truncate output to first N results" })),
offset: Type.Optional(Type.Number({ description: "Skip first N results (default: 0)" })),
});
@@ -88,14 +88,14 @@ interface GrepParams {
path?: string;
glob?: string;
type?: string;
ignoreCase?: boolean;
caseSensitive?: boolean;
ignore_case?: boolean;
case_sensitive?: boolean;
literal?: boolean;
multiline?: boolean;
context?: number;
limit?: number;
outputMode?: "content" | "files_with_matches" | "count";
headLimit?: number;
output_mode?: "content" | "files_with_matches" | "count";
head_limit?: number;
offset?: number;
}
@@ -154,14 +154,14 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
path: searchDir,
glob,
type,
ignoreCase,
caseSensitive,
ignore_case,
case_sensitive,
literal,
multiline,
context,
limit,
outputMode,
headLimit,
output_mode,
head_limit,
offset,
} = params;
@@ -195,9 +195,9 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
}
const contextValue = context && context > 0 ? context : 0;
const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
const effectiveOutputMode = outputMode ?? "content";
const effectiveOutputMode = output_mode ?? "content";
const effectiveOffset = offset && offset > 0 ? offset : 0;
const hasHeadLimit = headLimit !== undefined && headLimit > 0;
const hasHeadLimit = head_limit !== undefined && head_limit > 0;
const formatPath = (filePath: string): string => {
if (isDirectory) {
@@ -237,9 +237,9 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
args.push("--json", "--line-number", "--color=never", "--hidden");
}
if (caseSensitive) {
if (case_sensitive) {
args.push("--case-sensitive");
} else if (ignoreCase) {
} else if (ignore_case) {
args.push("--ignore-case");
} else {
args.push("--smart-case");
@@ -321,13 +321,13 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
};
}
// Apply offset and headLimit
// Apply offset and head_limit
let processedLines = lines;
if (effectiveOffset > 0) {
processedLines = processedLines.slice(effectiveOffset);
}
if (hasHeadLimit) {
processedLines = processedLines.slice(0, headLimit);
processedLines = processedLines.slice(0, head_limit);
}
let simpleMatchCount = 0;
@@ -395,7 +395,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
})),
mode: effectiveOutputMode,
truncated: truncatedByHeadLimit,
headLimitReached: truncatedByHeadLimit ? headLimit : undefined,
headLimitReached: truncatedByHeadLimit ? head_limit : undefined,
},
};
}
@@ -412,7 +412,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
files: simpleFileList,
mode: effectiveOutputMode,
truncated: truncatedByHeadLimit,
headLimitReached: truncatedByHeadLimit ? headLimit : undefined,
headLimitReached: truncatedByHeadLimit ? head_limit : undefined,
},
};
}
@@ -528,13 +528,13 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
};
}
// Apply offset and headLimit to output lines
// Apply offset and head_limit to output lines
let processedLines = outputLines;
if (effectiveOffset > 0) {
processedLines = processedLines.slice(effectiveOffset);
}
if (hasHeadLimit) {
processedLines = processedLines.slice(0, headLimit);
processedLines = processedLines.slice(0, head_limit);
}
// Apply byte truncation (no line limit since we already have match limit)
@@ -554,7 +554,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
})),
mode: effectiveOutputMode,
truncated: matchLimitReached || truncation.truncated || truncatedByHeadLimit,
headLimitReached: truncatedByHeadLimit ? headLimit : undefined,
headLimitReached: truncatedByHeadLimit ? head_limit : undefined,
};
// Build notices
@@ -598,13 +598,13 @@ interface GrepRenderArgs {
path?: string;
glob?: string;
type?: string;
ignoreCase?: boolean;
caseSensitive?: boolean;
ignore_case?: boolean;
case_sensitive?: boolean;
literal?: boolean;
multiline?: boolean;
context?: number;
limit?: number;
outputMode?: string;
output_mode?: string;
}
const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
@@ -621,10 +621,10 @@ export const grepToolRenderer = {
if (args.path) meta.push(`in ${args.path}`);
if (args.glob) meta.push(`glob:${args.glob}`);
if (args.type) meta.push(`type:${args.type}`);
if (args.outputMode && args.outputMode !== "files_with_matches") meta.push(`mode:${args.outputMode}`);
if (args.caseSensitive) {
if (args.output_mode && args.output_mode !== "files_with_matches") meta.push(`mode:${args.output_mode}`);
if (args.case_sensitive) {
meta.push("case:sensitive");
} else if (args.ignoreCase) {
} else if (args.ignore_case) {
meta.push("case:insensitive");
}
if (args.literal) meta.push("literal");
@@ -91,8 +91,8 @@ export { ApplyPatchError, EditMatchError, ParseError } from "./types";
const replaceEditSchema = Type.Object({
path: Type.String({ description: "File path (relative or absolute)" }),
oldText: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }),
newText: Type.String({ description: "Replacement text" }),
old_text: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }),
new_text: Type.String({ description: "Replacement text" }),
all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: unique match required)" })),
});
@@ -107,7 +107,7 @@ const patchEditSchema = Type.Object({
diff: Type.Optional(Type.String({ description: "Diff hunks (update) or full content (create)" })),
});
export type ReplaceParams = { path: string; oldText: string; newText: string; all?: boolean };
export type ReplaceParams = { path: string; old_text: string; new_text: string; all?: boolean };
export type PatchParams = { path: string; op?: string; rename?: string; diff?: string };
// ═══════════════════════════════════════════════════════════════════════════
@@ -346,14 +346,14 @@ export class EditTool implements AgentTool<TInput> {
// ─────────────────────────────────────────────────────────────────
// Replace mode execution
// ─────────────────────────────────────────────────────────────────
const { path, oldText, newText, all } = params as ReplaceParams;
const { path, old_text, new_text, all } = params as ReplaceParams;
if (path.endsWith(".ipynb")) {
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
}
if (oldText.length === 0) {
throw new Error("oldText must not be empty.");
if (old_text.length === 0) {
throw new Error("old_text must not be empty.");
}
const absolutePath = resolveToCwd(path, this.session.cwd);
@@ -367,8 +367,8 @@ export class EditTool implements AgentTool<TInput> {
const { bom, text: content } = stripBom(rawContent);
const originalEnding = detectLineEnding(content);
const normalizedContent = normalizeToLF(content);
const normalizedOldText = normalizeToLF(oldText);
const normalizedNewText = normalizeToLF(newText);
const normalizedOldText = normalizeToLF(old_text);
const normalizedNewText = normalizeToLF(new_text);
const result = replaceText(normalizedContent, normalizedOldText, normalizedNewText, {
fuzzy: this.allowFuzzy,
@@ -46,7 +46,7 @@ export const pythonSchema = Type.Object({
}),
{ description: "Cells to execute sequentially in persistent kernel" },
),
timeoutMs: Type.Optional(Type.Number({ description: "Timeout in ms (default: 30000)" })),
timeout_ms: Type.Optional(Type.Number({ description: "Timeout in ms (default: 30000)" })),
cwd: Type.Optional(Type.String({ description: "Working directory (default: cwd)" })),
reset: Type.Optional(Type.Boolean({ description: "Restart kernel before execution" })),
});
@@ -166,7 +166,7 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
throw new Error("Python tool requires a session when not using proxy executor");
}
const { cells, timeoutMs = 30000, cwd, reset } = params;
const { cells, timeout_ms = 30000, cwd, reset } = params;
const controller = new AbortController();
const onAbort = () => controller.abort();
signal?.addEventListener("abort", onAbort, { once: true });
@@ -256,7 +256,7 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
const sessionId = sessionFile ? `session:${sessionFile}:cwd:${commandCwd}` : `cwd:${commandCwd}`;
const baseExecutorOptions: Omit<PythonExecutorOptions, "reset"> = {
cwd: commandCwd,
timeoutMs,
timeoutMs: timeout_ms,
signal: controller.signal,
sessionId,
kernelMode: this.session.settings?.getPythonKernelMode?.() ?? "session",
+204 -215
View File
@@ -4,7 +4,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { ptree, untilAborted } from "@oh-my-pi/pi-utils";
import { ptree } from "@oh-my-pi/pi-utils";
import { Type } from "@sinclair/typebox";
import { CONFIG_DIR_NAME } from "../../config";
import type { Theme } from "../../modes/interactive/theme/theme";
@@ -388,6 +388,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
public readonly label = "Read";
public readonly description: string;
public readonly parameters = readSchema;
public readonly nonAbortable = true;
private readonly session: ToolSession;
private readonly autoResizeImages: boolean;
@@ -414,248 +415,236 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
const { path: readPath, offset, limit, lines } = params;
const absolutePath = resolveReadPath(readPath, this.session.cwd);
return untilAborted(signal, async () => {
let isDirectory = false;
let fileSize = 0;
try {
const stat = await Bun.file(absolutePath).stat();
fileSize = stat.size;
isDirectory = stat.isDirectory();
} catch (error) {
if (isNotFoundError(error)) {
let message = `File not found: ${readPath}`;
let isDirectory = false;
let fileSize = 0;
try {
const stat = await Bun.file(absolutePath).stat();
fileSize = stat.size;
isDirectory = stat.isDirectory();
} catch (error) {
if (isNotFoundError(error)) {
let message = `File not found: ${readPath}`;
// Skip fuzzy matching for remote mounts (sshfs) to avoid hangs
if (!isRemoteMountPath(absolutePath)) {
const suggestions = await findReadPathSuggestions(readPath, this.session.cwd, signal);
// Skip fuzzy matching for remote mounts (sshfs) to avoid hangs
if (!isRemoteMountPath(absolutePath)) {
const suggestions = await findReadPathSuggestions(readPath, this.session.cwd, signal);
if (suggestions?.suggestions.length) {
const scopeLabel = suggestions.scopeLabel ? ` in ${suggestions.scopeLabel}` : "";
message += `\n\nClosest matches${scopeLabel}:\n${suggestions.suggestions.map((match) => `- ${match}`).join("\n")}`;
if (suggestions.truncated) {
message += `\n[Search truncated to first ${MAX_FUZZY_CANDIDATES} paths. Refine the path if the match isn't listed.]`;
}
} else if (suggestions?.error) {
message += `\n\nFuzzy match failed: ${suggestions.error}`;
} else if (suggestions?.scopeLabel) {
message += `\n\nNo similar paths found in ${suggestions.scopeLabel}.`;
if (suggestions?.suggestions.length) {
const scopeLabel = suggestions.scopeLabel ? ` in ${suggestions.scopeLabel}` : "";
message += `\n\nClosest matches${scopeLabel}:\n${suggestions.suggestions.map((match) => `- ${match}`).join("\n")}`;
if (suggestions.truncated) {
message += `\n[Search truncated to first ${MAX_FUZZY_CANDIDATES} paths. Refine the path if the match isn't listed.]`;
}
} else if (suggestions?.error) {
message += `\n\nFuzzy match failed: ${suggestions.error}`;
} else if (suggestions?.scopeLabel) {
message += `\n\nNo similar paths found in ${suggestions.scopeLabel}.`;
}
throw new Error(message);
}
throw error;
throw new Error(message);
}
throw error;
}
if (isDirectory) {
const lsResult = await this.lsTool.execute(toolCallId, { path: readPath, limit }, signal);
return {
content: lsResult.content,
details: { redirectedTo: "ls", truncation: lsResult.details?.truncation },
};
}
if (isDirectory) {
const lsResult = await this.lsTool.execute(toolCallId, { path: readPath, limit }, signal);
return {
content: lsResult.content,
details: { redirectedTo: "ls", truncation: lsResult.details?.truncation },
};
}
const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath);
const ext = path.extname(absolutePath).toLowerCase();
const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath);
const ext = path.extname(absolutePath).toLowerCase();
// Read the file based on type
let content: (TextContent | ImageContent)[];
let details: ReadToolDetails | undefined;
// Read the file based on type
let content: (TextContent | ImageContent)[];
let details: ReadToolDetails | undefined;
if (mimeType) {
if (fileSize > MAX_IMAGE_SIZE) {
const sizeStr = formatSize(fileSize);
if (mimeType) {
if (fileSize > MAX_IMAGE_SIZE) {
const sizeStr = formatSize(fileSize);
const maxStr = formatSize(MAX_IMAGE_SIZE);
throw new Error(`Image file too large: ${sizeStr} exceeds ${maxStr} limit.`);
} else {
// Read as image (binary)
const file = Bun.file(absolutePath);
const buffer = await file.arrayBuffer();
// Check actual buffer size after reading to prevent OOM during serialization
if (buffer.byteLength > MAX_IMAGE_SIZE) {
const sizeStr = formatSize(buffer.byteLength);
const maxStr = formatSize(MAX_IMAGE_SIZE);
content = [
{
type: "text",
text: `[Image file too large: ${sizeStr} exceeds ${maxStr} limit. Use an image viewer or resize the image.]`,
},
];
throw new Error(`Image file too large: ${sizeStr} exceeds ${maxStr} limit.`);
} else {
// Read as image (binary)
const file = Bun.file(absolutePath);
const buffer = await file.arrayBuffer();
const base64 = Buffer.from(buffer).toString("base64");
// Check actual buffer size after reading to prevent OOM during serialization
if (buffer.byteLength > MAX_IMAGE_SIZE) {
const sizeStr = formatSize(buffer.byteLength);
const maxStr = formatSize(MAX_IMAGE_SIZE);
content = [
{
type: "text",
text: `[Image file too large: ${sizeStr} exceeds ${maxStr} limit. Use an image viewer or resize the image.]`,
},
];
} else {
const base64 = Buffer.from(buffer).toString("base64");
if (this.autoResizeImages) {
// Resize image if needed - catch errors from WASM
try {
const resized = await resizeImage({ type: "image", data: base64, mimeType });
const dimensionNote = formatDimensionNote(resized);
if (this.autoResizeImages) {
// Resize image if needed - catch errors from WASM
try {
const resized = await resizeImage({ type: "image", data: base64, mimeType });
const dimensionNote = formatDimensionNote(resized);
let textNote = `Read image file [${resized.mimeType}]`;
if (dimensionNote) {
textNote += `\n${dimensionNote}`;
}
content = [
{ type: "text", text: textNote },
{ type: "image", data: resized.data, mimeType: resized.mimeType },
];
} catch {
// Fall back to original image on resize failure
content = [
{ type: "text", text: `Read image file [${mimeType}]` },
{ type: "image", data: base64, mimeType },
];
let textNote = `Read image file [${resized.mimeType}]`;
if (dimensionNote) {
textNote += `\n${dimensionNote}`;
}
} else {
content = [
{ type: "text", text: textNote },
{ type: "image", data: resized.data, mimeType: resized.mimeType },
];
} catch {
// Fall back to original image on resize failure
content = [
{ type: "text", text: `Read image file [${mimeType}]` },
{ type: "image", data: base64, mimeType },
];
}
}
}
} else if (CONVERTIBLE_EXTENSIONS.has(ext)) {
// Convert document via markitdown
const result = await convertWithMarkitdown(absolutePath, signal);
if (result.ok) {
// Apply truncation to converted content
const truncation = truncateHead(result.content);
let outputText = truncation.content;
if (truncation.truncated) {
outputText += `\n\n[Document converted via markitdown. Output truncated to ${formatSize(DEFAULT_MAX_BYTES)}]`;
details = { truncation };
}
content = [{ type: "text", text: outputText }];
} else if (result.error) {
// markitdown not available or failed
const errorMsg =
result.error === "markitdown not found"
? `markitdown not installed. Install with: pip install markitdown`
: result.error || "conversion failed";
content = [{ type: "text", text: `[Cannot read ${ext} file: ${errorMsg}]` }];
} else {
content = [{ type: "text", text: `[Cannot read ${ext} file: conversion failed]` }];
}
} else {
// Read as text
const file = Bun.file(absolutePath);
const textContent = await file.text();
const allLines = textContent.split("\n");
const totalFileLines = allLines.length;
// Apply offset if specified (1-indexed to 0-indexed)
const startLine = offset ? Math.max(0, offset - 1) : 0;
const startLineDisplay = startLine + 1; // For display (1-indexed)
// Check if offset is out of bounds - return graceful message instead of throwing
if (startLine >= allLines.length) {
const suggestion =
allLines.length === 0
? "The file is empty."
: `Use offset=1 to read from the start, or offset=${allLines.length} to read the last line.`;
return {
content: [
{
type: "text",
text: `Offset ${offset} is beyond end of file (${allLines.length} lines total). ${suggestion}`,
},
],
};
}
// If limit is specified by user, use it; otherwise we'll let truncateHead decide
let selectedContent: string;
let userLimitedLines: number | undefined;
if (limit !== undefined) {
const endLine = Math.min(startLine + limit, allLines.length);
selectedContent = allLines.slice(startLine, endLine).join("\n");
userLimitedLines = endLine - startLine;
} else {
selectedContent = allLines.slice(startLine).join("\n");
}
// Apply truncation (respects both line and byte limits)
const truncation = truncateHead(selectedContent);
// Add line numbers if requested (uses setting default if not specified)
const shouldAddLineNumbers = lines ?? this.defaultLineNumbers;
const prependLineNumbers = (text: string, startNum: number): string => {
const textLines = text.split("\n");
const lastLineNum = startNum + textLines.length - 1;
const padWidth = String(lastLineNum).length;
return textLines
.map((line, i) => {
const lineNum = String(startNum + i).padStart(padWidth, " ");
return `${lineNum}\t${line}`;
})
.join("\n");
};
let outputText: string;
if (truncation.firstLineExceedsLimit) {
const firstLine = allLines[startLine] ?? "";
const firstLineBytes = Buffer.byteLength(firstLine, "utf-8");
const snippet = truncateStringToBytesFromStart(firstLine, DEFAULT_MAX_BYTES);
const shownSize = formatSize(snippet.bytes);
outputText = shouldAddLineNumbers ? prependLineNumbers(snippet.text, startLineDisplay) : snippet.text;
if (snippet.text.length > 0) {
outputText += `\n\n[Line ${startLineDisplay} is ${formatSize(
firstLineBytes,
)}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Showing first ${shownSize} of the line.]`;
} else {
outputText = `[Line ${startLineDisplay} is ${formatSize(
firstLineBytes,
)}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Unable to display a valid UTF-8 snippet.]`;
content = [
{ type: "text", text: `Read image file [${mimeType}]` },
{ type: "image", data: base64, mimeType },
];
}
}
}
} else if (CONVERTIBLE_EXTENSIONS.has(ext)) {
// Convert document via markitdown
const result = await convertWithMarkitdown(absolutePath, signal);
if (result.ok) {
// Apply truncation to converted content
const truncation = truncateHead(result.content);
let outputText = truncation.content;
if (truncation.truncated) {
outputText += `\n\n[Document converted via markitdown. Output truncated to ${formatSize(DEFAULT_MAX_BYTES)}]`;
details = { truncation };
} else if (truncation.truncated) {
// Truncation occurred - build actionable notice
const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
const nextOffset = endLineDisplay + 1;
outputText = shouldAddLineNumbers
? prependLineNumbers(truncation.content, startLineDisplay)
: truncation.content;
if (truncation.truncatedBy === "lines") {
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`;
} else {
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(
DEFAULT_MAX_BYTES,
)} limit). Use offset=${nextOffset} to continue]`;
}
details = { truncation };
} else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
// User specified limit, there's more content, but no truncation
const remaining = allLines.length - (startLine + userLimitedLines);
const nextOffset = startLine + userLimitedLines + 1;
outputText = shouldAddLineNumbers
? prependLineNumbers(truncation.content, startLineDisplay)
: truncation.content;
outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue]`;
} else {
// No truncation, no user limit exceeded
outputText = shouldAddLineNumbers
? prependLineNumbers(truncation.content, startLineDisplay)
: truncation.content;
}
content = [{ type: "text", text: outputText }];
} else if (result.error) {
// markitdown not available or failed
const errorMsg =
result.error === "markitdown not found"
? `markitdown not installed. Install with: pip install markitdown`
: result.error || "conversion failed";
content = [{ type: "text", text: `[Cannot read ${ext} file: ${errorMsg}]` }];
} else {
content = [{ type: "text", text: `[Cannot read ${ext} file: conversion failed]` }];
}
} else {
// Read as text
const file = Bun.file(absolutePath);
const textContent = await file.text();
const allLines = textContent.split("\n");
const totalFileLines = allLines.length;
// Apply offset if specified (1-indexed to 0-indexed)
const startLine = offset ? Math.max(0, offset - 1) : 0;
const startLineDisplay = startLine + 1; // For display (1-indexed)
// Check if offset is out of bounds - return graceful message instead of throwing
if (startLine >= allLines.length) {
const suggestion =
allLines.length === 0
? "The file is empty."
: `Use offset=1 to read from the start, or offset=${allLines.length} to read the last line.`;
return {
content: [
{
type: "text",
text: `Offset ${offset} is beyond end of file (${allLines.length} lines total). ${suggestion}`,
},
],
};
}
return { content, details };
});
// If limit is specified by user, use it; otherwise we'll let truncateHead decide
let selectedContent: string;
let userLimitedLines: number | undefined;
if (limit !== undefined) {
const endLine = Math.min(startLine + limit, allLines.length);
selectedContent = allLines.slice(startLine, endLine).join("\n");
userLimitedLines = endLine - startLine;
} else {
selectedContent = allLines.slice(startLine).join("\n");
}
// Apply truncation (respects both line and byte limits)
const truncation = truncateHead(selectedContent);
// Add line numbers if requested (uses setting default if not specified)
const shouldAddLineNumbers = lines ?? this.defaultLineNumbers;
const prependLineNumbers = (text: string, startNum: number): string => {
const textLines = text.split("\n");
const lastLineNum = startNum + textLines.length - 1;
const padWidth = String(lastLineNum).length;
return textLines
.map((line, i) => {
const lineNum = String(startNum + i).padStart(padWidth, " ");
return `${lineNum}\t${line}`;
})
.join("\n");
};
let outputText: string;
if (truncation.firstLineExceedsLimit) {
const firstLine = allLines[startLine] ?? "";
const firstLineBytes = Buffer.byteLength(firstLine, "utf-8");
const snippet = truncateStringToBytesFromStart(firstLine, DEFAULT_MAX_BYTES);
const shownSize = formatSize(snippet.bytes);
outputText = shouldAddLineNumbers ? prependLineNumbers(snippet.text, startLineDisplay) : snippet.text;
if (snippet.text.length > 0) {
outputText += `\n\n[Line ${startLineDisplay} is ${formatSize(
firstLineBytes,
)}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Showing first ${shownSize} of the line.]`;
} else {
outputText = `[Line ${startLineDisplay} is ${formatSize(
firstLineBytes,
)}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Unable to display a valid UTF-8 snippet.]`;
}
details = { truncation };
} else if (truncation.truncated) {
// Truncation occurred - build actionable notice
const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
const nextOffset = endLineDisplay + 1;
outputText = shouldAddLineNumbers
? prependLineNumbers(truncation.content, startLineDisplay)
: truncation.content;
if (truncation.truncatedBy === "lines") {
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`;
} else {
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(
DEFAULT_MAX_BYTES,
)} limit). Use offset=${nextOffset} to continue]`;
}
details = { truncation };
} else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
// User specified limit, there's more content, but no truncation
const remaining = allLines.length - (startLine + userLimitedLines);
const nextOffset = startLine + userLimitedLines + 1;
outputText = shouldAddLineNumbers
? prependLineNumbers(truncation.content, startLineDisplay)
: truncation.content;
outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue]`;
} else {
// No truncation, no user limit exceeded
outputText = shouldAddLineNumbers
? prependLineNumbers(truncation.content, startLineDisplay)
: truncation.content;
}
content = [{ type: "text", text: outputText }];
}
return { content, details };
}
}
@@ -363,7 +363,7 @@ function createMCPProxyTool(metadata: MCPToolMetadata): CustomTool<TSchema> {
}
function getPythonCallTimeoutMs(params: PythonToolParams): number | undefined {
const timeout = params.timeoutMs;
const timeout = params.timeout_ms;
if (typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0) {
return Math.max(1000, Math.round(timeout * 1000) + 1000);
}
@@ -18,7 +18,7 @@ const todoWriteSchema = Type.Object({
Type.Object({
id: Type.Optional(Type.String({ description: "Stable todo id" })),
content: Type.String({ description: "Imperative task description (e.g., 'Run tests')" }),
activeForm: Type.String({ description: "Present continuous form (e.g., 'Running tests')" }),
active_form: Type.String({ description: "Present continuous form (e.g., 'Running tests')" }),
status: StringEnum(["pending", "in_progress", "completed"]),
}),
{ description: "The updated todo list" },
@@ -30,7 +30,7 @@ type TodoStatus = "pending" | "in_progress" | "completed";
export interface TodoItem {
id: string;
content: string;
activeForm: string;
active_form: string;
status: TodoStatus;
}
@@ -47,7 +47,7 @@ export interface TodoWriteToolDetails {
const TODO_FILE_NAME = "todos.json";
type TodoWriteParams = { todos: Array<{ id?: string; content?: string; activeForm?: string; status?: string }> };
type TodoWriteParams = { todos: Array<{ id?: string; content?: string; active_form?: string; status?: string }> };
function normalizeTodoStatus(status?: string): TodoStatus {
switch (status) {
@@ -63,24 +63,24 @@ function normalizeTodoStatus(status?: string): TodoStatus {
}
function normalizeTodos(
items: Array<{ id?: string; content?: string; activeForm?: string; status?: string }>,
items: Array<{ id?: string; content?: string; active_form?: string; status?: string }>,
): TodoItem[] {
return items.map((item) => {
if (!item.content || !item.activeForm) {
throw new Error("Todo content and activeForm are required.");
if (!item.content || !item.active_form) {
throw new Error("Todo content and active_form are required.");
}
const content = item.content.trim();
const activeForm = item.activeForm.trim();
const active_form = item.active_form.trim();
if (!content) {
throw new Error("Todo content cannot be empty.");
}
if (!activeForm) {
throw new Error("Todo activeForm cannot be empty.");
if (!active_form) {
throw new Error("Todo active_form cannot be empty.");
}
return {
id: item.id && item.id.trim().length > 0 ? item.id : randomUUID(),
content,
activeForm,
active_form,
status: normalizeTodoStatus(item.status),
};
});
@@ -144,7 +144,7 @@ function formatTodoSummary(todos: TodoItem[]): string {
function formatTodoLine(item: TodoItem, uiTheme: Theme, prefix: string): string {
const checkbox = uiTheme.checkbox;
const displayText =
item.status === "in_progress" && item.activeForm !== item.content ? item.activeForm : item.content;
item.status === "in_progress" && item.active_form !== item.content ? item.active_form : item.content;
switch (item.status) {
case "completed":
return uiTheme.fg("success", `${prefix}${checkbox.checked} ${chalk.strikethrough(item.content)}`);
@@ -222,7 +222,7 @@ export class TodoWriteTool implements AgentTool<typeof todoWriteSchema, TodoWrit
// =============================================================================
interface TodoWriteRenderArgs {
todos?: Array<{ id?: string; content?: string; activeForm?: string; status?: string }>;
todos?: Array<{ id?: string; content?: string; active_form?: string; status?: string }>;
}
export const todoWriteToolRenderer = {
@@ -307,7 +307,7 @@ export const SETTINGS_DEFS: SettingDef[] = [
tab: "tools",
type: "boolean",
label: "Edit patch mode",
description: "Use codex-style apply-patch format instead of oldText/newText for edits",
description: "Use codex-style apply-patch format instead of old_text/new_text for edits",
get: (sm) => sm.getEditPatchMode(),
set: (sm, v) => sm.setEditPatchMode(v),
},
@@ -223,8 +223,8 @@ export class ToolExecutionComponent extends Container {
return;
}
const oldText = this.args?.oldText;
const newText = this.args?.newText;
const oldText = this.args?.old_text;
const newText = this.args?.new_text;
const all = this.args?.all;
// Need all three params to compute diff
@@ -4,7 +4,7 @@ Performs string replacements in files with fuzzy whitespace matching.
<instruction>
- Use the smallest edit that uniquely identifies the change
- If `oldText` is not unique, expand to include more context or use `all: true` to replace all occurrences
- If `old_text` is not unique, expand to include more context or use `all: true` to replace all occurrences
- You must use your read tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
- Fuzzy matching handles minor whitespace/indentation differences automatically
- Prefer editing existing files over creating new ones
@@ -148,7 +148,7 @@ The assistant did not use the todo list because this is a single command executi
**IMPORTANT**: Task descriptions must have two forms:
- content: The imperative form describing what needs to be done (e.g., "Run tests", "Build the project")
- activeForm: The present continuous form shown during execution (e.g., "Running tests", "Building the project")
- active_form: The present continuous form shown during execution (e.g., "Running tests", "Building the project")
2. **Task Management**:
@@ -175,7 +175,7 @@ The assistant did not use the todo list because this is a single command executi
- Use clear, descriptive task names
- Always provide both forms:
- content: "Fix authentication bug"
- activeForm: "Fixing authentication bug"
- active_form: "Fixing authentication bug"
</protocol>
When in doubt, use this tool. Being proactive with task management demonstrates attentiveness and ensures you complete all requirements successfully.
+29 -29
View File
@@ -44,7 +44,7 @@ describe("Coding Agent Tools", () => {
let originalEditVariant: string | undefined;
beforeEach(() => {
// Force replace mode for edit tool tests using oldText/newText
// Force replace mode for edit tool tests using old_text/new_text
originalEditVariant = process.env.OMP_EDIT_VARIANT;
process.env.OMP_EDIT_VARIANT = "replace";
@@ -260,8 +260,8 @@ describe("Coding Agent Tools", () => {
const result = await editTool.execute("test-call-5", {
path: testFile,
oldText: "world",
newText: "testing",
old_text: "world",
new_text: "testing",
});
expect(getTextOutput(result)).toContain("Successfully replaced");
@@ -279,8 +279,8 @@ describe("Coding Agent Tools", () => {
await expect(
editTool.execute("test-call-6", {
path: testFile,
oldText: "nonexistent",
newText: "testing",
old_text: "nonexistent",
new_text: "testing",
}),
).rejects.toThrow(/Could not find/);
});
@@ -293,8 +293,8 @@ describe("Coding Agent Tools", () => {
await expect(
editTool.execute("test-call-7", {
path: testFile,
oldText: "foo",
newText: "bar",
old_text: "foo",
new_text: "bar",
}),
).rejects.toThrow(/Found 3 occurrences/);
});
@@ -305,8 +305,8 @@ describe("Coding Agent Tools", () => {
const result = await editTool.execute("test-all-1", {
path: testFile,
oldText: "foo",
newText: "qux",
old_text: "foo",
new_text: "qux",
all: true,
});
@@ -337,8 +337,8 @@ function b() {
await expect(
editTool.execute("test-all-fuzzy", {
path: testFile,
oldText: "if (x) {\n doThing();\n}",
newText: "if (y) {\n doOther();\n}",
old_text: "if (x) {\n doThing();\n}",
new_text: "if (y) {\n doOther();\n}",
all: true,
}),
).rejects.toThrow(/Found 2 high-confidence matches/);
@@ -351,8 +351,8 @@ function b() {
await expect(
editTool.execute("test-all-nomatch", {
path: testFile,
oldText: "nonexistent",
newText: "bar",
old_text: "nonexistent",
new_text: "bar",
all: true,
}),
).rejects.toThrow(/Could not find/);
@@ -364,8 +364,8 @@ function b() {
const result = await editTool.execute("test-all-multiline", {
path: testFile,
oldText: "foo\nbar",
newText: "replaced",
old_text: "foo\nbar",
new_text: "replaced",
all: true,
});
@@ -380,8 +380,8 @@ function b() {
const result = await editTool.execute("test-all-single", {
path: testFile,
oldText: "world",
newText: "universe",
old_text: "world",
new_text: "universe",
all: true,
});
@@ -531,7 +531,7 @@ describe("edit tool CRLF handling", () => {
let originalEditVariant: string | undefined;
beforeEach(() => {
// Force replace mode for edit tool tests using oldText/newText
// Force replace mode for edit tool tests using old_text/new_text
originalEditVariant = process.env.OMP_EDIT_VARIANT;
process.env.OMP_EDIT_VARIANT = "replace";
@@ -551,15 +551,15 @@ describe("edit tool CRLF handling", () => {
}
});
it("should match LF oldText against CRLF file content", async () => {
it("should match LF old_text against CRLF file content", async () => {
const testFile = join(testDir, "crlf-test.txt");
writeFileSync(testFile, "line one\r\nline two\r\nline three\r\n");
const result = await editTool.execute("test-crlf-1", {
path: testFile,
oldText: "line two\n",
newText: "replaced line\n",
old_text: "line two\n",
new_text: "replaced line\n",
});
expect(getTextOutput(result)).toContain("Successfully replaced");
@@ -571,8 +571,8 @@ describe("edit tool CRLF handling", () => {
await editTool.execute("test-crlf-2", {
path: testFile,
oldText: "second\n",
newText: "REPLACED\n",
old_text: "second\n",
new_text: "REPLACED\n",
});
const content = readFileSync(testFile, "utf-8");
@@ -585,8 +585,8 @@ describe("edit tool CRLF handling", () => {
await editTool.execute("test-lf-1", {
path: testFile,
oldText: "second\n",
newText: "REPLACED\n",
old_text: "second\n",
new_text: "REPLACED\n",
});
const content = readFileSync(testFile, "utf-8");
@@ -601,8 +601,8 @@ describe("edit tool CRLF handling", () => {
await expect(
editTool.execute("test-crlf-dup", {
path: testFile,
oldText: "hello\nworld\n",
newText: "replaced\n",
old_text: "hello\nworld\n",
new_text: "replaced\n",
}),
).rejects.toThrow(/Found 2 occurrences/);
});
@@ -614,8 +614,8 @@ describe("edit tool CRLF handling", () => {
await editTool.execute("test-bom", {
path: testFile,
oldText: "second\n",
newText: "REPLACED\n",
old_text: "second\n",
new_text: "REPLACED\n",
});
const content = readFileSync(testFile, "utf-8");
@@ -40,7 +40,7 @@ describe("python tool execution", () => {
const tool = new PythonTool(createSession(tempDir.path));
const result = await tool.execute(
"call-id",
{ cells: [{ code: "print('hi')" }], timeoutMs: 5000, cwd: tempDir.path, reset: true },
{ cells: [{ code: "print('hi')" }], timeout_ms: 5000, cwd: tempDir.path, reset: true },
undefined,
undefined,
undefined,
@@ -57,7 +57,7 @@ describe("python tool schema", () => {
expect(schema.type).toBe("object");
expect(schema.properties.cells.type).toBe("array");
expect(schema.properties.timeoutMs.type).toBe("number");
expect(schema.properties.timeout_ms.type).toBe("number");
expect(schema.properties.cwd.type).toBe("string");
expect(schema.properties.reset.type).toBe("boolean");
expect(schema.required).toEqual(["cells"]);