fix: align the schemas, params, and descriptions (#128)

This commit is contained in:
Charles Packer
2025-11-26 19:12:31 -08:00
committed by GitHub
parent 3543276709
commit 36c571f38f
24 changed files with 1374 additions and 171 deletions

View File

@@ -8,7 +8,14 @@ interface GrepFilesArgs {
limit?: number;
}
type GrepFilesResult = Awaited<ReturnType<typeof grep>>;
interface GrepFilesResult {
output: string;
matches?: number;
files?: number;
truncated?: boolean;
}
const DEFAULT_LIMIT = 100;
/**
* Codex-style grep_files tool.
@@ -19,7 +26,7 @@ export async function grep_files(
): Promise<GrepFilesResult> {
validateRequiredParams(args, ["pattern"], "grep_files");
const { pattern, include, path } = args;
const { pattern, include, path, limit = DEFAULT_LIMIT } = args;
const grepArgs: GrepArgs = {
pattern,
@@ -28,5 +35,34 @@ export async function grep_files(
output_mode: "files_with_matches",
};
return grep(grepArgs);
const result = await grep(grepArgs);
// The underlying grep result already has the correct files count
const totalFiles = result.files ?? 0;
// Apply limit to the file list
if (result.output && limit > 0 && totalFiles > limit) {
// The output format is: "Found N files\n/path/to/file1\n/path/to/file2..."
const lines = result.output
.split("\n")
.filter((line) => line.trim() !== "");
// First line is "Found N files", rest are file paths
const filePaths = lines.slice(1);
const truncatedFiles = filePaths.slice(0, limit);
const truncatedOutput = `Found ${limit} file${limit !== 1 ? "s" : ""} (truncated from ${totalFiles})\n${truncatedFiles.join("\n")}`;
return {
output: truncatedOutput,
files: limit,
truncated: true,
};
}
return {
output: result.output,
files: totalFiles,
truncated: false,
};
}

View File

@@ -1,6 +1,10 @@
import { ls } from "./LS.js";
import { promises as fs } from "node:fs";
import * as path from "node:path";
import { validateRequiredParams } from "./validation.js";
const MAX_ENTRY_LENGTH = 500;
const INDENTATION_SPACES = 2;
interface ListDirCodexArgs {
dir_path: string;
offset?: number;
@@ -8,19 +12,212 @@ interface ListDirCodexArgs {
depth?: number;
}
type ListDirCodexResult = Awaited<ReturnType<typeof ls>>;
interface ListDirCodexResult {
content: string;
}
interface DirEntry {
name: string; // Full relative path for sorting
displayName: string; // Just the filename for display
depth: number; // Indentation depth
kind: "directory" | "file" | "symlink" | "other";
}
/**
* Codex-style list_dir tool.
* Delegates to the existing LS implementation; offset/limit/depth are accepted but currently ignored.
* Lists entries with pagination (offset/limit) and depth control.
*/
export async function list_dir(
args: ListDirCodexArgs,
): Promise<ListDirCodexResult> {
validateRequiredParams(args, ["dir_path"], "list_dir");
const { dir_path } = args;
const { dir_path, offset = 1, limit = 25, depth = 2 } = args;
// LS handles path resolution and formatting.
return ls({ path: dir_path, ignore: [] });
if (offset < 1) {
throw new Error("offset must be a 1-indexed entry number");
}
if (limit < 1) {
throw new Error("limit must be greater than zero");
}
if (depth < 1) {
throw new Error("depth must be greater than zero");
}
if (!path.isAbsolute(dir_path)) {
throw new Error("dir_path must be an absolute path");
}
const entries = await listDirSlice(dir_path, offset, limit, depth);
const output = [`Absolute path: ${dir_path}`, ...entries];
return { content: output.join("\n") };
}
/**
* List directory entries with pagination.
*/
async function listDirSlice(
dirPath: string,
offset: number,
limit: number,
maxDepth: number,
): Promise<string[]> {
const entries: DirEntry[] = [];
await collectEntries(dirPath, "", maxDepth, entries);
if (entries.length === 0) {
return [];
}
const startIndex = offset - 1;
if (startIndex >= entries.length) {
throw new Error("offset exceeds directory entry count");
}
const remainingEntries = entries.length - startIndex;
const cappedLimit = Math.min(limit, remainingEntries);
const endIndex = startIndex + cappedLimit;
// Get the selected entries and sort by name
const selectedEntries = entries.slice(startIndex, endIndex);
selectedEntries.sort((a, b) => a.name.localeCompare(b.name));
const formatted: string[] = [];
for (const entry of selectedEntries) {
formatted.push(formatEntryLine(entry));
}
if (endIndex < entries.length) {
formatted.push(`More than ${cappedLimit} entries found`);
}
return formatted;
}
/**
* Recursively collect directory entries using BFS.
*/
async function collectEntries(
dirPath: string,
relativePrefix: string,
remainingDepth: number,
entries: DirEntry[],
): Promise<void> {
const queue: Array<{ absPath: string; prefix: string; depth: number }> = [
{ absPath: dirPath, prefix: relativePrefix, depth: remainingDepth },
];
while (queue.length > 0) {
const current = queue.shift();
if (!current) break;
const { absPath, prefix, depth } = current;
const dirEntries: Array<{
absPath: string;
relativePath: string;
kind: DirEntry["kind"];
entry: DirEntry;
}> = [];
try {
const items = await fs.readdir(absPath, { withFileTypes: true });
for (const item of items) {
const itemAbsPath = path.join(absPath, item.name);
const relativePath = prefix ? path.join(prefix, item.name) : item.name;
const displayName = formatEntryComponent(item.name);
const displayDepth = prefix ? prefix.split(path.sep).length : 0;
const sortKey = formatEntryName(relativePath);
let kind: DirEntry["kind"];
if (item.isSymbolicLink()) {
kind = "symlink";
} else if (item.isDirectory()) {
kind = "directory";
} else if (item.isFile()) {
kind = "file";
} else {
kind = "other";
}
dirEntries.push({
absPath: itemAbsPath,
relativePath,
kind,
entry: {
name: sortKey,
displayName,
depth: displayDepth,
kind,
},
});
}
} catch (err) {
throw new Error(`failed to read directory: ${err}`);
}
// Sort entries alphabetically
dirEntries.sort((a, b) => a.entry.name.localeCompare(b.entry.name));
for (const item of dirEntries) {
// Queue subdirectories for traversal if depth allows
if (item.kind === "directory" && depth > 1) {
queue.push({
absPath: item.absPath,
prefix: item.relativePath,
depth: depth - 1,
});
}
entries.push(item.entry);
}
}
}
/**
* Format entry name for sorting (normalize path separators).
*/
function formatEntryName(filePath: string): string {
const normalized = filePath.replace(/\\/g, "/");
if (normalized.length > MAX_ENTRY_LENGTH) {
return normalized.substring(0, MAX_ENTRY_LENGTH);
}
return normalized;
}
/**
* Format a single path component.
*/
function formatEntryComponent(name: string): string {
if (name.length > MAX_ENTRY_LENGTH) {
return name.substring(0, MAX_ENTRY_LENGTH);
}
return name;
}
/**
* Format a directory entry for display.
*/
function formatEntryLine(entry: DirEntry): string {
const indent = " ".repeat(entry.depth * INDENTATION_SPACES);
let name = entry.displayName;
switch (entry.kind) {
case "directory":
name += "/";
break;
case "symlink":
name += "@";
break;
case "other":
name += "?";
break;
default:
// "file" type has no suffix
break;
}
return `${indent}${name}`;
}

View File

@@ -1,6 +1,10 @@
import { read } from "./Read.js";
import { promises as fs } from "node:fs";
import { validateRequiredParams } from "./validation.js";
const MAX_LINE_LENGTH = 500;
const TAB_WIDTH = 4;
const COMMENT_PREFIXES = ["#", "//", "--"];
interface IndentationOptions {
anchor_line?: number;
max_levels?: number;
@@ -21,22 +25,275 @@ interface ReadFileCodexResult {
content: string;
}
interface LineRecord {
number: number;
raw: string;
display: string;
indent: number;
}
/**
* Codex-style read_file tool.
* Currently supports slice-style reading; indentation mode is ignored but accepted.
* Supports both slice mode (simple range) and indentation mode (context-aware block reading).
*/
export async function read_file(
args: ReadFileCodexArgs,
): Promise<ReadFileCodexResult> {
validateRequiredParams(args, ["file_path"], "read_file");
const { file_path, offset, limit } = args;
const result = await read({
const {
file_path,
offset,
limit,
});
offset = 1,
limit = 2000,
mode = "slice",
indentation,
} = args;
return { content: result.content };
if (offset < 1) {
throw new Error("offset must be a 1-indexed line number");
}
if (limit < 1) {
throw new Error("limit must be greater than zero");
}
let lines: string[];
if (mode === "indentation") {
lines = await readIndentationMode(
file_path,
offset,
limit,
indentation ?? {},
);
} else {
lines = await readSliceMode(file_path, offset, limit);
}
return { content: lines.join("\n") };
}
/**
* Simple slice mode: read lines from offset to offset + limit.
*/
async function readSliceMode(
filePath: string,
offset: number,
limit: number,
): Promise<string[]> {
const content = await fs.readFile(filePath, "utf8");
const allLines = content.split(/\r?\n/);
const collected: string[] = [];
for (
let i = offset - 1;
i < allLines.length && collected.length < limit;
i++
) {
const line = allLines[i];
if (line === undefined) break;
const formatted = formatLine(line);
collected.push(`L${i + 1}: ${formatted}`);
}
if (offset > allLines.length) {
throw new Error("offset exceeds file length");
}
return collected;
}
/**
* Indentation mode: expand around an anchor line based on indentation levels.
*/
async function readIndentationMode(
filePath: string,
offset: number,
limit: number,
options: IndentationOptions,
): Promise<string[]> {
const anchorLine = options.anchor_line ?? offset;
const maxLevels = options.max_levels ?? 0;
const includeSiblings = options.include_siblings ?? false;
const includeHeader = options.include_header ?? true;
const maxLines = options.max_lines ?? limit;
if (anchorLine < 1) {
throw new Error("anchor_line must be a 1-indexed line number");
}
if (maxLines < 1) {
throw new Error("max_lines must be greater than zero");
}
// Read and parse all lines
const content = await fs.readFile(filePath, "utf8");
const rawLines = content.split(/\r?\n/);
if (rawLines.length === 0 || anchorLine > rawLines.length) {
throw new Error("anchor_line exceeds file length");
}
// Build line records
const records: LineRecord[] = rawLines.map((raw, idx) => ({
number: idx + 1,
raw,
display: formatLine(raw),
indent: measureIndent(raw),
}));
// Compute effective indents (blank lines inherit previous indent)
const effectiveIndents = computeEffectiveIndents(records);
const anchorIndex = anchorLine - 1;
const anchorRecord = records[anchorIndex];
const anchorIndent = effectiveIndents[anchorIndex] ?? 0;
if (!anchorRecord) {
throw new Error("anchor_line exceeds file length");
}
// Calculate minimum indent to include
const minIndent =
maxLevels === 0 ? 0 : Math.max(0, anchorIndent - maxLevels * TAB_WIDTH);
// Cap by limits
const finalLimit = Math.min(limit, maxLines, records.length);
if (finalLimit === 1) {
return [`L${anchorRecord.number}: ${anchorRecord.display}`];
}
// Expand from anchor line
const out: LineRecord[] = [anchorRecord];
let i = anchorIndex - 1; // up cursor
let j = anchorIndex + 1; // down cursor
let iCounterMinIndent = 0;
let jCounterMinIndent = 0;
while (out.length < finalLimit) {
let progressed = 0;
// Expand up
if (i >= 0) {
const iIndent = effectiveIndents[i];
const iRecord = records[i];
if (iIndent !== undefined && iRecord && iIndent >= minIndent) {
out.unshift(iRecord);
progressed++;
// Handle sibling exclusion
if (iIndent === minIndent && !includeSiblings) {
const allowHeaderComment = includeHeader && isComment(iRecord);
const canTakeLine = allowHeaderComment || iCounterMinIndent === 0;
if (canTakeLine) {
iCounterMinIndent++;
} else {
// Remove the line we just added
out.shift();
progressed--;
i = -1; // Stop moving up
}
}
i--;
if (out.length >= finalLimit) break;
} else {
i = -1; // Stop moving up
}
}
// Expand down
if (j < records.length) {
const jIndent = effectiveIndents[j];
const jRecord = records[j];
if (jIndent !== undefined && jRecord && jIndent >= minIndent) {
out.push(jRecord);
progressed++;
// Handle sibling exclusion
if (jIndent === minIndent && !includeSiblings) {
if (jCounterMinIndent > 0) {
// Remove the line we just added
out.pop();
progressed--;
j = records.length; // Stop moving down
}
jCounterMinIndent++;
}
j++;
} else {
j = records.length; // Stop moving down
}
}
if (progressed === 0) break;
}
// Trim empty lines at start and end
while (out.length > 0 && out[0]?.raw.trim() === "") {
out.shift();
}
while (out.length > 0 && out[out.length - 1]?.raw.trim() === "") {
out.pop();
}
return out.map((record) => `L${record.number}: ${record.display}`);
}
/**
* Compute effective indents - blank lines inherit previous line's indent.
*/
function computeEffectiveIndents(records: LineRecord[]): number[] {
const effective: number[] = [];
let previousIndent = 0;
for (const record of records) {
if (record.raw.trim() === "") {
effective.push(previousIndent);
} else {
previousIndent = record.indent;
effective.push(previousIndent);
}
}
return effective;
}
/**
* Measure indentation of a line (tabs = TAB_WIDTH spaces).
*/
function measureIndent(line: string): number {
let indent = 0;
for (const char of line) {
if (char === " ") {
indent++;
} else if (char === "\t") {
indent += TAB_WIDTH;
} else {
break;
}
}
return indent;
}
/**
* Check if a line is a comment.
*/
function isComment(record: LineRecord): boolean {
const trimmed = record.raw.trim();
return COMMENT_PREFIXES.some((prefix) => trimmed.startsWith(prefix));
}
/**
* Format a line for display (truncate if too long).
*/
function formatLine(line: string): string {
if (line.length > MAX_LINE_LENGTH) {
return line.substring(0, MAX_LINE_LENGTH);
}
return line;
}

View File

@@ -1,4 +1,5 @@
import { bash } from "./Bash.js";
import { spawn } from "node:child_process";
import * as path from "node:path";
import { validateRequiredParams } from "./validation.js";
interface ShellArgs {
@@ -15,58 +16,92 @@ interface ShellResult {
stderr: string[];
}
const DEFAULT_TIMEOUT = 120000;
/**
* Codex-style shell tool.
* Runs an array of shell arguments, typically ["bash", "-lc", "..."].
* Runs an array of shell arguments using execvp-style semantics.
* Typically called with ["bash", "-lc", "..."] for shell commands.
*/
export async function shell(args: ShellArgs): Promise<ShellResult> {
validateRequiredParams(args, ["command"], "shell");
const { command, workdir, timeout_ms, justification: description } = args;
const { command, workdir, timeout_ms } = args;
if (!Array.isArray(command) || command.length === 0) {
throw new Error("command must be a non-empty array of strings");
}
const commandString = command.join(" ");
const previousUserCwd = process.env.USER_CWD;
if (workdir) {
process.env.USER_CWD = workdir;
const [executable, ...execArgs] = command;
if (!executable) {
throw new Error("command must be a non-empty array of strings");
}
const timeout = timeout_ms ?? DEFAULT_TIMEOUT;
try {
const result = await bash({
command: commandString,
timeout: timeout_ms ?? 120000,
description,
run_in_background: false,
// Determine working directory
const cwd = workdir
? path.isAbsolute(workdir)
? workdir
: path.resolve(process.env.USER_CWD || process.cwd(), workdir)
: process.env.USER_CWD || process.cwd();
return new Promise((resolve, reject) => {
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
const child = spawn(executable, execArgs, {
cwd,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
const text = (result.content ?? [])
.map((item) =>
"text" in item && typeof item.text === "string" ? item.text : "",
)
.filter(Boolean)
.join("\n");
const timeoutId = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error(`Command timed out after ${timeout}ms`));
}, timeout);
const stdout = text ? text.split("\n") : [];
const stderr =
result.status === "error"
? ["Command reported an error. See output for details."]
: [];
child.stdout.on("data", (chunk: Buffer) => {
stdoutChunks.push(chunk);
});
return {
output: text,
stdout,
stderr,
};
} finally {
if (workdir) {
if (previousUserCwd === undefined) {
delete process.env.USER_CWD;
child.stderr.on("data", (chunk: Buffer) => {
stderrChunks.push(chunk);
});
child.on("error", (err: Error) => {
clearTimeout(timeoutId);
reject(new Error(`Failed to execute command: ${err.message}`));
});
child.on("close", (code: number | null) => {
clearTimeout(timeoutId);
const stdoutText = Buffer.concat(stdoutChunks).toString("utf8");
const stderrText = Buffer.concat(stderrChunks).toString("utf8");
const stdoutLines = stdoutText
.split("\n")
.filter((line) => line.length > 0);
const stderrLines = stderrText
.split("\n")
.filter((line) => line.length > 0);
// Combine stdout and stderr for output
const output = [stdoutText, stderrText].filter(Boolean).join("\n").trim();
if (code !== 0 && code !== null) {
// Command failed but we still return the output
resolve({
output: output || `Command exited with code ${code}`,
stdout: stdoutLines,
stderr: stderrLines,
});
} else {
process.env.USER_CWD = previousUserCwd;
resolve({
output,
stdout: stdoutLines,
stderr: stderrLines,
});
}
}
}
});
});
}