Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1be4f19
feat: add built-in read tool plugin and prompt guidelines support in …
prem315 Sep 30, 2026
90a7a6d
feat: support custom limit and offset parameters with refined truncat…
prem315 Oct 2, 2026
7eb82d7
Merge branch 'canary' into feature/read-tool
sanchitrk Oct 10, 2026
ffc7250
feat(harness): read built-in tool with bounded text and inline images
sanchitrk Oct 10, 2026
0fe1821
feat(harness): write built-in tool
sanchitrk Oct 10, 2026
a2ea0d8
feat(harness): edit built-in tool
sanchitrk Oct 10, 2026
340462a
Merge remote-tracking branch 'origin/canary' into feature/read-tool
sanchitrk Oct 10, 2026
03653c0
feat(harness): sample read arguments strictly where the provider can
sanchitrk Oct 10, 2026
c8418b2
Merge branch 'feature/read-tool' into feature/write-tool
sanchitrk Oct 10, 2026
c701c62
feat(harness): sample write arguments strictly where the provider can
sanchitrk Oct 10, 2026
3d98d03
Merge branch 'feature/write-tool' into feature/edit-tool
sanchitrk Oct 10, 2026
6ef70e1
feat(harness): sample edit arguments strictly where the provider can
sanchitrk Oct 10, 2026
0a7f9e2
refactor(harness): name the image resize worker reply after Photon
sanchitrk Oct 10, 2026
c596bf7
Merge branch 'feature/read-tool' into feature/write-tool
sanchitrk Oct 10, 2026
f33cb2b
Merge branch 'feature/write-tool' into feature/edit-tool
sanchitrk Oct 10, 2026
7d324bc
Merge remote-tracking branch 'origin/canary' into feature/write-tool
sanchitrk Oct 10, 2026
e2855d2
Merge branch 'feature/write-tool' into feature/edit-tool
sanchitrk Oct 10, 2026
8a692c0
Merge remote-tracking branch 'origin/canary' into feature/edit-tool
sanchitrk Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(harness): edit built-in tool
Add `edit` ported from Pi's edit tool: exact-text replacements matched
against the original file, exact first then fuzzy (NFKC, trailing
whitespace, smart quotes, dashes, special spaces) with untouched lines
kept byte for byte, CRLF and BOM preserved, Pi's error messages, and a
numbered display diff plus unified patch in `details`. The whole
read-match-write holds the file's mutation lock.

Add `prepareArguments` to tool definitions, run by the executor before
the arguments are validated, so `edit` repairs `edits` sent as a JSON
string, a single object, or a top-level oldText/newText pair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
  • Loading branch information
sanchitrk and claude committed Oct 10, 2026
commit a2ea0d864ebd6ae6141c18ec26c26528f764d6a2
1 change: 1 addition & 0 deletions packages/harness/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
"@npmcli/config": "^11.0.1",
"@platformatic/vfs": "^0.4.0",
"@silvia-odwyer/photon-node": "0.3.4",
"diff": "9.0.0",
"jsonc-parser": "3.3.1",
"just-bash": "^3.4.2",
"npm-package-arg": "^14.0.0",
Expand Down
10 changes: 9 additions & 1 deletion packages/harness/src/plugin/builtin.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { defaultPromptPlugin } from "./builtin/prompt/default.ts";
import { instructionPlugin } from "./builtin/prompt/instruction.ts";
import { bashPlugin } from "./builtin/tool/bash.ts";
import { editPlugin } from "./builtin/tool/edit.ts";
import { readPlugin } from "./builtin/tool/read.ts";
import { writePlugin } from "./builtin/tool/write.ts";

/**
* Every plugin the harness ships, and the selection a caller who passes no `plugins` gets. Tool
* contributors come first; within the prompt domain, declaration order is run order.
*/
export const builtins = Object.freeze([bashPlugin, readPlugin, writePlugin, defaultPromptPlugin, instructionPlugin]);
export const builtins = Object.freeze([
bashPlugin,
readPlugin,
writePlugin,
editPlugin,
defaultPromptPlugin,
instructionPlugin,
]);
160 changes: 160 additions & 0 deletions packages/harness/src/plugin/builtin/tool/edit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { Effect, Layer, Schema } from "effect";
import { SandboxFileSystem } from "../../../sandbox/fs/filesystem.ts";
import { SandboxIO } from "../../../sandbox/io.ts";
import { EditDiff } from "../../../tool/diff.ts";
import { ToolPath } from "../../../tool/path.ts";
import * as Tool from "../../../tool/tool.ts";
import { define } from "../../plugin.ts";

/**
* The built-in edit plugin: exact-text replacements, all matched against the
* original file, under the file's mutation lock for the whole read → match →
* write. The model reads a one-line summary; `details` carries the diff.
*/

const ReplaceEdit = Schema.Struct({
oldText: Schema.String.annotate({
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: Schema.String.annotate({ description: "Replacement text for this targeted edit." }),
});

const EditParams = Schema.Struct({
path: Schema.String.annotate({ description: "Path to the file to edit (relative or absolute)" }),
edits: Schema.Array(ReplaceEdit).annotate({
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.",
}),
});

const EditSuccess = Schema.Struct({
path: Schema.String,
replaced: Schema.Finite,
diff: Schema.String,
patch: Schema.String,
firstChangedLine: Schema.optional(Schema.Finite),
});

class EditFailed extends Schema.TaggedError<EditFailed>()("EditFailed", { message: Schema.String }) {}

const isSingleEdit = (value: unknown): value is typeof ReplaceEdit.Type => {
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";
};

/**
* Repair shapes models commonly send: `edits` as a JSON string or as a single
* edit object, and a top-level `oldText`/`newText` pair. Works on a copy; the
* call's arguments stay unchanged.
*/
export const prepareEditArguments = (input: unknown): unknown => {
if (!input || typeof input !== "object" || Array.isArray(input)) return input;
const args: Record<string, unknown> = { ...input };
if (typeof args.edits === "string") {
try {
const parsed: unknown = JSON.parse(args.edits);
if (Array.isArray(parsed)) args.edits = parsed;
else if (isSingleEdit(parsed)) args.edits = [parsed];
} catch {}
} else if (isSingleEdit(args.edits)) {
args.edits = [args.edits];
}

if (typeof args.oldText !== "string" || typeof args.newText !== "string") return args;
const edits = Array.isArray(args.edits) ? [...args.edits] : [];
edits.push({ oldText: args.oldText, newText: args.newText });
const { oldText: _oldText, newText: _newText, ...rest } = args;
return { ...rest, edits };
};

export const editDef = Tool.define({
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.",
promptSnippet: "Make precise file edits with exact text replacement, including multiple disjoint edits in one call",
promptGuidelines: [
"Use edit for precise changes (edits[].oldText must match exactly)",
"When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls",
"Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.",
"Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.",
],
prepareArguments: prepareEditArguments,
parameters: EditParams,
success: EditSuccess,
failure: EditFailed,
encodeContent: (success) => [
{ type: "text", text: `Successfully replaced ${success.replaced} block(s) in ${success.path}.` },
],
encodeFailureContent: (failure) => [{ type: "text", text: failure.message }],
encodeDetails: ({ diff, patch, firstChangedLine }) => ({
diff,
patch,
...(firstChangedLine === undefined ? {} : { firstChangedLine }),
}),
});

const fail = (message: string) => Effect.fail(new EditFailed({ message }));

/** Filesystem failures name the backend's error code, as Pi's do. */
const accessFailure = (path: string) => (error: SandboxFileSystem.FileSystemError) => {
const code = (error.cause as { code?: unknown } | undefined)?.code;
return fail(`Could not edit file: ${path}. Error code: ${typeof code === "string" ? code : "unknown"}.`);
};

export const editHandler: Tool.Handler<
typeof EditParams,
typeof EditSuccess,
typeof EditFailed,
SandboxIO.FileSystem | SandboxIO.Mutation
> = (params) =>
Effect.gen(function* () {
const { path, edits } = params;
if (edits.length === 0) {
return yield* fail("Edit tool input is invalid. edits must contain at least one replacement.");
}
const fs = yield* SandboxIO.FileSystem;
const mutation = yield* SandboxIO.Mutation;
const resolved = ToolPath.normalize(path);
return yield* mutation.withFile(
resolved,
Effect.gen(function* () {
const stat = yield* fs.stat(resolved);
if (!stat.isFile) return yield* fail(`Could not edit file: ${path}. Path is not a file.`);

const { bom, text: content } = EditDiff.stripBom(yield* fs.readFile(resolved));
const originalEnding = EditDiff.detectLineEnding(content);
const { baseContent, newContent } = yield* Effect.try({
try: () => EditDiff.applyEditsToNormalizedContent(EditDiff.normalizeToLF(content), edits, path),
catch: (cause) => new EditFailed({ message: cause instanceof Error ? cause.message : String(cause) }),
});
yield* fs.writeFile(resolved, bom + EditDiff.restoreLineEndings(newContent, originalEnding));

const { diff, firstChangedLine } = EditDiff.generateDiffString(baseContent, newContent);
return {
path,
replaced: edits.length,
diff,
patch: EditDiff.generateUnifiedPatch(path, baseContent, newContent),
...(firstChangedLine === undefined ? {} : { firstChangedLine }),
};
}),
);
}).pipe(Effect.catchTag("SandboxFileSystemError", accessFailure(params.path)));

/** The edit tool: definition + handler, wired the testable (def/exec split) way. */
export const editTool = Tool.implement(editDef, editHandler);

export const editPlugin = define({
id: "codework.tool.edit",
kind: "tool",
setup: Effect.fn("EditPlugin.setup")(function* (ctx) {
const mounted = Layer.merge(
Layer.succeed(SandboxIO.FileSystem, yield* SandboxIO.FileSystem),
Layer.succeed(SandboxIO.Mutation, yield* SandboxIO.Mutation),
);
ctx.plugin.tools.add(Tool.provide(editTool, mounted));
}),
});
2 changes: 2 additions & 0 deletions packages/harness/src/plugin/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ const assertTool = (tool: RegisteredTool) => {
throw new Error(`Tool ${name}: encodeContent must be a function`);
if (def.encodeFailureContent !== undefined && !Predicate.isFunction(def.encodeFailureContent))
throw new Error(`Tool ${name}: encodeFailureContent must be a function`);
if (def.prepareArguments !== undefined && !Predicate.isFunction(def.prepareArguments))
throw new Error(`Tool ${name}: prepareArguments must be a function`);
if (def.encodeDetails !== undefined && !Predicate.isFunction(def.encodeDetails))
throw new Error(`Tool ${name}: encodeDetails must be a function`);
if (!Predicate.isFunction(tool.handler)) throw new Error(`Tool ${name}: handler must be a function`);
Expand Down
Loading