@sema-agent/core
Version:
Stateless, task-oriented AI agent core
590 lines (589 loc) • 35.8 kB
JavaScript
import { randomUUID } from "node:crypto";
import { Type } from "typebox";
import { defineTool, errorResult } from "../../core/tools.js";
import { sha256, resolveKey, violationText, violationDetails, requireRead, checkStale, checkEditMatch, checkNoChange, resolveQuoteMatch, adaptNewStringQuotes, resolveEscapeMatch, adaptNewStringEscapes, escapeMatchWasAttempted, ESCAPE_MATCH_MISS_NOTE, deletionOldString, countOccurrences, READ_REFUSED_ESCAPE_HINT, } from "./safety.js";
import { decodeTextBytes, encodeTextForFile, normalizeEditText, normalizeFileText } from "./encoding.js";
import { seatForCall } from "./read-deny.js";
import { MAX_EDIT_BYTES, decodeEditBytes, tooLargeToEditMessage, truncatedUtf16BodyMessage, persistedTextOf, notReadRefusalText, enoentMessage, FILE_STATE_TRAILER, FILE_PATH_PARAMS, coerceWriteArguments, ipynbRedirect, countLines, applyRecordedEdit, } from "./fs-shared.js";
import { toolFace } from "../../core/tool-catalog-entries.js";
import { fileArgPath } from "../../core/tool-registry.js";
import { thrownText } from "../../core/thrown-value.js";
async function envFinalWrite(env, key, content, signal, opts) {
const fireEdited = () => {
if (opts?.edited?.hook === undefined)
return;
try {
const ack = opts.edited.hook({ tool: opts.edited.tool, path: opts.edited.path, key });
if (ack !== null && (typeof ack === "object" || typeof ack === "function") && typeof ack.then === "function") {
void ack.then(undefined, () => undefined);
}
}
catch {
}
};
let r;
try {
r = await writeThroughEnv(env, key, content, signal, opts);
}
catch (err) {
await discardTrackedEdit(opts?.track, "verify");
fireEdited();
throw err;
}
if (!r.ok) {
const proven = NOTHING_WAS_WRITTEN.has(r.error.code);
await discardTrackedEdit(opts?.track, proven ? "proven" : "verify");
if (!proven)
fireEdited();
return r;
}
fireEdited();
return r;
}
const NOTHING_WAS_WRITTEN = new Set(["already_exists", "precondition_failed"]);
async function writeThroughEnv(env, key, content, signal, opts) {
if (env.writeFileGuarded !== undefined) {
const r = await env.writeFileGuarded(key, content, { canonicalPath: key, ...(opts?.exclusive === true ? { exclusive: true } : {}) }, signal);
return r.ok ? { ok: true } : r;
}
if (opts?.exclusive === true && env.writeFileExclusive !== undefined) {
return env.writeFileExclusive(key, content, signal);
}
return env.writeFile(key, content, signal);
}
async function discardTrackedEdit(lease, proof) {
if (lease?.annul === undefined)
return;
try {
await lease.annul(proof);
}
catch {
}
}
const clipHookText = (s) => (s.length > 300 ? `${s.slice(0, 300)}…` : s);
async function trackToolEdit(hook, tool, path, key, signal) {
if (hook === undefined)
return {};
let res;
try {
res = await hook({ tool, path, key, ...(signal !== undefined ? { signal } : {}) });
}
catch (err) {
return { refusal: `Error (${tool}): the file-history first-touch backup failed (${clipHookText(thrownText(err))}) — nothing was written.` };
}
if (res.ok === false)
return { refusal: clipHookText(res.refusal) };
return res.annul !== undefined ? { annul: res.annul } : {};
}
async function gateToolWrite(hook, tool, path, key, content) {
if (hook === undefined)
return undefined;
let res;
try {
res = await hook({ tool, path, key, content });
}
catch (err) {
return `Error (${tool}): the write gate failed (${clipHookText(thrownText(err))}) — nothing was written.`;
}
if (res !== undefined && res.ok === false)
return `Error (${tool}): write rejected by the write gate (${res.code}): ${clipHookText(res.reason)}`;
return undefined;
}
export function createEditFileTool(env, state, rootCanonical, cwdRef, additionalRoots, beforeWrite, trackEdit, onEdited, writeFace, writeDeny) {
return defineTool({
name: "Edit",
...toolFace("edit"),
description: "Performs exact string replacement in a file.\n" +
"\n" +
"- You must Read the file in this conversation before editing, or the call will fail.\n" +
"- `old_string` must match the file exactly, including indentation, and be unique — the edit fails otherwise. Strip the Read line prefix (line number + tab) before matching.\n" +
"- `replace_all: true` replaces every occurrence instead.\n" +
"- The edit also fails if the file changed on disk since you read it — Read it again first.",
parameters: Type.Object({
...FILE_PATH_PARAMS,
old_string: Type.Optional(Type.String({ description: "The text to replace" })),
new_string: Type.Optional(Type.String({ description: "The text to replace it with (must be different from old_string)" })),
replace_all: Type.Optional(Type.Boolean({ description: "Replace all occurrences of old_string (default false)" })),
}),
validateInput: async (args, ctx) => {
const a = args;
if (Array.isArray(a.edits) && a.edits.length > 0)
return { ok: true };
if (typeof a.old_string !== "string" || typeof a.new_string !== "string")
return { ok: true };
const path = fileArgPath(args);
if (path === undefined)
return { ok: true };
const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots, undefined, seatForCall(writeDeny, ctx.toolCallId), writeFace?.());
if (!r.ok)
return { ok: true };
if (a.old_string === a.new_string)
return { ok: true };
const exists = await env.exists(r.key, ctx.signal);
if (!exists.ok || !exists.value)
return { ok: true };
const editInfo = await env.fileInfo(r.key, ctx.signal);
if (editInfo.ok && editInfo.value.size > MAX_EDIT_BYTES)
return { ok: true };
if (a.old_string === "")
return { ok: true };
if (ipynbRedirect("Edit", path))
return { ok: true };
const notRead = requireRead(state, r.key);
if (notRead === undefined)
return { ok: true };
return { ok: false, code: notRead.code, message: await notReadRefusalText(env, "Edit", r.key, notRead, ctx.signal, READ_REFUSED_ESCAPE_HINT) };
},
execute: async (args, ctx) => {
const a = args;
const batch = Array.isArray(a.edits) && a.edits.length > 0;
const edits = batch
? a.edits
: [{ old_string: a.old_string ?? "", new_string: a.new_string ?? "", ...(a.replace_all !== undefined ? { replace_all: a.replace_all } : {}) }];
if (!batch && (typeof a.old_string !== "string" || typeof a.new_string !== "string")) {
return errorResult(`Error (Edit): old_string and new_string are required.`);
}
for (let i = 0; i < edits.length; i++) {
const e = edits[i];
if (!e || typeof e.old_string !== "string" || typeof e.new_string !== "string") {
return errorResult(`Error (Edit): edit ${i + 1} of ${edits.length}: old_string and new_string must both be strings (no changes written — the batch is atomic).`);
}
}
const path = fileArgPath(args);
if (path === undefined)
return errorResult(`Error (Edit): file_path is required.`);
const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots, undefined, seatForCall(writeDeny, ctx.toolCallId), writeFace?.());
if (!r.ok)
return errorResult(violationText("Edit", r.violation), violationDetails(r.violation));
if (!batch && a.old_string === a.new_string) {
return errorResult(violationText("Edit", { code: "invalid", message: "No changes to make: old_string and new_string are exactly the same." }));
}
const singleOld = batch ? undefined : a.old_string;
const exists = await env.exists(r.key, ctx.signal);
if (!exists.ok)
return errorResult(`Error (Edit): cannot stat "${path}": ${exists.error.message}`);
if (!exists.value) {
if (singleOld === "") {
const created = a.new_string ?? "";
if (created === "")
return errorResult(violationText("Edit", { code: "invalid", message: "No changes to make: old_string and new_string are exactly the same." }));
const gated = await gateToolWrite(beforeWrite, "Edit", path, r.key, created);
if (gated !== undefined)
return errorResult(gated);
const tracked = await trackToolEdit(trackEdit, "Edit", path, r.key, ctx.signal);
if (tracked.refusal !== undefined)
return errorResult(tracked.refusal);
const write = await envFinalWrite(env, r.key, created, ctx.signal, { exclusive: true, track: tracked, edited: { hook: onEdited, tool: "Edit", path } });
if (!write.ok) {
if (write.error.code === "already_exists") {
return errorResult(`Error (Edit): cannot create "${path}": file already exists (created concurrently since the existence check). Read the file first, then edit it normally (or Write after the Read to overwrite it).`);
}
if (write.error.code === "precondition_failed") {
return errorResult(`Error (Edit): cannot create "${path.slice(0, 512)}": the atomic write refused its precondition (${write.error.message.slice(0, 512)}) — the on-disk state at that path changed between the existence check and the write (something may now exist there, or the path may now resolve somewhere else, e.g. through a symlink). Nothing was written. Read the path to see what is actually there before acting on it.`);
}
return errorResult(`Error (Edit): cannot write "${path}": ${write.error.message}`);
}
const createdNorm = normalizeFileText(created);
state.set(r.key, { hash: sha256(createdNorm), totalLines: countLines(createdNorm), truncated: false, lastReadAt: Date.now() });
return {
content: `Created ${path} (${countLines(createdNorm)} lines).`,
details: { type: "create", filePath: path, content: created },
};
}
return errorResult(`Error (Edit): ${await enoentMessage(env, r.key, cwdRef?.current ?? rootCanonical, ctx.signal)}`);
}
const editInfo = await env.fileInfo(r.key, ctx.signal);
if (editInfo.ok && editInfo.value.size > MAX_EDIT_BYTES) {
return errorResult(tooLargeToEditMessage(path, editInfo.value.size));
}
if (singleOld === "") {
const preBin = await env.readBinaryFile(r.key, ctx.signal);
if (!preBin.ok)
return errorResult(`Error (Edit): cannot read "${path}": ${preBin.error.message}`);
if (preBin.value.byteLength > MAX_EDIT_BYTES) {
return errorResult(tooLargeToEditMessage(path, preBin.value.byteLength));
}
const preDecResult = decodeEditBytes(preBin.value, path);
if (!preDecResult.ok)
return errorResult(preDecResult.message);
const preDec = preDecResult.value;
if (preDec.malformed)
return errorResult(truncatedUtf16BodyMessage("Edit", path));
if (preDec.text.trim() !== "")
return errorResult(`Error (Edit): Cannot create new file - file already exists.`);
const priorRead = state.get(r.key);
if (priorRead !== undefined) {
const staleCreate = checkStale(priorRead, sha256(preDec.text));
if (staleCreate)
return errorResult(violationText("Edit", staleCreate));
}
const newContent = normalizeEditText(a.new_string ?? "");
if (newContent === preDec.text)
return errorResult(violationText("Edit", { code: "invalid", message: "No changes to make: old_string and new_string are exactly the same." }));
const gated = await gateToolWrite(beforeWrite, "Edit", path, r.key, newContent);
if (gated !== undefined)
return errorResult(gated);
const trackedOverwrite = await trackToolEdit(trackEdit, "Edit", path, r.key, ctx.signal);
if (trackedOverwrite.refusal !== undefined)
return errorResult(trackedOverwrite.refusal);
const encodedOverwrite = encodeTextForFile(newContent, preDec.encoding, preDec.endings);
const write = await envFinalWrite(env, r.key, encodedOverwrite, ctx.signal, { track: trackedOverwrite, edited: { hook: onEdited, tool: "Edit", path } });
if (!write.ok)
return errorResult(`Error (Edit): cannot write "${path}": ${write.error.message}`);
const persistedOverwrite = persistedTextOf(encodedOverwrite);
state.set(r.key, { hash: sha256(persistedOverwrite), totalLines: countLines(persistedOverwrite), truncated: false, lastReadAt: Date.now() });
return {
content: `The file ${path} has been updated successfully.${FILE_STATE_TRAILER}`,
details: { type: "edit", filePath: path, originalFile: preDec.text, oldString: "", newString: newContent, replaceAll: false, edits: [{ oldString: "", newString: newContent, replaceAll: false }] },
};
}
const ipynb = ipynbRedirect("Edit", path);
if (ipynb)
return errorResult(ipynb);
const notRead = requireRead(state, r.key);
if (notRead)
return errorResult(await notReadRefusalText(env, "Edit", r.key, notRead, ctx.signal, READ_REFUSED_ESCAPE_HINT));
const readBin = await env.readBinaryFile(r.key, ctx.signal);
if (!readBin.ok)
return errorResult(`Error (Edit): cannot read "${path}": ${readBin.error.message}`);
if (readBin.value.byteLength > MAX_EDIT_BYTES) {
return errorResult(tooLargeToEditMessage(path, readBin.value.byteLength));
}
const decodedResult = decodeEditBytes(readBin.value, path);
if (!decodedResult.ok)
return errorResult(decodedResult.message);
const decoded = decodedResult.value;
if (decoded.malformed)
return errorResult(truncatedUtf16BodyMessage("Edit", path));
const original = decoded.text;
const entry = state.get(r.key);
const readWasTruncated = entry?.truncated === true;
const stale = checkStale(entry, sha256(original));
if (stale)
return errorResult(violationText("Edit", stale));
let working = original;
let replacements = 0;
const appliedEdits = [];
for (let i = 0; i < edits.length; i++) {
const e = edits[i];
const where = batch ? `edit ${i + 1} of ${edits.length}: ` : "";
let oldS = normalizeEditText(e.old_string);
let newS = normalizeEditText(e.new_string);
const noChange = checkNoChange(oldS, newS);
if (noChange)
return errorResult(batch ? `Error (Edit): ${where}${noChange.message}` : violationText("Edit", noChange));
if (oldS !== "" && !working.includes(oldS)) {
const resolved = resolveQuoteMatch(working, oldS);
if (resolved !== undefined && resolved !== oldS) {
newS = adaptNewStringQuotes(resolved, newS);
oldS = resolved;
}
}
if (oldS !== "" && !working.includes(oldS)) {
const resolvedEsc = resolveEscapeMatch(working, oldS);
if (resolvedEsc !== undefined && resolvedEsc !== oldS) {
newS = adaptNewStringEscapes(oldS, resolvedEsc, newS);
oldS = resolvedEsc;
}
}
const match = checkEditMatch(working, oldS, e.replace_all === true, readWasTruncated);
if (match) {
const escNote = match.code === "ambiguous_edit" && escapeMatchWasAttempted(e.old_string) && !working.includes(oldS) ? ESCAPE_MATCH_MISS_NOTE : "";
return errorResult(batch ? `Error (Edit): ${where}${match.message}${escNote} (no changes written — the batch is atomic).` : `${violationText("Edit", match)}${escNote}`);
}
replacements += e.replace_all === true ? countOccurrences(working, oldS) : 1;
const effOld = e.replace_all === true ? oldS : deletionOldString(working, oldS, newS);
const applied = { oldString: effOld, newString: newS, replaceAll: e.replace_all === true };
working = applyRecordedEdit(working, applied);
appliedEdits.push(applied);
}
const gated = await gateToolWrite(beforeWrite, "Edit", path, r.key, working);
if (gated !== undefined)
return errorResult(gated);
const trackedEdit = await trackToolEdit(trackEdit, "Edit", path, r.key, ctx.signal);
if (trackedEdit.refusal !== undefined)
return errorResult(trackedEdit.refusal);
const encodedEdit = encodeTextForFile(working, decoded.encoding, decoded.endings);
const write = await envFinalWrite(env, r.key, encodedEdit, ctx.signal, { track: trackedEdit, edited: { hook: onEdited, tool: "Edit", path } });
if (!write.ok)
return errorResult(`Error (Edit): cannot write "${path}": ${write.error.message}`);
const persistedEdit = persistedTextOf(encodedEdit);
state.set(r.key, { hash: sha256(persistedEdit), totalLines: countLines(persistedEdit), truncated: false, lastReadAt: Date.now() });
const a0 = appliedEdits[0];
return {
content: batch
? `Edited ${path} (${edits.length} edit${edits.length === 1 ? "" : "s"}, ${replacements} replacement${replacements === 1 ? "" : "s"}).`
: a0.replaceAll
? `The file ${path} has been updated. All occurrences were successfully replaced.${FILE_STATE_TRAILER}`
: `The file ${path} has been updated successfully.${FILE_STATE_TRAILER}`,
details: {
type: "edit",
filePath: path,
originalFile: original,
edits: appliedEdits,
...(batch ? {} : { oldString: a0.oldString, newString: a0.newString, replaceAll: a0.replaceAll }),
},
};
},
});
}
export function createWriteFileTool(env, state, rootCanonical, cwdRef, additionalRoots, beforeWrite, trackEdit, onEdited, writeFace, writeDeny) {
const write = defineTool({
name: "Write",
...toolFace("write"),
description: "Writes a file to the local filesystem, overwriting if one exists.\n" +
"\n" +
"When to use: creating a new file, or fully replacing one you've already Read. Overwriting an existing " +
"file you haven't Read will fail. For partial changes, use Edit instead. The overwrite also fails if the " +
"file changed on disk since you read it — Read it again first.",
parameters: Type.Object({
...FILE_PATH_PARAMS,
content: Type.String({ description: "The content to write to the file" }),
}),
validateInput: async (args, ctx) => {
const path = fileArgPath(args);
if (path === undefined)
return { ok: true };
if (ipynbRedirect("Write", path))
return { ok: true };
const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots, undefined, seatForCall(writeDeny, ctx.toolCallId), writeFace?.());
if (!r.ok)
return { ok: true };
const exists = await env.exists(r.key, ctx.signal);
if (!exists.ok || !exists.value)
return { ok: true };
const notRead = requireRead(state, r.key);
if (notRead === undefined)
return { ok: true };
return { ok: false, code: notRead.code, message: await notReadRefusalText(env, "Write", r.key, notRead, ctx.signal, READ_REFUSED_ESCAPE_HINT) };
},
execute: async (args, ctx) => {
const { content } = args;
const path = fileArgPath(args);
if (path === undefined)
return errorResult(`Error (Write): file_path is required.`);
const ipynb = ipynbRedirect("Write", path);
if (ipynb)
return errorResult(ipynb);
const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots, undefined, seatForCall(writeDeny, ctx.toolCallId), writeFace?.());
if (!r.ok)
return errorResult(violationText("Write", r.violation), violationDetails(r.violation));
const exists = await env.exists(r.key, ctx.signal);
if (!exists.ok)
return errorResult(`Error (Write): cannot stat "${path}": ${exists.error.message}`);
let originalFile;
if (exists.value) {
const notRead = requireRead(state, r.key);
if (notRead) {
return errorResult(await notReadRefusalText(env, "Write", r.key, notRead, ctx.signal, READ_REFUSED_ESCAPE_HINT));
}
const readBin = await env.readBinaryFile(r.key, ctx.signal);
if (!readBin.ok)
return errorResult(`Error (Write): cannot re-read "${path}" to verify it is unchanged: ${readBin.error.message}`);
const decodedPrev = decodeTextBytes(readBin.value);
if (decodedPrev.malformed)
return errorResult(truncatedUtf16BodyMessage("Write", path));
const stale = checkStale(state.get(r.key), sha256(decodedPrev.text));
if (stale)
return errorResult(violationText("Write", stale));
originalFile = decodedPrev.text;
const gated = await gateToolWrite(beforeWrite, "Write", path, r.key, content);
if (gated !== undefined)
return errorResult(gated);
const trackedWrite = await trackToolEdit(trackEdit, "Write", path, r.key, ctx.signal);
if (trackedWrite.refusal !== undefined)
return errorResult(trackedWrite.refusal);
const encodedWrite = encodeTextForFile(content, decodedPrev.encoding, "preserve");
const write = await envFinalWrite(env, r.key, encodedWrite, ctx.signal, { track: trackedWrite, edited: { hook: onEdited, tool: "Write", path } });
if (!write.ok)
return errorResult(`Error (Write): cannot write "${path}": ${write.error.message}`);
const persistedWrite = persistedTextOf(encodedWrite);
state.set(r.key, { hash: sha256(persistedWrite), totalLines: countLines(persistedWrite), truncated: false, lastReadAt: Date.now() });
return {
content: `The file ${path} has been updated successfully.${FILE_STATE_TRAILER}`,
details: { type: "update", filePath: path, content: persistedWrite, originalFile },
};
}
const gatedCreate = await gateToolWrite(beforeWrite, "Write", path, r.key, content);
if (gatedCreate !== undefined)
return errorResult(gatedCreate);
const trackedCreate = await trackToolEdit(trackEdit, "Write", path, r.key, ctx.signal);
if (trackedCreate.refusal !== undefined)
return errorResult(trackedCreate.refusal);
const write = await envFinalWrite(env, r.key, content, ctx.signal, { track: trackedCreate, edited: { hook: onEdited, tool: "Write", path } });
if (!write.ok)
return errorResult(`Error (Write): cannot write "${path}": ${write.error.message}`);
const totalLines = countLines(content);
state.set(r.key, { hash: sha256(normalizeFileText(content)), totalLines, truncated: false, lastReadAt: Date.now() });
void originalFile;
return {
content: `File created successfully at: ${path}${FILE_STATE_TRAILER}`,
details: { type: "create", filePath: path, content },
};
},
});
write.prepareArguments = coerceWriteArguments;
return write;
}
const notebookNotIpynbMessage = (notebookPath) => `Error (NotebookEdit): "${notebookPath}" is not a .ipynb file; use Edit for other file types.`;
const NOTEBOOK_CELL_TYPE_REQUIRED_MESSAGE = `Error (NotebookEdit): Cell type is required when using edit_mode=insert.`;
const NOTEBOOK_CELL_ID_REQUIRED_MESSAGE = `Error (NotebookEdit): cell_id is required for replace/delete.`;
export function createNotebookEditTool(env, state, rootCanonical, cwdRef, additionalRoots, beforeWrite, trackEdit, onEdited, writeFace, writeDeny) {
return defineTool({
name: "NotebookEdit",
...toolFace("notebook-edit"),
description: "Replaces, inserts, or deletes a single cell in a Jupyter notebook (.ipynb file).\n" +
"\n" +
"Usage:\n" +
"- You must use the Read tool on the notebook in this conversation before editing — this tool will fail otherwise.\n" +
"- `cell_id` is the `id` attribute shown in the Read tool's `<cell id=\"...\">` output. It is required for `replace` and `delete`.\n" +
"- `edit_mode` defaults to `replace`. Use `insert` to add a new cell after the cell with the given `cell_id` " +
"(or at the beginning of the notebook if `cell_id` is omitted) — `cell_type` is required when inserting. " +
"Use `delete` to remove the cell.",
parameters: Type.Object({
notebook_path: Type.String({ description: "The path to the Jupyter notebook file to edit (within the configured root)." }),
cell_id: Type.Optional(Type.String({
description: "The ID of the cell to edit. When inserting a new cell, the new cell will be inserted after the cell with this ID, or at the beginning if not specified.",
})),
new_source: Type.String({ description: "The new source for the cell" }),
cell_type: Type.Optional(Type.Union([Type.Literal("code"), Type.Literal("markdown")], {
description: "The type of the cell (code or markdown). If not specified, it defaults to the current cell type. If using edit_mode=insert, this is required.",
})),
edit_mode: Type.Optional(Type.Union([Type.Literal("replace"), Type.Literal("insert"), Type.Literal("delete")], {
description: "The type of edit to make (replace, insert, delete). Defaults to replace.",
})),
}),
validateInput: async (args, ctx) => {
const a = args;
const mode = a.edit_mode ?? "replace";
if (!a.notebook_path.toLowerCase().endsWith(".ipynb"))
return { ok: false, code: "invalid", message: notebookNotIpynbMessage(a.notebook_path) };
if (mode === "insert" && !a.cell_type)
return { ok: false, code: "invalid", message: NOTEBOOK_CELL_TYPE_REQUIRED_MESSAGE };
if (mode !== "insert" && !a.cell_id)
return { ok: false, code: "invalid", message: NOTEBOOK_CELL_ID_REQUIRED_MESSAGE };
const r = await resolveKey(env, rootCanonical, a.notebook_path, ctx.signal, cwdRef?.current, additionalRoots, undefined, seatForCall(writeDeny, ctx.toolCallId), writeFace?.());
if (!r.ok)
return { ok: true };
const notRead = requireRead(state, r.key);
if (notRead === undefined)
return { ok: true };
return { ok: false, code: notRead.code, message: await notReadRefusalText(env, "NotebookEdit", r.key, notRead, ctx.signal, READ_REFUSED_ESCAPE_HINT) };
},
execute: async (args, ctx) => {
const a = args;
const { notebook_path, cell_id, new_source } = a;
let cellType = a.cell_type;
const mode = a.edit_mode ?? "replace";
if (!notebook_path.toLowerCase().endsWith(".ipynb")) {
return errorResult(notebookNotIpynbMessage(notebook_path));
}
if (mode === "insert" && !cellType)
return errorResult(NOTEBOOK_CELL_TYPE_REQUIRED_MESSAGE);
if (mode !== "insert" && !cell_id)
return errorResult(NOTEBOOK_CELL_ID_REQUIRED_MESSAGE);
const r = await resolveKey(env, rootCanonical, notebook_path, ctx.signal, cwdRef?.current, additionalRoots, undefined, seatForCall(writeDeny, ctx.toolCallId), writeFace?.());
if (!r.ok)
return errorResult(violationText("NotebookEdit", r.violation), violationDetails(r.violation));
const notRead = requireRead(state, r.key);
if (notRead)
return errorResult(await notReadRefusalText(env, "NotebookEdit", r.key, notRead, ctx.signal, READ_REFUSED_ESCAPE_HINT));
const readBin = await env.readBinaryFile(r.key, ctx.signal);
if (!readBin.ok)
return errorResult(`Error (NotebookEdit): cannot read "${notebook_path}": ${readBin.error.message}`);
const decodedNb = decodeTextBytes(readBin.value);
if (decodedNb.malformed)
return errorResult(truncatedUtf16BodyMessage("NotebookEdit", notebook_path));
const nbText = decodedNb.text;
const stale = checkStale(state.get(r.key), sha256(nbText));
if (stale)
return errorResult(violationText("NotebookEdit", stale));
let nb;
try {
nb = JSON.parse(nbText);
}
catch {
return errorResult(`Error (NotebookEdit): "${notebook_path}" is not valid JSON.`);
}
if (!nb || typeof nb !== "object" || !Array.isArray(nb.cells)) {
return errorResult(`Error (NotebookEdit): "${notebook_path}" is not a valid notebook (no cells array).`);
}
const cells = nb.cells;
let h;
if (!cell_id) {
h = 0;
}
else {
h = cells.findIndex((c) => c && c.id === cell_id);
if (h === -1) {
const m = /^cell-(\d+)$/.exec(cell_id);
if (m) {
const idx = parseInt(m[1], 10);
if (idx >= 0 && idx < cells.length)
h = idx;
}
}
if (h === -1)
return errorResult(`Error (NotebookEdit): Cell with ID "${cell_id}" not found in notebook.`);
if (mode === "insert")
h += 1;
}
let g = mode;
if (g === "replace" && h === cells.length) {
g = "insert";
cellType = cellType ?? "code";
}
const supportsId = (nb.nbformat ?? 0) > 4 || ((nb.nbformat ?? 0) === 4 && (nb.nbformat_minor ?? 0) >= 5);
const newId = supportsId && g === "insert" ? randomUUID().slice(0, 8) : undefined;
let oldSource;
if (g === "delete") {
const prev = cells[h];
oldSource = Array.isArray(prev.source) ? prev.source.join("") : typeof prev.source === "string" ? prev.source : undefined;
cells.splice(h, 1);
}
else if (g === "insert") {
const cell = cellType === "markdown"
? { cell_type: "markdown", id: newId, source: new_source, metadata: {} }
: { cell_type: "code", id: newId, source: new_source, metadata: {}, execution_count: null, outputs: [] };
cells.splice(h, 0, cell);
}
else {
const cell = cells[h];
oldSource = Array.isArray(cell.source) ? cell.source.join("") : typeof cell.source === "string" ? cell.source : undefined;
cell.source = new_source;
if (cell.cell_type === "code") {
cell.execution_count = null;
cell.outputs = [];
}
if (cellType && cellType !== cell.cell_type)
cell.cell_type = cellType;
}
const updated = JSON.stringify(nb, null, 1);
const gated = await gateToolWrite(beforeWrite, "NotebookEdit", notebook_path, r.key, updated);
if (gated !== undefined)
return errorResult(gated);
const trackedNb = await trackToolEdit(trackEdit, "NotebookEdit", notebook_path, r.key, ctx.signal);
if (trackedNb.refusal !== undefined)
return errorResult(trackedNb.refusal);
const w = await envFinalWrite(env, r.key, encodeTextForFile(updated, decodedNb.encoding, decodedNb.endings), ctx.signal, { track: trackedNb, edited: { hook: onEdited, tool: "NotebookEdit", path: notebook_path } });
if (!w.ok)
return errorResult(`Error (NotebookEdit): cannot write "${notebook_path}": ${w.error.message}`);
state.set(r.key, { hash: sha256(updated), totalLines: countLines(updated), truncated: false, lastReadAt: Date.now() });
const nbDetails = {
type: "notebook-edit",
notebookPath: notebook_path,
...(cell_id ? { cellId: cell_id } : {}),
editMode: g,
...(cellType ? { cellType } : {}),
newSource: new_source,
...(oldSource !== undefined ? { oldSource } : {}),
originalFile: nbText,
};
if (g === "delete")
return { content: `Deleted cell ${cell_id}`, details: nbDetails };
if (g === "insert")
return { content: `Inserted cell${newId ? ` ${newId}` : ""} with ${new_source}`, details: { ...nbDetails, ...(newId ? { newCellId: newId } : {}) } };
return { content: `Updated cell ${cell_id} with ${new_source}`, details: nbDetails };
},
});
}