import { existsSync, readFileSync } from 'fs' import { Box, Text } from 'ink' import { extname, isAbsolute, relative, resolve } from 'path' import * as React from 'react' import { z } from 'zod' import { FallbackToolUseRejectedMessage } from '../../components/FallbackToolUseRejectedMessage' import { HighlightedCode } from '../../components/HighlightedCode' import type { Tool } from '../../Tool' import { NotebookCellType, NotebookContent } from '../../types/notebook' import { detectFileEncoding, detectLineEndings, writeTextContent, } from '../../utils/file.js' import { safeParseJSON } from '../../utils/json' import { getCwd } from '../../utils/state' import { DESCRIPTION, PROMPT } from './prompt' import { hasWritePermission } from '../../utils/permissions/filesystem' import { emitReminderEvent } from '../../services/systemReminder' import { recordFileEdit } from '../../services/fileFreshness' const inputSchema = z.strictObject({ notebook_path: z .string() .describe( 'The absolute path to the Jupyter notebook file to edit (must be absolute, not relative)', ), cell_number: z.number().describe('The index of the cell to edit (0-based)'), new_source: z.string().describe('The new source for the cell'), cell_type: z .enum(['code', 'markdown']) .optional() .describe( '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: z .string() .optional() .describe( 'The type of edit to make (replace, insert, delete). Defaults to replace.', ), }) export const NotebookEditTool = { name: 'NotebookEditCell', async description() { return DESCRIPTION }, async prompt() { return PROMPT }, inputSchema, userFacingName() { return 'Edit Notebook' }, async isEnabled() { return true }, isReadOnly() { return false }, isConcurrencySafe() { return false // NotebookEditTool modifies state/files, not safe for concurrent execution }, needsPermissions({ notebook_path }) { return !hasWritePermission(notebook_path) }, renderResultForAssistant({ cell_number, edit_mode, new_source, error }) { if (error) { return error } switch (edit_mode) { case 'replace': return `Updated cell ${cell_number} with ${new_source}` case 'insert': return `Inserted cell ${cell_number} with ${new_source}` case 'delete': return `Deleted cell ${cell_number}` } }, renderToolUseMessage(input, { verbose }) { return `notebook_path: ${verbose ? input.notebook_path : relative(getCwd(), input.notebook_path)}, cell: ${input.cell_number}, content: ${input.new_source.slice(0, 30)}…, cell_type: ${input.cell_type}, edit_mode: ${input.edit_mode ?? 'replace'}` }, renderToolUseRejectedMessage() { return }, renderToolResultMessage({ cell_number, new_source, language, error }) { if (error) { return ( {error} ) } return ( Updated cell {cell_number}: ) }, async validateInput({ notebook_path, cell_number, cell_type, edit_mode = 'replace', }) { const fullPath = isAbsolute(notebook_path) ? notebook_path : resolve(getCwd(), notebook_path) if (!existsSync(fullPath)) { return { result: false, message: 'Notebook file does not exist.', } } if (extname(fullPath) !== '.ipynb') { return { result: false, message: 'File must be a Jupyter notebook (.ipynb file). For editing other file types, use the FileEdit tool.', } } if (cell_number < 0) { return { result: false, message: 'Cell number must be non-negative.', } } if ( edit_mode !== 'replace' && edit_mode !== 'insert' && edit_mode !== 'delete' ) { return { result: false, message: 'Edit mode must be replace, insert, or delete.', } } if (edit_mode === 'insert' && !cell_type) { return { result: false, message: 'Cell type is required when using edit_mode=insert.', } } const enc = detectFileEncoding(fullPath) const content = readFileSync(fullPath, enc) const notebook = safeParseJSON(content) as NotebookContent | null if (!notebook) { return { result: false, message: 'Notebook is not valid JSON.', } } if (edit_mode === 'insert' && cell_number > notebook.cells.length) { return { result: false, message: `Cell number is out of bounds. For insert mode, the maximum value is ${notebook.cells.length} (to append at the end).`, } } else if ( (edit_mode === 'replace' || edit_mode === 'delete') && (cell_number >= notebook.cells.length || !notebook.cells[cell_number]) ) { return { result: false, message: `Cell number is out of bounds. Notebook has ${notebook.cells.length} cells.`, } } return { result: true } }, async *call({ notebook_path, cell_number, new_source, cell_type, edit_mode, }) { const fullPath = isAbsolute(notebook_path) ? notebook_path : resolve(getCwd(), notebook_path) try { const enc = detectFileEncoding(fullPath) const content = readFileSync(fullPath, enc) const notebook = JSON.parse(content) as NotebookContent const language = notebook.metadata.language_info?.name ?? 'python' if (edit_mode === 'delete') { // Delete the specified cell notebook.cells.splice(cell_number, 1) } else if (edit_mode === 'insert') { // Insert the new cell const new_cell = { cell_type: cell_type!, // validateInput ensures cell_type is not undefined source: new_source, metadata: {}, } notebook.cells.splice( cell_number, 0, cell_type == 'markdown' ? new_cell : { ...new_cell, outputs: [] }, ) } else { // Find the specified cell const targetCell = notebook.cells[cell_number]! // validateInput ensures cell_number is in bounds targetCell.source = new_source // Reset execution count and clear outputs since cell was modified targetCell.execution_count = undefined targetCell.outputs = [] if (cell_type && cell_type !== targetCell.cell_type) { targetCell.cell_type = cell_type } } // Write back to file const endings = detectLineEndings(fullPath) const updatedNotebook = JSON.stringify(notebook, null, 1) writeTextContent(fullPath, updatedNotebook, enc, endings!) // Record Agent edit operation for file freshness tracking recordFileEdit(fullPath, updatedNotebook) // Emit file edited event for system reminders emitReminderEvent('file:edited', { filePath: fullPath, cellNumber: cell_number, newSource: new_source, cellType: cell_type, editMode: edit_mode || 'replace', timestamp: Date.now(), operation: 'notebook_edit', }) const data = { cell_number, new_source, cell_type: cell_type ?? 'code', language, edit_mode: edit_mode ?? 'replace', error: '', } yield { type: 'result', data, resultForAssistant: this.renderResultForAssistant(data), } } catch (error) { if (error instanceof Error) { const data = { cell_number, new_source, cell_type: cell_type ?? 'code', language: 'python', edit_mode: 'replace', error: error.message, } yield { type: 'result', data, resultForAssistant: this.renderResultForAssistant(data), } return } const data = { cell_number, new_source, cell_type: cell_type ?? 'code', language: 'python', edit_mode: 'replace', error: 'Unknown error occurred while editing notebook', } yield { type: 'result', data, resultForAssistant: this.renderResultForAssistant(data), } } }, } satisfies Tool< typeof inputSchema, { cell_number: number new_source: string cell_type: NotebookCellType language: string edit_mode: string error?: string } >