UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

590 lines (589 loc) • 35.8 kB
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 }; }, }); }