| import { randomUUID } from 'node:crypto' |
| import { createStudioWorkResult } from '../../domain/factories' |
| import type { StudioFileAttachment, StudioToolDefinition, StudioToolResult, StudioWorkResult } from '../../domain/types' |
| import type { StudioRuntimeBackedToolContext } from '../../runtime/tool-runtime-context' |
| import { createWorkAndTask, publishWorkUpdated, updateTaskAndWork } from '../../works/work-lifecycle' |
| import { executeMatplotlibRender } from '../../../services/plot-runtime/matplotlib-executor' |
|
|
| interface PlotRenderToolInput { |
| concept: string |
| code: string |
| } |
|
|
| export function createPlotStudioRenderTool(): StudioToolDefinition<PlotRenderToolInput> { |
| return { |
| name: 'render', |
| description: 'Execute matplotlib code and persist static plot outputs for preview.', |
| category: 'render', |
| permission: 'render', |
| allowedAgents: ['builder'], |
| allowedStudioKinds: ['plot'], |
| requiresTask: true, |
| execute: async (input, context) => executePlotRenderTool(input, context as StudioRuntimeBackedToolContext) |
| } |
| } |
|
|
| async function executePlotRenderTool( |
| input: PlotRenderToolInput, |
| context: StudioRuntimeBackedToolContext |
| ): Promise<StudioToolResult> { |
| if (!input.concept?.trim() || !input.code?.trim()) { |
| throw new Error('Render tool requires non-empty "concept" and "code"') |
| } |
|
|
| const renderId = `plot_${randomUUID()}` |
| const title = `Plot render: ${input.concept.slice(0, 80)}` |
| const lifecycleMetadata = { |
| renderId, |
| concept: input.concept, |
| studioKind: 'plot', |
| outputMode: 'image' |
| } |
|
|
| const { work, task } = await createWorkAndTask({ |
| context, |
| work: { |
| sessionId: context.session.id, |
| runId: context.run.id, |
| type: 'plot', |
| title, |
| status: 'running', |
| metadata: lifecycleMetadata |
| }, |
| task: { |
| sessionId: context.session.id, |
| runId: context.run.id, |
| type: 'render', |
| status: 'running', |
| title, |
| detail: input.concept, |
| metadata: lifecycleMetadata |
| }, |
| workMetadata: lifecycleMetadata |
| }) |
|
|
| context.setToolMetadata?.({ |
| title, |
| metadata: { |
| renderId, |
| workId: work?.id, |
| taskId: task?.id, |
| studioKind: 'plot' |
| } |
| }) |
|
|
| try { |
| const execution = await executeMatplotlibRender({ |
| workspaceDirectory: context.session.directory, |
| renderId, |
| code: input.code |
| }) |
|
|
| const workResult = await persistWorkResult({ |
| context, |
| workId: work?.id, |
| taskId: task?.id, |
| renderId, |
| code: input.code, |
| codeLanguage: 'python', |
| execution, |
| }) |
|
|
| const completed = await updateTaskAndWork({ |
| context, |
| task, |
| work, |
| taskPatch: { |
| status: 'completed', |
| metadata: { |
| ...(task?.metadata ?? {}), |
| ...lifecycleMetadata, |
| result: { |
| status: 'completed', |
| timestamp: Date.now(), |
| data: { |
| outputMode: 'image', |
| imageUrls: execution.imageDataUris, |
| imageCount: execution.imageDataUris.length, |
| workspaceImagePaths: execution.imagePaths, |
| code: input.code, |
| codeLanguage: 'python', |
| usedAI: true, |
| quality: 'medium', |
| generationType: 'studio-plot' |
| } |
| } |
| } |
| }, |
| workMetadata: { |
| ...lifecycleMetadata, |
| currentResultId: workResult?.id, |
| workspaceImagePaths: execution.imagePaths, |
| scriptPath: execution.scriptPath |
| } |
| }) |
|
|
| if (workResult && completed.work && context.workStore) { |
| const updatedWork = await context.workStore.update(completed.work.id, { |
| currentResultId: workResult.id, |
| metadata: { |
| ...(completed.work.metadata ?? {}), |
| currentResultId: workResult.id, |
| workspaceImagePaths: execution.imagePaths, |
| scriptPath: execution.scriptPath |
| } |
| }) |
| publishWorkUpdated(context, updatedWork ?? completed.work) |
| } |
|
|
| return { |
| title, |
| output: `plot_render_id: ${renderId}`, |
| attachments: buildAttachments(execution.imageDataUris), |
| metadata: { |
| renderId, |
| taskId: completed.task?.id ?? task?.id, |
| workId: completed.work?.id ?? work?.id, |
| workResultId: workResult?.id, |
| imageCount: execution.imageDataUris.length, |
| scriptPath: execution.scriptPath, |
| workspaceImagePaths: execution.imagePaths |
| } |
| } |
| } catch (error) { |
| await persistFailureResult({ |
| context, |
| workId: work?.id, |
| taskId: task?.id, |
| renderId, |
| error: error instanceof Error ? error.message : String(error) |
| }) |
|
|
| await updateTaskAndWork({ |
| context, |
| task, |
| work, |
| taskPatch: { |
| status: 'failed', |
| metadata: { |
| ...(task?.metadata ?? {}), |
| ...lifecycleMetadata, |
| error: error instanceof Error ? error.message : String(error) |
| } |
| }, |
| workMetadata: { |
| ...lifecycleMetadata, |
| error: error instanceof Error ? error.message : String(error) |
| } |
| }) |
|
|
| throw error |
| } |
| } |
|
|
| async function persistWorkResult(input: { |
| context: StudioRuntimeBackedToolContext |
| workId?: string |
| taskId?: string |
| renderId: string |
| code: string |
| codeLanguage: 'python' |
| execution: Awaited<ReturnType<typeof executeMatplotlibRender>> |
| }): Promise<StudioWorkResult | null> { |
| if (!input.workId || !input.context.workResultStore) { |
| return null |
| } |
|
|
| const result = await input.context.workResultStore.create(createStudioWorkResult({ |
| workId: input.workId, |
| kind: 'render-output', |
| summary: `Plot render completed with ${input.execution.imageDataUris.length} image output(s)`, |
| attachments: buildAttachments(input.execution.imageDataUris), |
| metadata: { |
| taskId: input.taskId, |
| renderId: input.renderId, |
| studioKind: 'plot', |
| code: input.code, |
| codeLanguage: input.codeLanguage, |
| imageCount: input.execution.imageDataUris.length, |
| workspaceImagePaths: input.execution.imagePaths, |
| scriptPath: input.execution.scriptPath, |
| stdout: input.execution.stdout, |
| stderr: input.execution.stderr |
| } |
| })) |
|
|
| input.context.eventBus.publish({ |
| type: 'work_result_updated', |
| sessionId: input.context.session.id, |
| runId: input.context.run.id, |
| result |
| }) |
|
|
| return result |
| } |
|
|
| async function persistFailureResult(input: { |
| context: StudioRuntimeBackedToolContext |
| workId?: string |
| taskId?: string |
| renderId: string |
| error: string |
| }): Promise<void> { |
| if (!input.workId || !input.context.workResultStore) { |
| return |
| } |
|
|
| const result = await input.context.workResultStore.create(createStudioWorkResult({ |
| workId: input.workId, |
| kind: 'failure-report', |
| summary: input.error, |
| metadata: { |
| taskId: input.taskId, |
| renderId: input.renderId, |
| studioKind: 'plot', |
| error: input.error |
| } |
| })) |
|
|
| input.context.eventBus.publish({ |
| type: 'work_result_updated', |
| sessionId: input.context.session.id, |
| runId: input.context.run.id, |
| result |
| }) |
| } |
|
|
| function buildAttachments(imageDataUris: string[]): StudioFileAttachment[] { |
| return imageDataUris.map((path, index) => ({ |
| kind: 'file', |
| path, |
| name: `plot_${index + 1}.png`, |
| mimeType: 'image/png' |
| })) |
| }
|
|
|
|
|