chore(vendor): import pi 0.85.1

Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
2026-09-14 10:48:47 -04:00
parent 5ebc7018c3
commit 4def357d09
1669 changed files with 397627 additions and 0 deletions

View File

@@ -0,0 +1,145 @@
import { type Static, Type } from "typebox";
import type { Context } from "../context.ts";
import type { AgentHarnessTool, ShellOutputTruncation, ShellOutputView } from "../types.ts";
import { applyShellOutputUpdate } from "../utils/output-capture.ts";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../utils/truncate.ts";
import type { ExecutionToolContext } from "./tool-context.ts";
const MAX_TIMEOUT_SECONDS = 2_147_483_647 / 1000;
const BASH_CHECKPOINT_INTERVAL_MS = 2_000;
const bashSchema = Type.Object({
command: Type.String({ description: "Bash command to execute" }),
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
});
export type BashToolInput = Static<typeof bashSchema>;
export interface BashToolDetails {
truncation?: ShellOutputTruncation;
fullOutputPath?: string;
}
export interface BashExecution {
command: string;
cwd: string;
env: Record<string, string>;
inheritEnv: boolean;
}
export type BashPrepare<TContext extends ExecutionToolContext = ExecutionToolContext> = (
execution: BashExecution,
toolContext: TContext,
context: Context,
) => void | Promise<void>;
export interface BashToolOptions<TContext extends ExecutionToolContext = ExecutionToolContext> {
commandPrefix?: string;
prepare?: BashPrepare<TContext>;
}
function validateTimeout(timeout: number | undefined): void {
if (timeout === undefined) return;
if (!Number.isFinite(timeout) || timeout <= 0) {
throw new Error("Invalid timeout: must be a finite number of seconds");
}
if (timeout > MAX_TIMEOUT_SECONDS) {
throw new Error(`Invalid timeout: maximum is ${MAX_TIMEOUT_SECONDS} seconds`);
}
}
export function createBashTool<TContext extends ExecutionToolContext = ExecutionToolContext>(
options?: BashToolOptions<TContext>,
): AgentHarnessTool<TContext, typeof bashSchema, BashToolDetails | undefined> {
return {
name: "bash",
label: "bash",
description: `Execute a bash command in the current working directory. Returns combined 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.`,
parameters: bashSchema,
async execute(_toolCallId, { command, timeout }, onUpdate, toolContext, _invocation, context) {
validateTimeout(timeout);
const { env } = toolContext;
const execution: BashExecution = {
command: options?.commandPrefix ? `${options.commandPrefix}\n${command}` : command,
cwd: env.cwd,
env: {},
inheritEnv: true,
};
await options?.prepare?.(execution, toolContext, context);
let view: ShellOutputView | undefined;
let lastCheckpointAt = Date.now();
let lastCheckpoint: string | undefined;
let acceptingUpdates = true;
onUpdate({ content: [], details: undefined });
const result = await env.exec(
execution.command,
{
cwd: execution.cwd,
env: execution.env,
inheritEnv: execution.inheritEnv,
timeout,
capture: {
limits: { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES, retain: "tail" },
spill: true,
},
onUpdate: (update) => {
if (!acceptingUpdates) return;
view = applyShellOutputUpdate(view, update);
const snapshot = {
content: [{ type: "text" as const, text: view.text }],
details: {
truncation: view.truncation.truncated ? view.truncation : undefined,
fullOutputPath: view.spillPath,
},
};
const now = Date.now();
const encoded = JSON.stringify(snapshot);
const checkpoint =
now - lastCheckpointAt >= BASH_CHECKPOINT_INTERVAL_MS && encoded !== lastCheckpoint;
onUpdate(snapshot, checkpoint ? { checkpoint: true } : undefined);
if (checkpoint) {
lastCheckpointAt = now;
lastCheckpoint = encoded;
}
},
},
context,
);
acceptingUpdates = false;
let outputText = view?.text ?? "";
const capture = result.ok ? { text: outputText, ...result.value } : view;
let details: BashToolDetails | undefined;
if (capture?.truncation.truncated) {
details = { truncation: capture.truncation, fullOutputPath: capture.spillPath };
const startLine = capture.truncation.totalLines - capture.truncation.outputLines + 1;
const endLine = capture.truncation.totalLines;
if (capture.truncation.lastLinePartial) {
const lastLineSize = formatSize(capture.lastLineBytes ?? capture.truncation.outputBytes);
outputText += `\n\n[Showing last ${formatSize(capture.truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${capture.spillPath}]`;
} else if (capture.truncation.truncatedBy === "lines") {
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${capture.truncation.totalLines}. Full output: ${capture.spillPath}]`;
} else {
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${capture.truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${capture.spillPath}]`;
}
}
if (!result.ok) {
const status =
result.error.code === "timeout"
? `Command timed out after ${timeout} seconds`
: result.error.code === "aborted"
? "Command aborted"
: result.error.message;
throw new Error(outputText ? `${outputText}\n\n${status}` : status, { cause: result.error });
}
if (result.value.exitCode !== 0) {
throw new Error(
`${outputText ? `${outputText}\n\n` : ""}Command exited with code ${result.value.exitCode}`,
);
}
return { content: [{ type: "text", text: outputText || "(no output)" }], details };
},
};
}

View File

@@ -0,0 +1,500 @@
/**
* Shared diff computation utilities for the edit and similar tools.
*/
import * as Diff from "diff";
export function detectLineEnding(content: string): "\r\n" | "\n" {
const crlfIdx = content.indexOf("\r\n");
const lfIdx = content.indexOf("\n");
if (lfIdx === -1) return "\n";
if (crlfIdx === -1) return "\n";
return crlfIdx < lfIdx ? "\r\n" : "\n";
}
export function normalizeToLF(text: string): string {
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
export function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string {
return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
}
/**
* Normalize text for fuzzy matching. Applies progressive transformations:
* - Strip trailing whitespace from each line
* - Normalize smart quotes to ASCII equivalents
* - Normalize Unicode dashes/hyphens to ASCII hyphen
* - Normalize special Unicode spaces to regular space
*/
export function normalizeForFuzzyMatch(text: string): string {
return (
text
.normalize("NFKC")
// Strip trailing whitespace per line
.split("\n")
.map((line) => line.trimEnd())
.join("\n")
// Smart single quotes → '
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
// Smart double quotes → "
.replace(/[\u201C\u201D\u201E\u201F]/g, '"')
// Various dashes/hyphens → -
// U+2010 hyphen, U+2011 non-breaking hyphen, U+2012 figure dash,
// U+2013 en-dash, U+2014 em-dash, U+2015 horizontal bar, U+2212 minus
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
// Special spaces → regular space
// U+00A0 NBSP, U+2002-U+200A various spaces, U+202F narrow NBSP,
// U+205F medium math space, U+3000 ideographic space
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ")
);
}
function splitLinesWithEndings(content: string): string[] {
return content.match(/[^\n]*\n|[^\n]+/g) ?? [];
}
interface LineSpan {
start: number;
end: number;
}
interface MatchedEdit {
editIndex: number;
matchIndex: number;
matchLength: number;
newText: string;
}
type TextReplacement = Pick<MatchedEdit, "matchIndex" | "matchLength" | "newText">;
function getLineSpans(content: string): LineSpan[] {
let offset = 0;
return splitLinesWithEndings(content).map((line) => {
const span = { start: offset, end: offset + line.length };
offset = span.end;
return span;
});
}
function getReplacementLineRange(lines: LineSpan[], replacement: TextReplacement) {
const replacementStart = replacement.matchIndex;
const replacementEnd = replacement.matchIndex + replacement.matchLength;
let startLine = -1;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (replacementStart >= line.start && replacementStart < line.end) {
startLine = i;
break;
}
}
if (startLine === -1) {
throw new Error("Replacement range is outside the base content.");
}
let endLine = startLine;
while (endLine < lines.length && lines[endLine].end < replacementEnd) {
endLine++;
}
if (endLine >= lines.length) {
throw new Error("Replacement range is outside the base content.");
}
return { startLine, endLine: endLine + 1 };
}
function applyReplacements(content: string, replacements: TextReplacement[], offset = 0): string {
let result = content;
for (let i = replacements.length - 1; i >= 0; i--) {
const replacement = replacements[i];
const matchIndex = replacement.matchIndex - offset;
result =
result.substring(0, matchIndex) + replacement.newText + result.substring(matchIndex + replacement.matchLength);
}
return result;
}
/**
* Apply replacements matched against `baseContent` to `originalContent` while
* preserving unchanged line blocks from the original.
*
* This is useful when `baseContent` is a normalized view of the original. Each
* replacement is widened to the lines it actually touches, those touched lines
* are rewritten from the normalized base, and all other lines are copied back
* from `originalContent`. The actual replacement ranges drive preservation so
* duplicate normalized lines cannot be aligned to the wrong occurrence.
*/
export function applyReplacementsPreservingUnchangedLines(
originalContent: string,
baseContent: string,
replacements: TextReplacement[],
): string {
const originalLines = splitLinesWithEndings(originalContent);
const baseLines = getLineSpans(baseContent);
if (originalLines.length !== baseLines.length) {
throw new Error("Cannot preserve unchanged lines because the base content has a different line count.");
}
const groups: Array<{ startLine: number; endLine: number; replacements: TextReplacement[] }> = [];
const sortedReplacements = [...replacements].sort((a, b) => a.matchIndex - b.matchIndex);
for (const replacement of sortedReplacements) {
const range = getReplacementLineRange(baseLines, replacement);
const current = groups[groups.length - 1];
if (current && range.startLine < current.endLine) {
current.endLine = Math.max(current.endLine, range.endLine);
current.replacements.push(replacement);
continue;
}
groups.push({ ...range, replacements: [replacement] });
}
let originalLineIndex = 0;
let result = "";
for (const group of groups) {
result += originalLines.slice(originalLineIndex, group.startLine).join("");
const groupStartOffset = baseLines[group.startLine].start;
const groupEndOffset = baseLines[group.endLine - 1].end;
result += applyReplacements(
baseContent.slice(groupStartOffset, groupEndOffset),
group.replacements,
groupStartOffset,
);
originalLineIndex = group.endLine;
}
result += originalLines.slice(originalLineIndex).join("");
return result;
}
export interface FuzzyMatchResult {
/** Whether a match was found */
found: boolean;
/** The index where the match starts (in the content that should be used for replacement) */
index: number;
/** Length of the matched text */
matchLength: number;
/** Whether fuzzy matching was used (false = exact match) */
usedFuzzyMatch: boolean;
/**
* The content to use for replacement operations.
* When exact match: original content. When fuzzy match: normalized content.
*/
contentForReplacement: string;
}
export interface Edit {
oldText: string;
newText: string;
}
export interface AppliedEditsResult {
baseContent: string;
newContent: string;
}
/**
* Find oldText in content, trying exact match first, then fuzzy match.
* When fuzzy matching is used, the returned contentForReplacement is the
* fuzzy-normalized version of the content (trailing whitespace stripped,
* Unicode quotes/dashes normalized to ASCII).
*/
export function fuzzyFindText(content: string, oldText: string): FuzzyMatchResult {
// Try exact match first
const exactIndex = content.indexOf(oldText);
if (exactIndex !== -1) {
return {
found: true,
index: exactIndex,
matchLength: oldText.length,
usedFuzzyMatch: false,
contentForReplacement: content,
};
}
// Try fuzzy match - work entirely in normalized space
const fuzzyContent = normalizeForFuzzyMatch(content);
const fuzzyOldText = normalizeForFuzzyMatch(oldText);
const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText);
if (fuzzyIndex === -1) {
return {
found: false,
index: -1,
matchLength: 0,
usedFuzzyMatch: false,
contentForReplacement: content,
};
}
// When fuzzy matching, return offsets in normalized space. Callers can use
// the normalized content to compute replacements, then decide how much of
// that normalized output should be written back.
return {
found: true,
index: fuzzyIndex,
matchLength: fuzzyOldText.length,
usedFuzzyMatch: true,
contentForReplacement: fuzzyContent,
};
}
/** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */
export function stripBom(content: string): { bom: string; text: string } {
return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
}
function countOccurrences(content: string, oldText: string): number {
const fuzzyContent = normalizeForFuzzyMatch(content);
const fuzzyOldText = normalizeForFuzzyMatch(oldText);
return fuzzyContent.split(fuzzyOldText).length - 1;
}
function getNotFoundError(path: string, editIndex: number, totalEdits: number): Error {
if (totalEdits === 1) {
return new Error(
`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,
);
}
return new Error(
`Could not find edits[${editIndex}] in ${path}. The oldText must match exactly including all whitespace and newlines.`,
);
}
function getDuplicateError(path: string, editIndex: number, totalEdits: number, occurrences: number): Error {
if (totalEdits === 1) {
return new Error(
`Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
);
}
return new Error(
`Found ${occurrences} occurrences of edits[${editIndex}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`,
);
}
function getEmptyOldTextError(path: string, editIndex: number, totalEdits: number): Error {
if (totalEdits === 1) {
return new Error(`oldText must not be empty in ${path}.`);
}
return new Error(`edits[${editIndex}].oldText must not be empty in ${path}.`);
}
function getNoChangeError(path: string, totalEdits: number): Error {
if (totalEdits === 1) {
return 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 new Error(`No changes made to ${path}. The replacements produced identical content.`);
}
/**
* Apply one or more exact-text replacements to LF-normalized content.
*
* All edits are matched against the same original content. Replacements are
* then applied in reverse order so offsets remain stable. If any edit needs
* fuzzy matching, the operation runs in fuzzy-normalized content space and then
* overlays those line-level changes onto the original content so unchanged line
* blocks keep their original bytes.
*/
export function applyEditsToNormalizedContent(
normalizedContent: string,
edits: Edit[],
path: string,
): AppliedEditsResult {
const normalizedEdits = edits.map((edit) => ({
oldText: normalizeToLF(edit.oldText),
newText: normalizeToLF(edit.newText),
}));
for (let i = 0; i < normalizedEdits.length; i++) {
if (normalizedEdits[i].oldText.length === 0) {
throw getEmptyOldTextError(path, i, normalizedEdits.length);
}
}
const initialMatches = normalizedEdits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText));
const usedFuzzyMatch = initialMatches.some((match) => match.usedFuzzyMatch);
const replacementBaseContent = usedFuzzyMatch ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent;
const matchedEdits: MatchedEdit[] = [];
for (let i = 0; i < normalizedEdits.length; i++) {
const edit = normalizedEdits[i];
const matchResult = fuzzyFindText(replacementBaseContent, edit.oldText);
if (!matchResult.found) {
throw getNotFoundError(path, i, normalizedEdits.length);
}
const occurrences = countOccurrences(replacementBaseContent, edit.oldText);
if (occurrences > 1) {
throw getDuplicateError(path, i, normalizedEdits.length, occurrences);
}
matchedEdits.push({
editIndex: i,
matchIndex: matchResult.index,
matchLength: matchResult.matchLength,
newText: edit.newText,
});
}
matchedEdits.sort((a, b) => a.matchIndex - b.matchIndex);
for (let i = 1; i < matchedEdits.length; i++) {
const previous = matchedEdits[i - 1];
const current = matchedEdits[i];
if (previous.matchIndex + previous.matchLength > current.matchIndex) {
throw new Error(
`edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`,
);
}
}
const baseContent = normalizedContent;
const newContent = usedFuzzyMatch
? applyReplacementsPreservingUnchangedLines(normalizedContent, replacementBaseContent, matchedEdits)
: applyReplacements(replacementBaseContent, matchedEdits);
if (baseContent === newContent) {
throw getNoChangeError(path, normalizedEdits.length);
}
return { baseContent, newContent };
}
/** Generate a standard unified patch. */
export function generateUnifiedPatch(path: string, oldContent: string, newContent: string, contextLines = 4): string {
return Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, {
context: contextLines,
headerOptions: Diff.FILE_HEADERS_ONLY,
});
}
/**
* Generate a display-oriented diff string with line numbers and context.
* Returns both the diff string and the first changed line number (in the new file).
*/
export function generateDiffString(
oldContent: string,
newContent: string,
contextLines = 4,
): { diff: string; firstChangedLine: number | undefined } {
const parts = Diff.diffLines(oldContent, newContent);
const output: string[] = [];
const oldLines = oldContent.split("\n");
const newLines = newContent.split("\n");
const maxLineNum = Math.max(oldLines.length, newLines.length);
const lineNumWidth = String(maxLineNum).length;
let oldLineNum = 1;
let newLineNum = 1;
let lastWasChange = false;
let firstChangedLine: number | undefined;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const raw = part.value.split("\n");
if (raw[raw.length - 1] === "") {
raw.pop();
}
if (part.added || part.removed) {
// Capture the first changed line (in the new file)
if (firstChangedLine === undefined) {
firstChangedLine = newLineNum;
}
// Show the change
for (const line of raw) {
if (part.added) {
const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
output.push(`+${lineNum} ${line}`);
newLineNum++;
} else {
// removed
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
output.push(`-${lineNum} ${line}`);
oldLineNum++;
}
}
lastWasChange = true;
} else {
// Context lines - only show a few before/after changes
const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
const hasLeadingChange = lastWasChange;
const hasTrailingChange = nextPartIsChange;
if (hasLeadingChange && hasTrailingChange) {
if (raw.length <= contextLines * 2) {
for (const line of raw) {
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
output.push(` ${lineNum} ${line}`);
oldLineNum++;
newLineNum++;
}
} else {
const leadingLines = raw.slice(0, contextLines);
const trailingLines = raw.slice(raw.length - contextLines);
const skippedLines = raw.length - leadingLines.length - trailingLines.length;
for (const line of leadingLines) {
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
output.push(` ${lineNum} ${line}`);
oldLineNum++;
newLineNum++;
}
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
oldLineNum += skippedLines;
newLineNum += skippedLines;
for (const line of trailingLines) {
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
output.push(` ${lineNum} ${line}`);
oldLineNum++;
newLineNum++;
}
}
} else if (hasLeadingChange) {
const shownLines = raw.slice(0, contextLines);
const skippedLines = raw.length - shownLines.length;
for (const line of shownLines) {
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
output.push(` ${lineNum} ${line}`);
oldLineNum++;
newLineNum++;
}
if (skippedLines > 0) {
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
oldLineNum += skippedLines;
newLineNum += skippedLines;
}
} else if (hasTrailingChange) {
const skippedLines = Math.max(0, raw.length - contextLines);
if (skippedLines > 0) {
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
oldLineNum += skippedLines;
newLineNum += skippedLines;
}
for (const line of raw.slice(skippedLines)) {
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
output.push(` ${lineNum} ${line}`);
oldLineNum++;
newLineNum++;
}
} else {
// Skip these context lines entirely
oldLineNum += raw.length;
newLineNum += raw.length;
}
lastWasChange = false;
}
}
return { diff: output.join("\n"), firstChangedLine };
}

View File

@@ -0,0 +1,145 @@
import { type Static, Type } from "typebox";
import type { AgentHarnessTool, FileError } from "../types.ts";
import {
applyEditsToNormalizedContent,
detectLineEnding,
type Edit,
generateDiffString,
generateUnifiedPatch,
normalizeToLF,
restoreLineEndings,
stripBom,
} from "./edit-diff.ts";
import { withFileMutationQueue } from "./file-mutation-queue.ts";
import { resolveToolPath } from "./path-utils.ts";
import type { ExecutionToolContext } from "./tool-context.ts";
const replaceEditSchema = Type.Object(
{
oldText: Type.String({
description:
"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.",
}),
newText: Type.String({ description: "Replacement text for this targeted edit." }),
},
{},
);
const editSchema = Type.Object(
{
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
edits: Type.Array(replaceEditSchema, {
description:
"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.",
}),
},
{},
);
export type EditToolInput = Static<typeof editSchema>;
type LegacyEditToolInput = EditToolInput & { oldText?: unknown; newText?: unknown };
type SingleEditInput = { oldText: string; newText: string };
function isSingleEditInput(value: unknown): value is SingleEditInput {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const edit = value as Record<string, unknown>;
return typeof edit.oldText === "string" && typeof edit.newText === "string";
}
export interface EditToolDetails {
diff: string;
patch: string;
firstChangedLine?: number;
}
function prepareEditArguments(input: unknown): EditToolInput {
if (!input || typeof input !== "object") return input as EditToolInput;
const args = input as Record<string, unknown>;
if (typeof args.edits === "string") {
try {
const parsed: unknown = JSON.parse(args.edits);
if (Array.isArray(parsed)) {
args.edits = parsed;
} else if (isSingleEditInput(parsed)) {
args.edits = [parsed];
}
} catch {}
} else if (isSingleEditInput(args.edits)) {
args.edits = [args.edits];
}
const legacy = args as LegacyEditToolInput;
if (typeof legacy.oldText !== "string" || typeof legacy.newText !== "string") return args as EditToolInput;
const edits = Array.isArray(legacy.edits) ? [...legacy.edits] : [];
edits.push({ oldText: legacy.oldText, newText: legacy.newText });
const { oldText: _oldText, newText: _newText, ...rest } = legacy;
return { ...rest, edits } as EditToolInput;
}
function validateEditInput(input: EditToolInput): { path: string; edits: Edit[] } {
if (!Array.isArray(input.edits) || input.edits.length === 0) {
throw new Error("Edit tool input is invalid. edits must contain at least one replacement.");
}
return { path: input.path, edits: input.edits };
}
function editAccessError(path: string, error: FileError): Error {
return new Error(`Could not edit file: ${path}. Error code: ${error.code}.`, { cause: error });
}
export function createEditTool<TContext extends ExecutionToolContext = ExecutionToolContext>(): AgentHarnessTool<
TContext,
typeof editSchema,
EditToolDetails | undefined
> {
return {
name: "edit",
label: "edit",
description:
"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.",
parameters: editSchema,
prepareArguments: prepareEditArguments,
async execute(_toolCallId, input, _onUpdate, { env }, _invocation, context) {
const { path, edits } = validateEditInput(input);
const absolutePath = await resolveToolPath(env, path, context);
return withFileMutationQueue(
env,
absolutePath,
async () => {
if (context.abortSignal?.aborted) throw new Error("Operation aborted");
const info = await env.fileInfo(absolutePath, context);
if (!info.ok) throw editAccessError(path, info.error);
if (info.value.kind !== "file" && info.value.kind !== "symlink") {
throw new Error(`Could not edit file: ${path}. Path is not a file.`);
}
const readResult = await env.readTextFile(absolutePath, context);
if (!readResult.ok) throw editAccessError(path, readResult.error);
if (context.abortSignal?.aborted) throw new Error("Operation aborted");
const { bom, text: content } = stripBom(readResult.value);
const originalEnding = detectLineEnding(content);
const normalizedContent = normalizeToLF(content);
const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);
if (context.abortSignal?.aborted) throw new Error("Operation aborted");
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
const writeResult = await env.writeFile(absolutePath, finalContent, context);
if (!writeResult.ok) throw editAccessError(path, writeResult.error);
if (context.abortSignal?.aborted) throw new Error("Operation aborted");
const diffResult = generateDiffString(baseContent, newContent);
return {
content: [{ type: "text", text: `Successfully replaced ${edits.length} block(s) in ${path}.` }],
details: {
diff: diffResult.diff,
patch: generateUnifiedPatch(path, baseContent, newContent),
firstChangedLine: diffResult.firstChangedLine,
},
};
},
context,
);
},
};
}

View File

@@ -0,0 +1,62 @@
import type { Context } from "../context.ts";
import type { ExecutionEnv } from "../types.ts";
import { getOrThrow } from "../types.ts";
type MutationQueueState = {
queues: Map<string, Promise<void>>;
registration: Promise<void>;
};
const states = new WeakMap<ExecutionEnv, MutationQueueState>();
function getState(env: ExecutionEnv): MutationQueueState {
let state = states.get(env);
if (!state) {
state = { queues: new Map(), registration: Promise.resolve() };
states.set(env, state);
}
return state;
}
async function getMutationQueueKey(env: ExecutionEnv, path: string, context: Context): Promise<string> {
const absolutePath = getOrThrow(await env.absolutePath(path, context));
const canonicalPath = await env.canonicalPath(absolutePath, context);
if (canonicalPath.ok) return canonicalPath.value;
if (canonicalPath.error.code === "not_found" || canonicalPath.error.code === "not_supported") return absolutePath;
throw canonicalPath.error;
}
/** Serialize file mutations targeting the same environment and canonical path. */
export async function withFileMutationQueue<T>(
env: ExecutionEnv,
path: string,
fn: () => Promise<T>,
context: Context,
): Promise<T> {
const state = getState(env);
const registration = state.registration.then(async () => {
const key = await getMutationQueueKey(env, path, context);
const currentQueue = state.queues.get(key) ?? Promise.resolve();
let releaseNext = () => {};
const nextQueue = new Promise<void>((resolve) => {
releaseNext = resolve;
});
const chainedQueue = currentQueue.then(() => nextQueue);
state.queues.set(key, chainedQueue);
return { key, currentQueue, chainedQueue, releaseNext };
});
state.registration = registration.then(
() => undefined,
() => undefined,
);
const { key, currentQueue, chainedQueue, releaseNext } = await registration;
await currentQueue;
try {
return await fn();
} finally {
releaseNext();
if (state.queues.get(key) === chainedQueue) state.queues.delete(key);
}
}

View File

@@ -0,0 +1,104 @@
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
export function detectSupportedImageMimeType(buffer: Uint8Array): string | undefined {
if (startsWith(buffer, [0xff, 0xd8, 0xff])) return buffer[3] === 0xf7 ? undefined : "image/jpeg";
if (startsWith(buffer, PNG_SIGNATURE)) return isPng(buffer) && !isAnimatedPng(buffer) ? "image/png" : undefined;
if (startsWithAscii(buffer, 0, "GIF")) return "image/gif";
if (startsWithAscii(buffer, 0, "RIFF") && startsWithAscii(buffer, 8, "WEBP")) return "image/webp";
if (startsWithAscii(buffer, 0, "BM") && isBmp(buffer)) return "image/bmp";
return undefined;
}
export function encodeBase64(bytes: Uint8Array): string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let output = "";
for (let index = 0; index < bytes.length; index += 3) {
const first = bytes[index] ?? 0;
const second = bytes[index + 1];
const third = bytes[index + 2];
output += alphabet[first >> 2];
output += alphabet[((first & 0x03) << 4) | ((second ?? 0) >> 4)];
output += second === undefined ? "=" : alphabet[((second & 0x0f) << 2) | ((third ?? 0) >> 6)];
output += third === undefined ? "=" : alphabet[third & 0x3f];
}
return output;
}
function isPng(buffer: Uint8Array): boolean {
return (
buffer.length >= 16 && readUint32BE(buffer, PNG_SIGNATURE.length) === 13 && startsWithAscii(buffer, 12, "IHDR")
);
}
function isAnimatedPng(buffer: Uint8Array): boolean {
let offset = PNG_SIGNATURE.length;
while (offset + 8 <= buffer.length) {
const chunkLength = readUint32BE(buffer, offset);
const chunkTypeOffset = offset + 4;
if (startsWithAscii(buffer, chunkTypeOffset, "acTL")) return true;
if (startsWithAscii(buffer, chunkTypeOffset, "IDAT")) return false;
const nextOffset = offset + 8 + chunkLength + 4;
if (nextOffset <= offset || nextOffset > buffer.length) return false;
offset = nextOffset;
}
return false;
}
function isBmp(buffer: Uint8Array): boolean {
if (buffer.length < 26) return false;
const declaredFileSize = readUint32LE(buffer, 2);
const pixelDataOffset = readUint32LE(buffer, 10);
const dibHeaderSize = readUint32LE(buffer, 14);
if (declaredFileSize !== 0 && declaredFileSize < 26) return false;
if (pixelDataOffset < 14 + dibHeaderSize) return false;
if (declaredFileSize !== 0 && pixelDataOffset >= declaredFileSize) return false;
let colorPlanes: number;
let bitsPerPixel: number;
if (dibHeaderSize === 12) {
colorPlanes = readUint16LE(buffer, 22);
bitsPerPixel = readUint16LE(buffer, 24);
} else if (dibHeaderSize >= 40 && dibHeaderSize <= 124) {
if (buffer.length < 30) return false;
colorPlanes = readUint16LE(buffer, 26);
bitsPerPixel = readUint16LE(buffer, 28);
} else {
return false;
}
return colorPlanes === 1 && [1, 4, 8, 16, 24, 32].includes(bitsPerPixel);
}
function readUint16LE(buffer: Uint8Array, offset: number): number {
return (buffer[offset] ?? 0) + ((buffer[offset + 1] ?? 0) << 8);
}
function readUint32BE(buffer: Uint8Array, offset: number): number {
return (
(buffer[offset] ?? 0) * 0x1000000 +
((buffer[offset + 1] ?? 0) << 16) +
((buffer[offset + 2] ?? 0) << 8) +
(buffer[offset + 3] ?? 0)
);
}
function readUint32LE(buffer: Uint8Array, offset: number): number {
return (
(buffer[offset] ?? 0) +
((buffer[offset + 1] ?? 0) << 8) +
((buffer[offset + 2] ?? 0) << 16) +
(buffer[offset + 3] ?? 0) * 0x1000000
);
}
function startsWith(buffer: Uint8Array, bytes: number[]): boolean {
if (buffer.length < bytes.length) return false;
return bytes.every((byte, index) => buffer[index] === byte);
}
function startsWithAscii(buffer: Uint8Array, offset: number, text: string): boolean {
if (buffer.length < offset + text.length) return false;
for (let index = 0; index < text.length; index++) {
if (buffer[offset + index] !== text.charCodeAt(index)) return false;
}
return true;
}

View File

@@ -0,0 +1,23 @@
export {
type BashExecution,
type BashPrepare,
type BashToolDetails,
type BashToolInput,
type BashToolOptions,
createBashTool,
} from "./bash.ts";
export {
createEditTool,
type EditToolDetails,
type EditToolInput,
} from "./edit.ts";
export {
createReadTool,
type ReadImageProcessor,
type ReadImageProcessorResult,
type ReadToolDetails,
type ReadToolInput,
type ReadToolOptions,
} from "./read.ts";
export type { ExecutionToolContext } from "./tool-context.ts";
export { createWriteTool, type WriteToolInput } from "./write.ts";

View File

@@ -0,0 +1,31 @@
import type { Context } from "../context.ts";
import type { ExecutionEnv } from "../types.ts";
import { getOrThrow } from "../types.ts";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
const NARROW_NO_BREAK_SPACE = "\u202F";
function normalizeToolPath(path: string): string {
const normalized = path.replace(UNICODE_SPACES, " ");
return normalized.startsWith("@") ? normalized.slice(1) : normalized;
}
export async function resolveToolPath(env: ExecutionEnv, path: string, context: Context): Promise<string> {
return getOrThrow(await env.absolutePath(normalizeToolPath(path), context));
}
export async function resolveReadToolPath(env: ExecutionEnv, path: string, context: Context): Promise<string> {
const resolved = await resolveToolPath(env, path, context);
const variants = [
resolved,
resolved.replace(/ (AM|PM)\./gi, `${NARROW_NO_BREAK_SPACE}$1.`),
resolved.normalize("NFD"),
resolved.replace(/'/g, "\u2019"),
resolved.normalize("NFD").replace(/'/g, "\u2019"),
];
for (const variant of new Set(variants)) {
if (getOrThrow(await env.exists(variant, context))) return variant;
}
return resolved;
}

View File

@@ -0,0 +1,149 @@
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import { type Static, Type } from "typebox";
import type { Context } from "../context.ts";
import type { AgentHarnessTool } from "../types.ts";
import { getOrThrow } from "../types.ts";
import {
DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES,
formatSize,
type TruncationResult,
truncateHead,
} from "../utils/truncate.ts";
import { detectSupportedImageMimeType, encodeBase64 } from "./image.ts";
import { resolveReadToolPath } from "./path-utils.ts";
import type { ExecutionToolContext } from "./tool-context.ts";
const readSchema = Type.Object({
path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
});
export type ReadToolInput = Static<typeof readSchema>;
export interface ReadToolDetails {
truncation?: TruncationResult;
}
export type ReadImageProcessorResult =
| { ok: true; data: string; mimeType: string; hints: string[] }
| { ok: false; message: string };
export type ReadImageProcessor = (
bytes: Uint8Array,
mimeType: string,
options: { autoResizeImages: boolean },
context: Context,
) => Promise<ReadImageProcessorResult>;
export interface ReadToolOptions {
/** Whether an injected image processor should resize images. Default: true. */
autoResizeImages?: boolean;
/** Optional image conversion/resizing implementation. */
imageProcessor?: ReadImageProcessor;
}
export function createReadTool<TContext extends ExecutionToolContext = ExecutionToolContext>(
options?: ReadToolOptions,
): AgentHarnessTool<TContext, typeof readSchema, ReadToolDetails | undefined> {
return {
name: "read",
label: "read",
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). 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. When you need the full file, continue with offset until complete.`,
parameters: readSchema,
async execute(_toolCallId, { path, offset, limit }, _onUpdate, { env }, _invocation, context) {
const absolutePath = await resolveReadToolPath(env, path, context);
const bytes = getOrThrow(await env.readBinaryFile(absolutePath, context));
const mimeType = detectSupportedImageMimeType(bytes);
if (mimeType) {
if (options?.imageProcessor) {
const processed = await options.imageProcessor(
bytes,
mimeType,
{ autoResizeImages: options.autoResizeImages ?? true },
context,
);
if (!processed.ok) {
return {
content: [{ type: "text", text: `Read image file [${mimeType}]\n${processed.message}` }],
details: undefined,
};
}
const hints = processed.hints.length > 0 ? `\n${processed.hints.join("\n")}` : "";
return {
content: [
{ type: "text", text: `Read image file [${processed.mimeType}]${hints}` },
{ type: "image", data: processed.data, mimeType: processed.mimeType },
] satisfies Array<TextContent | ImageContent>,
details: undefined,
};
}
if (mimeType === "image/bmp") {
return {
content: [
{
type: "text",
text: "Read image file [image/bmp]\n[Image omitted: configure an imageProcessor to convert BMP images.]",
},
],
details: undefined,
};
}
return {
content: [
{ type: "text", text: `Read image file [${mimeType}]` },
{ type: "image", data: encodeBase64(bytes), mimeType },
] satisfies Array<TextContent | ImageContent>,
details: undefined,
};
}
const textContent = new TextDecoder().decode(bytes);
const allLines = textContent.split("\n");
const totalFileLines = allLines.length;
const startLine = offset ? Math.max(0, offset - 1) : 0;
const startLineDisplay = startLine + 1;
if (startLine >= allLines.length) {
throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);
}
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");
}
const truncation = truncateHead(selectedContent);
let outputText: string;
let details: ReadToolDetails | undefined;
if (truncation.firstLineExceedsLimit) {
const firstLineSize = formatSize(new TextEncoder().encode(allLines[startLine]).byteLength);
outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
details = { truncation };
} else if (truncation.truncated) {
const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
const nextOffset = endLineDisplay + 1;
outputText = 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) {
const remaining = allLines.length - (startLine + userLimitedLines);
const nextOffset = startLine + userLimitedLines + 1;
outputText = `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
} else {
outputText = truncation.content;
}
return { content: [{ type: "text", text: outputText }], details };
},
};
}

View File

@@ -0,0 +1,6 @@
import type { ExecutionEnv } from "../types.ts";
/** Filesystem and shell context required by the built-in execution tools. */
export interface ExecutionToolContext {
env: ExecutionEnv;
}

View File

@@ -0,0 +1,44 @@
import { type Static, Type } from "typebox";
import type { AgentHarnessTool } from "../types.ts";
import { getOrThrow } from "../types.ts";
import { withFileMutationQueue } from "./file-mutation-queue.ts";
import { resolveToolPath } from "./path-utils.ts";
import type { ExecutionToolContext } from "./tool-context.ts";
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 type WriteToolInput = Static<typeof writeSchema>;
export function createWriteTool<TContext extends ExecutionToolContext = ExecutionToolContext>(): AgentHarnessTool<
TContext,
typeof writeSchema,
undefined
> {
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,
async execute(_toolCallId, { path, content }, _onUpdate, { env }, _invocation, context) {
const absolutePath = await resolveToolPath(env, path, context);
return withFileMutationQueue(
env,
absolutePath,
async () => {
if (context.abortSignal?.aborted) throw new Error("Operation aborted");
getOrThrow(await env.writeFile(absolutePath, content, context));
if (context.abortSignal?.aborted) throw new Error("Operation aborted");
return {
content: [{ type: "text", text: `Successfully wrote to ${path}` }],
details: undefined,
};
},
context,
);
},
};
}