notebook.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. import type {
  2. ImageBlockParam,
  3. TextBlockParam,
  4. ToolResultBlockParam,
  5. } from '@anthropic-ai/sdk/resources/index.mjs'
  6. import { BASH_TOOL_NAME } from '../tools/BashTool/toolName.js'
  7. import { formatOutput } from '../tools/BashTool/utils.js'
  8. import type {
  9. NotebookCell,
  10. NotebookCellOutput,
  11. NotebookCellSource,
  12. NotebookCellSourceOutput,
  13. NotebookContent,
  14. NotebookOutputImage,
  15. } from '../types/notebook.js'
  16. import { getFsImplementation } from './fsOperations.js'
  17. import { expandPath } from './path.js'
  18. import { jsonParse } from './slowOperations.js'
  19. const LARGE_OUTPUT_THRESHOLD = 10000
  20. function isLargeOutputs(
  21. outputs: (NotebookCellSourceOutput | undefined)[],
  22. ): boolean {
  23. let size = 0
  24. for (const o of outputs) {
  25. if (!o) continue
  26. size += (o.text?.length ?? 0) + (o.image?.image_data.length ?? 0)
  27. if (size > LARGE_OUTPUT_THRESHOLD) return true
  28. }
  29. return false
  30. }
  31. function processOutputText(text: string | string[] | undefined): string {
  32. if (!text) return ''
  33. const rawText = Array.isArray(text) ? text.join('') : text
  34. const { truncatedContent } = formatOutput(rawText)
  35. return truncatedContent
  36. }
  37. function extractImage(
  38. data: Record<string, unknown>,
  39. ): NotebookOutputImage | undefined {
  40. if (typeof data['image/png'] === 'string') {
  41. return {
  42. image_data: data['image/png'].replace(/\s/g, ''),
  43. media_type: 'image/png',
  44. }
  45. }
  46. if (typeof data['image/jpeg'] === 'string') {
  47. return {
  48. image_data: data['image/jpeg'].replace(/\s/g, ''),
  49. media_type: 'image/jpeg',
  50. }
  51. }
  52. return undefined
  53. }
  54. function processOutput(output: NotebookCellOutput) {
  55. switch (output.output_type) {
  56. case 'stream':
  57. return {
  58. output_type: output.output_type,
  59. text: processOutputText(output.text),
  60. }
  61. case 'execute_result':
  62. case 'display_data':
  63. return {
  64. output_type: output.output_type,
  65. text: processOutputText(output.data?.['text/plain']),
  66. image: output.data && extractImage(output.data),
  67. }
  68. case 'error':
  69. return {
  70. output_type: output.output_type,
  71. text: processOutputText(
  72. `${output.ename}: ${output.evalue}\n${output.traceback.join('\n')}`,
  73. ),
  74. }
  75. }
  76. }
  77. function processCell(
  78. cell: NotebookCell,
  79. index: number,
  80. codeLanguage: string,
  81. includeLargeOutputs: boolean,
  82. ): NotebookCellSource {
  83. const cellId = cell.id ?? `cell-${index}`
  84. const cellData: NotebookCellSource = {
  85. cellType: cell.cell_type,
  86. source: Array.isArray(cell.source) ? cell.source.join('') : cell.source,
  87. execution_count:
  88. cell.cell_type === 'code' ? cell.execution_count || undefined : undefined,
  89. cell_id: cellId,
  90. }
  91. // Avoid giving text cells the code language.
  92. if (cell.cell_type === 'code') {
  93. cellData.language = codeLanguage
  94. }
  95. if (cell.cell_type === 'code' && cell.outputs?.length) {
  96. const outputs = cell.outputs.map(processOutput)
  97. if (!includeLargeOutputs && isLargeOutputs(outputs)) {
  98. cellData.outputs = [
  99. {
  100. output_type: 'stream',
  101. text: `Outputs are too large to include. Use ${BASH_TOOL_NAME} with: cat <notebook_path> | jq '.cells[${index}].outputs'`,
  102. },
  103. ]
  104. } else {
  105. cellData.outputs = outputs
  106. }
  107. }
  108. return cellData
  109. }
  110. function cellContentToToolResult(cell: NotebookCellSource): TextBlockParam {
  111. const metadata = []
  112. if (cell.cellType !== 'code') {
  113. metadata.push(`<cell_type>${cell.cellType}</cell_type>`)
  114. }
  115. if (cell.language !== 'python' && cell.cellType === 'code') {
  116. metadata.push(`<language>${cell.language}</language>`)
  117. }
  118. const cellContent = `<cell id="${cell.cell_id}">${metadata.join('')}${cell.source}</cell id="${cell.cell_id}">`
  119. return {
  120. text: cellContent,
  121. type: 'text',
  122. }
  123. }
  124. function cellOutputToToolResult(output: NotebookCellSourceOutput) {
  125. const outputs: (TextBlockParam | ImageBlockParam)[] = []
  126. if (output.text) {
  127. outputs.push({
  128. text: `\n${output.text}`,
  129. type: 'text',
  130. })
  131. }
  132. if (output.image) {
  133. outputs.push({
  134. type: 'image',
  135. source: {
  136. data: output.image.image_data,
  137. media_type: output.image.media_type,
  138. type: 'base64',
  139. },
  140. })
  141. }
  142. return outputs
  143. }
  144. function getToolResultFromCell(cell: NotebookCellSource) {
  145. const contentResult = cellContentToToolResult(cell)
  146. const outputResults = cell.outputs?.flatMap(cellOutputToToolResult)
  147. return [contentResult, ...(outputResults ?? [])]
  148. }
  149. /**
  150. * Reads and parses a Jupyter notebook file into processed cell data
  151. */
  152. export async function readNotebook(
  153. notebookPath: string,
  154. cellId?: string,
  155. ): Promise<NotebookCellSource[]> {
  156. const fullPath = expandPath(notebookPath)
  157. const buffer = await getFsImplementation().readFileBytes(fullPath)
  158. const content = buffer.toString('utf-8')
  159. const notebook = jsonParse(content) as NotebookContent
  160. const language = notebook.metadata.language_info?.name ?? 'python'
  161. if (cellId) {
  162. const cell = notebook.cells.find(c => c.id === cellId)
  163. if (!cell) {
  164. throw new Error(`Cell with ID "${cellId}" not found in notebook`)
  165. }
  166. return [processCell(cell, notebook.cells.indexOf(cell), language, true)]
  167. }
  168. return notebook.cells.map((cell, index) =>
  169. processCell(cell, index, language, false),
  170. )
  171. }
  172. /**
  173. * Maps notebook cell data to tool result block parameters with sophisticated text block merging
  174. */
  175. export function mapNotebookCellsToToolResult(
  176. data: NotebookCellSource[],
  177. toolUseID: string,
  178. ): ToolResultBlockParam {
  179. const allResults = data.flatMap(getToolResultFromCell)
  180. // Merge adjacent text blocks
  181. return {
  182. tool_use_id: toolUseID,
  183. type: 'tool_result' as const,
  184. content: allResults.reduce<(TextBlockParam | ImageBlockParam)[]>(
  185. (acc, curr) => {
  186. if (acc.length === 0) return [curr]
  187. const prev = acc[acc.length - 1]
  188. if (prev && prev.type === 'text' && curr.type === 'text') {
  189. // Merge the text blocks
  190. prev.text += '\n' + curr.text
  191. return acc
  192. }
  193. acc.push(curr)
  194. return acc
  195. },
  196. [],
  197. ),
  198. }
  199. }
  200. export function parseCellId(cellId: string): number | undefined {
  201. const match = cellId.match(/^cell-(\d+)$/)
  202. if (match && match[1]) {
  203. const index = parseInt(match[1], 10)
  204. return isNaN(index) ? undefined : index
  205. }
  206. return undefined
  207. }