From 7638d1650f606c388db6321eef1276d11d385a69 Mon Sep 17 00:00:00 2001 From: dal Date: Mon, 22 Sep 2025 12:57:23 -0600 Subject: [PATCH] change report to be single entry --- .../helpers/done-tool-file-selection.ts | 98 ++++-- .../create-reports-delta.ts | 256 +++++++--------- .../create-reports-execute.test.ts | 244 +++++---------- .../create-reports-execute.ts | 280 +++++++----------- .../create-reports-finish.ts | 37 +-- .../create-reports-start.ts | 4 +- .../create-reports-tool-description.txt | 2 +- .../create-reports-tool.ts | 50 ++-- .../create-reports-tool-transform-helper.ts | 89 +++--- 9 files changed, 425 insertions(+), 635 deletions(-) diff --git a/packages/ai/src/tools/communication-tools/done-tool/helpers/done-tool-file-selection.ts b/packages/ai/src/tools/communication-tools/done-tool/helpers/done-tool-file-selection.ts index 46d1e81ea..e30d06d4d 100644 --- a/packages/ai/src/tools/communication-tools/done-tool/helpers/done-tool-file-selection.ts +++ b/packages/ai/src/tools/communication-tools/done-tool/helpers/done-tool-file-selection.ts @@ -94,13 +94,24 @@ export function extractAllFilesForChatUpdate(messages: ModelMessage[]): Extracte const contentObj = content as { toolCallId?: string; input?: unknown }; const toolCallId = contentObj.toolCallId; const input = contentObj.input as { + name?: string; + content?: string; + // Legacy support for old array structure files?: Array<{ yml_content?: string; content?: string }>; }; - if (toolCallId && input && input.files && Array.isArray(input.files)) { - for (const file of input.files) { - const reportContent = file.yml_content || file.content; - if (reportContent) { - createReportContents.set(toolCallId, reportContent); + + // Handle new single-file structure + if (toolCallId && input) { + if (input.content) { + // New structure: single report + createReportContents.set(toolCallId, input.content); + } else if (input.files && Array.isArray(input.files)) { + // Legacy structure: array of files + for (const file of input.files) { + const reportContent = file.yml_content || file.content; + if (reportContent) { + createReportContents.set(toolCallId, reportContent); + } } } } @@ -198,18 +209,32 @@ export function extractFilesFromToolCalls(messages: ModelMessage[]): ExtractedFi const contentObj = content as { toolCallId?: string; input?: unknown }; const toolCallId = contentObj.toolCallId; const input = contentObj.input as { + name?: string; + content?: string; + // Legacy support for old array structure files?: Array<{ yml_content?: string; content?: string }>; }; - if (toolCallId && input && input.files && Array.isArray(input.files)) { - for (const file of input.files) { - // Check for both yml_content and content fields - const reportContent = file.yml_content || file.content; - if (reportContent) { - createReportContents.set(toolCallId, reportContent); - console.info('[done-tool-file-selection] Stored report content for toolCallId', { - toolCallId, - contentLength: reportContent.length, - }); + + if (toolCallId && input) { + if (input.content) { + // New structure: single report + createReportContents.set(toolCallId, input.content); + console.info('[done-tool-file-selection] Stored report content for toolCallId', { + toolCallId, + contentLength: input.content.length, + }); + } else if (input.files && Array.isArray(input.files)) { + // Legacy structure: array of files + for (const file of input.files) { + // Check for both yml_content and content fields + const reportContent = file.yml_content || file.content; + if (reportContent) { + createReportContents.set(toolCallId, reportContent); + console.info('[done-tool-file-selection] Stored report content for toolCallId', { + toolCallId, + contentLength: reportContent.length, + }); + } } } } @@ -493,8 +518,47 @@ function processCreateReportsOutput( const reportsOutput = output as CreateReportsOutput; let reportInfo: ReportInfo | undefined; - if ('files' in reportsOutput && reportsOutput.files && Array.isArray(reportsOutput.files)) { - console.info('[done-tool-file-selection] Processing create report files array', { + // Handle new single-file structure + if ('file' in reportsOutput && reportsOutput.file && typeof reportsOutput.file === 'object') { + const file = reportsOutput.file; + console.info('[done-tool-file-selection] Processing create report single file', { + toolCallId, + hasContent: toolCallId && createReportContents ? createReportContents.has(toolCallId) : false, + }); + + const fileName = file.name; + if (file.id && fileName) { + // Get the content from the create report input using toolCallId + const content = + toolCallId && createReportContents ? createReportContents.get(toolCallId) : undefined; + + files.push({ + id: file.id, + fileType: 'report_file', + fileName: fileName, + status: 'completed', + operation: 'created', + versionNumber: file.version_number || 1, + content: content, // Store the content from the input + }); + + // Track this as the last report if we have content + if (content) { + reportInfo = { + id: file.id, + content: content, + versionNumber: file.version_number || 1, + operation: 'created', + }; + } + } + } else if ( + 'files' in reportsOutput && + reportsOutput.files && + Array.isArray(reportsOutput.files) + ) { + // Legacy support for array structure + console.info('[done-tool-file-selection] Processing create report files array (legacy)', { count: reportsOutput.files.length, toolCallId, hasContent: toolCallId && createReportContents ? createReportContents.has(toolCallId) : false, diff --git a/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-delta.ts b/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-delta.ts index 82f10ef29..05db58cb3 100644 --- a/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-delta.ts +++ b/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-delta.ts @@ -24,13 +24,11 @@ import { // Define TOOL_KEYS locally since we removed them from the helper const TOOL_KEYS = { - files: 'files' as const, name: 'name' as const, content: 'content' as const, } satisfies { - files: keyof CreateReportsInput; - name: keyof CreateReportsInput['files'][number]; - content: keyof CreateReportsInput['files'][number]; + name: keyof CreateReportsInput; + content: keyof CreateReportsInput; }; type VersionHistory = (typeof reportFiles.$inferSelect)['versionHistory']; @@ -57,124 +55,81 @@ export function createCreateReportsDelta(context: CreateReportsContext, state: C const parseResult = OptimisticJsonParser.parse(state.argsText || ''); if (parseResult.parsed) { - // Extract files array from parsed result - const filesArray = getOptimisticValue( + // Extract name and content from parsed result + const name = getOptimisticValue(parseResult.extractedValues, TOOL_KEYS.name, ''); + const rawContent = getOptimisticValue( parseResult.extractedValues, - TOOL_KEYS.files, - [] + TOOL_KEYS.content, + '' ); + // Unescape JSON string sequences, then normalize any double-escaped characters + const content = rawContent ? normalizeEscapedText(unescapeJsonString(rawContent)) : ''; - if (filesArray && Array.isArray(filesArray)) { - // Track which reports need to be created - const reportsToCreate: Array<{ - id: string; - name: string; - index: number; - }> = []; + // Only process if we have at least a name + if (name) { + // Check if report already exists in state to preserve its ID + const existingFile = state.file; - // Track which reports need content updates - const contentUpdates: Array<{ - reportId: string; - content: string; - }> = []; + let reportId: string; + let needsCreation = false; - // Update state files with streamed data - const updatedFiles: CreateReportStateFile[] = []; + if (existingFile?.id) { + // Report already exists, use its ID + reportId = existingFile.id; + } else { + // New report, generate ID and mark for creation + reportId = randomUUID(); + needsCreation = true; + } - filesArray.forEach((file, index) => { - if (file && typeof file === 'object') { - const fileObj = file as Record; - const name = getOptimisticValue( - new Map(Object.entries(fileObj)), - TOOL_KEYS.name, - '' - ); - const rawContent = getOptimisticValue( - new Map(Object.entries(fileObj)), - TOOL_KEYS.content, - '' - ); - // Unescape JSON string sequences, then normalize any double-escaped characters - const content = rawContent ? normalizeEscapedText(unescapeJsonString(rawContent)) : ''; - - // Only add files that have at least a name - if (name) { - // Check if this file already exists in state to preserve its ID - const existingFile = state.files?.[index]; - - let reportId: string; - - if (existingFile?.id) { - // Report already exists, use its ID - reportId = existingFile.id; - } else { - // New report, generate ID and mark for creation - reportId = randomUUID(); - reportsToCreate.push({ id: reportId, name, index }); + // Update state with the single report + state.file = { + id: reportId, + file_name: name, + file_type: 'report_file', + version_number: 1, + file: content + ? { + text: content, } + : undefined, + status: 'loading', + }; - updatedFiles.push({ - id: reportId, - file_name: name, - file_type: 'report_file', - version_number: 1, - file: content - ? { - text: content, - } - : undefined, - status: 'loading', - }); + // Track that we created/modified this report in this message + state.reportModifiedInMessage = true; - // Track that we created/modified this report in this message - if (!state.reportsModifiedInMessage) { - state.reportsModifiedInMessage = new Set(); - } - state.reportsModifiedInMessage.add(reportId); - - // If we have content and a report ID, update the content - if (content && reportId) { - contentUpdates.push({ reportId, content }); - } - } - } - }); - - state.files = updatedFiles; - - // Create new reports in the database if needed - if (reportsToCreate.length > 0 && context.userId && context.organizationId) { + // Create new report in the database if needed + if (needsCreation && context.userId && context.organizationId) { try { await db.transaction(async (tx) => { - // Insert report files - const reportRecords = reportsToCreate.map((report) => { - const now = new Date().toISOString(); - return { - id: report.id, - name: report.name, - content: '', // Start with empty content, will be updated via streaming - organizationId: context.organizationId, - createdBy: context.userId, - createdAt: now, - updatedAt: now, - deletedAt: null, - publiclyAccessible: false, - publiclyEnabledBy: null, - publicExpiryDate: null, - versionHistory: createInitialReportVersionHistory('', now), - publicPassword: null, - workspaceSharing: 'none' as const, - workspaceSharingEnabledBy: null, - workspaceSharingEnabledAt: null, - }; - }); - await tx.insert(reportFiles).values(reportRecords); + // Insert report file + const now = new Date().toISOString(); + const reportRecord = { + id: reportId, + name: name, + content: '', // Start with empty content, will be updated via streaming + organizationId: context.organizationId, + createdBy: context.userId, + createdAt: now, + updatedAt: now, + deletedAt: null, + publiclyAccessible: false, + publiclyEnabledBy: null, + publicExpiryDate: null, + versionHistory: createInitialReportVersionHistory('', now), + publicPassword: null, + workspaceSharing: 'none' as const, + workspaceSharingEnabledBy: null, + workspaceSharingEnabledAt: null, + }; + await tx.insert(reportFiles).values(reportRecord); - // Insert asset permissions - const assetPermissionRecords = reportRecords.map((record) => ({ + // Insert asset permission + const assetPermissionRecord = { identityId: context.userId, identityType: 'user' as const, - assetId: record.id, + assetId: reportId, assetType: 'report_file' as const, role: 'owner' as const, createdAt: new Date().toISOString(), @@ -182,70 +137,63 @@ export function createCreateReportsDelta(context: CreateReportsContext, state: C deletedAt: null, createdBy: context.userId, updatedBy: context.userId, - })); - await tx.insert(assetPermissions).values(assetPermissionRecords); + }; + await tx.insert(assetPermissions).values(assetPermissionRecord); }); - console.info('[create-reports] Created reports in database', { - count: reportsToCreate.length, - reportIds: reportsToCreate.map((r) => r.id), + console.info('[create-reports] Created report in database', { + reportId, + name, }); // Note: Response messages are only created in execute phase after checking for metrics } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Database creation failed'; - console.error('[create-reports] Error creating reports in database:', { + console.error('[create-reports] Error creating report in database:', { error: errorMessage, - reportCount: reportsToCreate.length, + reportId, stack: error instanceof Error ? error.stack : undefined, }); - // Mark all reports as failed with error message - reportsToCreate.forEach(({ id }) => { - const stateFile = state.files?.find((f) => f.id === id); - if (stateFile) { - stateFile.status = 'failed'; - stateFile.error = `Failed to create report in database: ${errorMessage}`; - } - }); + // Mark report as failed with error message + if (state.file) { + state.file.status = 'failed'; + state.file.error = `Failed to create report in database: ${errorMessage}`; + } } } - // Update report content for all reports that have content - if (contentUpdates.length > 0) { - for (const update of contentUpdates) { - try { - await updateReportContent({ - reportId: update.reportId, - content: update.content, - }); + // Update report content if we have content + if (content && reportId) { + try { + await updateReportContent({ + reportId: reportId, + content: content, + }); - // Keep the file status as 'loading' during streaming - // Status will be updated to 'completed' in the execute phase - const stateFile = state.files?.find((f) => f.id === update.reportId); - if (stateFile) { - // Ensure status remains 'loading' during delta phase - stateFile.status = 'loading'; - } + // Keep the file status as 'loading' during streaming + // Status will be updated to 'completed' in the execute phase + if (state.file) { + // Ensure status remains 'loading' during delta phase + state.file.status = 'loading'; + } - // Note: Response messages should only be created in execute phase - // after all processing is complete - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Content update failed'; - console.error('[create-reports] Error updating report content:', { - reportId: update.reportId, - error: errorMessage, - stack: error instanceof Error ? error.stack : undefined, - }); + // Note: Response messages should only be created in execute phase + // after all processing is complete + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Content update failed'; + console.error('[create-reports] Error updating report content:', { + reportId: reportId, + error: errorMessage, + stack: error instanceof Error ? error.stack : undefined, + }); - // Keep file as loading during delta phase even on error - // The execute phase will handle final status - const stateFile = state.files?.find((f) => f.id === update.reportId); - if (stateFile) { - stateFile.status = 'loading'; - stateFile.error = `Failed to update report content: ${errorMessage}`; - } + // Keep file as loading during delta phase even on error + // The execute phase will handle final status + if (state.file) { + state.file.status = 'loading'; + state.file.error = `Failed to update report content: ${errorMessage}`; } } } diff --git a/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-execute.test.ts b/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-execute.test.ts index e659901a9..f018625a0 100644 --- a/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-execute.test.ts +++ b/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-execute.test.ts @@ -55,35 +55,29 @@ describe('create-reports-execute', () => { state = { toolCallId: 'tool-call-123', - files: [], + file: undefined, startTime: Date.now(), }; }); describe('responseMessages creation', () => { - it('should add all reports to responseMessages', async () => { + it('should add report to responseMessages', async () => { // Setup state with a successful report - state.files = [ - { - id: 'report-1', - file_name: 'Sales Report Q4', - file_type: 'report_file', - version_number: 1, - status: 'completed', - }, - ]; + state.file = { + id: 'report-1', + file_name: 'Sales Report Q4', + file_type: 'report_file', + version_number: 1, + status: 'completed', + }; const input: CreateReportsInput = { - files: [ - { - name: 'Sales Report Q4', - content: ` - # Sales Report Q4 - - Sales increased by 25%. - `, - }, - ], + name: 'Sales Report Q4', + content: ` + # Sales Report Q4 + + Sales increased by 25%. + `, }; // Mock that the report contains metrics @@ -110,26 +104,20 @@ describe('create-reports-execute', () => { it('should add reports without metrics to responseMessages', async () => { // Setup state with a successful report - state.files = [ - { - id: 'report-2', - file_name: 'Simple Report', - file_type: 'report_file', - version_number: 1, - status: 'completed', - }, - ]; + state.file = { + id: 'report-2', + file_name: 'Simple Report', + file_type: 'report_file', + version_number: 1, + status: 'completed', + }; const input: CreateReportsInput = { - files: [ - { - name: 'Simple Report', - content: ` - # Simple Report - This report has no metrics, just text analysis. - `, - }, - ], + name: 'Simple Report', + content: ` + # Simple Report + This report has no metrics, just text analysis. + `, }; const execute = createCreateReportsExecute(context, state); @@ -152,78 +140,13 @@ describe('create-reports-execute', () => { }); }); - it('should handle multiple reports all in responseMessages', async () => { - // Setup state with multiple reports - state.files = [ - { - id: 'report-1', - file_name: 'Report With Metrics', - file_type: 'report_file', - version_number: 1, - status: 'completed', - }, - { - id: 'report-2', - file_name: 'Report Without Metrics', - file_type: 'report_file', - version_number: 1, - status: 'completed', - }, - { - id: 'report-3', - file_name: 'Another Report With Metrics', - file_type: 'report_file', - version_number: 1, - status: 'completed', - }, - ]; - - const input: CreateReportsInput = { - files: [ - { - name: 'Report With Metrics', - content: '', - }, - { - name: 'Report Without Metrics', - content: 'Just text', - }, - { - name: 'Another Report With Metrics', - content: '', - }, - ], - }; - - const execute = createCreateReportsExecute(context, state); - await execute(input); - - // Check that all reports were added to responseMessages - expect(mockUpdateMessageEntries).toHaveBeenCalled(); - // Get the last call (final entries) which should have responseMessages - const lastCallIndex = mockUpdateMessageEntries.mock.calls.length - 1; - const updateCall = mockUpdateMessageEntries.mock.calls[lastCallIndex]?.[0]; - - expect(updateCall?.responseMessages).toBeDefined(); - expect(updateCall?.responseMessages).toHaveLength(3); // All 3 reports - - const responseIds = updateCall?.responseMessages?.map((msg: any) => msg.id) || []; - expect(responseIds).toContain('report-1'); - expect(responseIds).toContain('report-3'); - expect(responseIds).toContain('report-2'); - }); - it('should create initial entries on first execution', async () => { state.initialEntriesCreated = undefined; - state.files = []; + state.file = undefined; const input: CreateReportsInput = { - files: [ - { - name: 'Test Report', - content: 'Test content', - }, - ], + name: 'Test Report', + content: 'Test content', }; const execute = createCreateReportsExecute(context, state); @@ -244,23 +167,17 @@ describe('create-reports-execute', () => { it('should not create initial entries if already created', async () => { state.initialEntriesCreated = true; - state.files = [ - { - id: 'report-1', - file_name: 'Test Report', - file_type: 'report_file', - version_number: 1, - status: 'completed', - }, - ]; + state.file = { + id: 'report-1', + file_name: 'Test Report', + file_type: 'report_file', + version_number: 1, + status: 'completed', + }; const input: CreateReportsInput = { - files: [ - { - name: 'Test Report', - content: '', - }, - ], + name: 'Test Report', + content: '', }; const execute = createCreateReportsExecute(context, state); @@ -270,84 +187,59 @@ describe('create-reports-execute', () => { expect(mockUpdateMessageEntries).toHaveBeenCalledTimes(1); }); - it('should handle reports with failed status', async () => { - // Simulate a scenario where one report was created during delta but another failed - // Report 1 has an ID (was created), Report 2 has no ID (creation failed) - state.files = [ - { - id: 'report-1', - file_name: 'Success Report', - file_type: 'report_file', - version_number: 1, - status: 'completed', - }, - { - id: '', // No ID means report creation failed during delta - file_name: 'Failed Report', - file_type: 'report_file', - version_number: 1, - status: 'failed', - error: 'Report creation failed during streaming', - }, - ]; + it('should handle report with failed status', async () => { + // Simulate a scenario where report creation failed during delta + state.file = { + id: '', // No ID means report creation failed during delta + file_name: 'Failed Report', + file_type: 'report_file', + version_number: 1, + status: 'failed', + error: 'Report creation failed during streaming', + }; const input: CreateReportsInput = { - files: [ - { - name: 'Success Report', - content: '', - }, - { - name: 'Failed Report', - content: '', - }, - ], + name: 'Failed Report', + content: '', }; const execute = createCreateReportsExecute(context, state); const result = await execute(input); - // Result should show one success and one failure - // Report 1 succeeds because it has an ID, Report 2 fails because it has no ID - expect(result.files).toHaveLength(1); - expect(result.failed_files).toHaveLength(1); + // Result should show failure + expect(result.file).toBeUndefined(); + expect(result.error).toBeDefined(); + expect(result.error).toContain('Report creation failed during streaming'); - // Only the successful report should be in responseMessages - // Get the last call (final entries) which should have responseMessages + // No responseMessages should be created for failed report + // Get the last call (final entries) const lastCallIndex = mockUpdateMessageEntries.mock.calls.length - 1; const updateCall = mockUpdateMessageEntries.mock.calls[lastCallIndex]?.[0]; - expect(updateCall?.responseMessages).toHaveLength(1); - expect(updateCall?.responseMessages?.[0]?.id).toBe('report-1'); + expect(updateCall?.responseMessages).toBeUndefined(); }); it('should handle missing messageId in context', async () => { // Remove messageId from context context.messageId = undefined; - state.files = [ - { - id: 'report-1', - file_name: 'Test Report', - file_type: 'report_file', - version_number: 1, - status: 'completed', - }, - ]; + state.file = { + id: 'report-1', + file_name: 'Test Report', + file_type: 'report_file', + version_number: 1, + status: 'completed', + }; const input: CreateReportsInput = { - files: [ - { - name: 'Test Report', - content: '', - }, - ], + name: 'Test Report', + content: '', }; const execute = createCreateReportsExecute(context, state); const result = await execute(input); // Should complete successfully but not call updateMessageEntries - expect(result.files).toHaveLength(1); + expect(result.file).toBeDefined(); expect(mockUpdateMessageEntries).not.toHaveBeenCalled(); }); }); diff --git a/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-execute.ts b/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-execute.ts index b872434d0..afd0caf1a 100644 --- a/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-execute.ts +++ b/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-execute.ts @@ -24,7 +24,7 @@ import { createCreateReportsReasoningEntry, } from './helpers/create-reports-tool-transform-helper'; -// Main create report files function - returns success status +// Main create report function - returns success status const getReportCreationResults = wrapTraced( async ( params: CreateReportsInput, @@ -38,53 +38,37 @@ const getReportCreationResults = wrapTraced( if (!userId) { return { message: 'Unable to verify your identity. Please log in again.', - files: [], - failed_files: [], + error: 'Authentication error', }; } if (!organizationId) { return { message: 'Unable to access your organization. Please check your permissions.', - files: [], - failed_files: [], + error: 'Authorization error', }; } - // Reports have already been created and updated in the delta function + // Report has already been created and updated in the delta function // Here we just return the final status - const files = state?.files || []; - const successfulFiles = files.filter((f) => f.id && f.file_name && f.status === 'completed'); - const failedFiles: Array<{ name: string; error: string }> = []; + const file = state?.file; - // Check for any files that weren't successfully created - params.files.forEach((inputFile, index) => { - const stateFile = state?.files?.[index]; - if (!stateFile || !stateFile.id || stateFile.status === 'failed') { - failedFiles.push({ - name: inputFile.name, - error: stateFile?.error || 'Failed to create report', - }); - } - }); + if (!file || !file.id || file.status === 'failed') { + return { + message: `Failed to create report: ${file?.error || 'Unknown error'}`, + error: file?.error || 'Failed to create report', + }; + } // Generate result message - let message: string; - if (failedFiles.length === 0) { - message = `Successfully created ${successfulFiles.length} report file${successfulFiles.length !== 1 ? 's' : ''}.`; - } else if (successfulFiles.length === 0) { - message = `Failed to create all report files.`; - } else { - message = `Successfully created ${successfulFiles.length} report file${successfulFiles.length !== 1 ? 's' : ''}. Failed to create ${failedFiles.length} file${failedFiles.length !== 1 ? 's' : ''}.`; - } + const message = `Successfully created report file.`; return { message, - files: successfulFiles.map((f) => ({ - id: f.id, - name: f.file_name || '', - version_number: f.version_number, - })), - failed_files: failedFiles, + file: { + id: file.id, + name: file.file_name || params.name, + version_number: file.version_number, + }, }; }, { name: 'Get Report Creation Results' } @@ -123,38 +107,32 @@ export function createCreateReportsExecute( } } - // Ensure all reports that were created during delta have complete content from input + // Ensure the report that was created during delta has complete content from input // IMPORTANT: The input is the source of truth for content, not any streaming updates - // Delta phase creates reports with empty/partial content, execute phase ensures complete content - console.info('[create-reports] Ensuring all reports have complete content from input'); + // Delta phase creates report with empty/partial content, execute phase ensures complete content + console.info('[create-reports] Ensuring report has complete content from input'); - for (let i = 0; i < input.files.length; i++) { - const inputFile = input.files[i]; - if (!inputFile) continue; + const { name, content } = input; - const { name, content } = inputFile; + // Only update report that was successfully created during delta phase + const reportId = state.file?.id; - // Only update reports that were successfully created during delta phase - const reportId = state.files?.[i]?.id; - - if (!reportId) { - // Report wasn't created during delta - mark as failed - console.warn('[create-reports] Report was not created during delta phase', { name }); - - if (!state.files) { - state.files = []; - } - state.files[i] = { - id: '', - file_name: name, - file_type: 'report_file', - version_number: 1, - status: 'failed', - error: 'Report creation failed during streaming', - }; - continue; - } + if (!reportId) { + // Report wasn't created during delta - mark as failed + console.warn('[create-reports] Report was not created during delta phase', { name }); + state.file = { + id: '', + file_name: name, + file_type: 'report_file', + version_number: 1, + status: 'failed', + error: 'Report creation failed during streaming', + file: { + text: content, + }, + }; + } else { try { // Create initial version history for the report const now = new Date().toISOString(); @@ -217,23 +195,17 @@ export function createCreateReportsExecute( } // Update state to reflect successful update - if (!state.files) { - state.files = []; - } - if (!state.files[i]) { - state.files[i] = { - id: reportId, - file_name: name, - file_type: 'report_file', - version_number: 1, - status: 'completed', - }; - } else { - const stateFile = state.files[i]; - if (stateFile) { - stateFile.status = 'completed'; - } - } + // Include content so rawLlmMessage can be created properly + state.file = { + id: reportId, + file_name: name, + file_type: 'report_file', + version_number: 1, + status: 'completed', + file: { + text: content, + }, + }; console.info('[create-reports] Successfully updated report with complete content', { reportId, @@ -249,100 +221,66 @@ export function createCreateReportsExecute( }); // Update state to reflect failure - if (!state.files) { - state.files = []; - } - if (!state.files[i]) { - state.files[i] = { - id: reportId, - file_name: name, - file_type: 'report_file', - version_number: 1, - status: 'failed', - error: errorMessage, - }; - } else { - const stateFile = state.files[i]; - if (stateFile) { - stateFile.status = 'failed'; - stateFile.error = errorMessage; - } - } + state.file = { + id: reportId, + file_name: name, + file_type: 'report_file', + version_number: 1, + status: 'failed', + error: errorMessage, + file: { + text: content, + }, + }; } } // Get the results (after ensuring all reports are properly created) const result = await getReportCreationResults(input, context, state); - // Update state files with final results + // Update state file with final results if (result && typeof result === 'object') { const typedResult = result as CreateReportsOutput; - // Ensure state.files is initialized for safe mutations below - state.files = state.files ?? []; - // Mark any remaining files as completed/failed based on result - if (state.files) { - state.files.forEach((stateFile) => { - if (stateFile.status === 'loading') { - // Check if this file is in the success list - const isSuccess = typedResult.files?.some((f) => f.id === stateFile.id); - stateFile.status = isSuccess ? 'completed' : 'failed'; - } - }); + // Mark file as completed/failed based on result + if (state.file && state.file.status === 'loading') { + state.file.status = typedResult.file ? 'completed' : 'failed'; } // Update last entries if we have a messageId if (context.messageId) { try { - const finalStatus = typedResult.failed_files?.length ? 'failed' : 'completed'; + const finalStatus = typedResult.error ? 'failed' : 'completed'; const toolCallId = state.toolCallId || `tool-${Date.now()}`; // Update state for final status - if (state.files) { - state.files.forEach((f) => { - if (!f.status || f.status === 'loading') { - f.status = finalStatus === 'failed' ? 'failed' : 'completed'; - } - }); + if (state.file && (!state.file.status || state.file.status === 'loading')) { + state.file.status = finalStatus; } - // Check which reports contain metrics to determine if they should be in responseMessages + // Check if report should be in responseMessages const responseMessages: ChatMessageResponseMessage[] = []; - // Check each report file for metrics - if (state.files && typedResult.files) { - for (const resultFile of typedResult.files) { - // Skip if response message was already created during delta - if (state.responseMessagesCreated?.has(resultFile.id)) { - continue; - } + // Check if report file exists and hasn't been added to response + if (state.file && typedResult.file && !state.responseMessageCreated) { + responseMessages.push({ + id: state.file.id, + type: 'file' as const, + file_type: 'report_file' as const, + file_name: state.file.file_name || typedResult.file.name, + version_number: state.file.version_number || 1, + filter_version_id: null, + metadata: [ + { + status: 'completed' as const, + message: 'Report created successfully', + timestamp: Date.now(), + }, + ], + }); - // Find the corresponding state file - const stateFile = state.files.find((f) => f.id === resultFile.id); - if (stateFile) { - responseMessages.push({ - id: stateFile.id, - type: 'file' as const, - file_type: 'report_file' as const, - file_name: stateFile.file_name || resultFile.name, - version_number: stateFile.version_number || 1, - filter_version_id: null, - metadata: [ - { - status: 'completed' as const, - message: 'Report created successfully', - timestamp: Date.now(), - }, - ], - }); - - // Track that we've created a response message for this report - if (!state.responseMessagesCreated) { - state.responseMessagesCreated = new Set(); - } - state.responseMessagesCreated.add(resultFile.id); - } - } + // Track that we've created a response message for this report + state.responseMessageCreated = true; } const reasoningEntry = createCreateReportsReasoningEntry(state, toolCallId); @@ -350,9 +288,7 @@ export function createCreateReportsExecute( const rawLlmResultEntry = createRawToolResultEntry( toolCallId, CREATE_REPORTS_TOOL_NAME, - { - files: state.files, - } + typedResult ); const updates: Parameters[0] = { @@ -378,8 +314,8 @@ export function createCreateReportsExecute( console.info('[create-reports] Updated last entries with final results', { messageId: context.messageId, - successCount: typedResult.files?.length || 0, - failedCount: typedResult.failed_files?.length || 0, + success: !!typedResult.file, + failed: !!typedResult.error, reportsWithMetrics: responseMessages.length, }); } catch (error) { @@ -392,8 +328,8 @@ export function createCreateReportsExecute( const executionTime = Date.now() - startTime; console.info('[create-reports] Execution completed', { executionTime: `${executionTime}ms`, - filesCreated: result?.files?.length || 0, - filesFailed: result?.failed_files?.length || 0, + fileCreated: !!result?.file, + failed: !!result?.error, }); return result as CreateReportsOutput; @@ -418,22 +354,22 @@ export function createCreateReportsExecute( if (context.messageId) { try { const toolCallId = state.toolCallId || `tool-${Date.now()}`; - // Update state files to failed status with error message - if (state.files) { - state.files.forEach((f) => { - f.status = 'failed'; - f.error = f.error || errorMessage; - }); + // Update state file to failed status with error message + if (state.file) { + state.file.status = 'failed'; + state.file.error = state.file.error || errorMessage; } const reasoningEntry = createCreateReportsReasoningEntry(state, toolCallId); const rawLlmMessage = createCreateReportsRawLlmMessageEntry(state, toolCallId); + const errorResult: CreateReportsOutput = { + message: `Failed to create report: ${errorMessage}`, + error: errorMessage, + }; const rawLlmResultEntry = createRawToolResultEntry( toolCallId, CREATE_REPORTS_TOOL_NAME, - { - files: state.files, - } + errorResult ); const updates: Parameters[0] = { @@ -463,19 +399,9 @@ export function createCreateReportsExecute( } // Return error information to the agent - const failedFiles: Array<{ name: string; error: string }> = []; - input.files.forEach((inputFile, index) => { - const stateFile = state.files?.[index]; - failedFiles.push({ - name: inputFile.name, - error: stateFile?.error || errorMessage, - }); - }); - return { - message: `Failed to create reports: ${errorMessage}`, - files: [], - failed_files: failedFiles, + message: `Failed to create report: ${errorMessage}`, + error: state.file?.error || errorMessage, } as CreateReportsOutput; } }, diff --git a/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-finish.ts b/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-finish.ts index a27c60c1f..19dfba8ee 100644 --- a/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-finish.ts +++ b/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-finish.ts @@ -18,27 +18,22 @@ export function createCreateReportsFinish( return async (options: { input: CreateReportsInput } & ToolCallOptions) => { const input = options.input; - // Process final input - if (input.files) { - // Initialize state files if needed - if (!state.files) { - state.files = []; - } + // Process final input for single report + if (input.name && input.content) { + // Initialize state file if needed + const existingFile = state.file; - // Set final state for all files - state.files = input.files.map((file, index) => { - const existingFile = state.files?.[index]; - return { - id: existingFile?.id || randomUUID(), - file_name: file.name, - file_type: 'report_file', - version_number: existingFile?.version_number || 1, - file: { - text: file.content, - }, - status: existingFile?.status || 'loading', - }; - }); + // Set final state for the single report + state.file = { + id: existingFile?.id || randomUUID(), + file_name: input.name, + file_type: 'report_file', + version_number: existingFile?.version_number || 1, + file: { + text: input.content, + }, + status: existingFile?.status || 'loading', + }; } // Update database with final state @@ -65,7 +60,7 @@ export function createCreateReportsFinish( console.info('[create-reports] Finished input processing', { messageId: context.messageId, - fileCount: state.files?.length || 0, + reportCreated: !!state.file, }); } catch (error) { console.error('[create-reports] Error updating entries on finish:', error); diff --git a/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-start.ts b/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-start.ts index 04feb3056..294b3c023 100644 --- a/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-start.ts +++ b/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-start.ts @@ -6,8 +6,8 @@ export function createReportsStart(_context: CreateReportsContext, state: Create // Reset state for new tool call to prevent contamination from previous calls state.toolCallId = options.toolCallId; state.argsText = undefined; - state.files = []; + state.file = undefined; state.startTime = Date.now(); - state.responseMessagesCreated = new Set(); + state.responseMessageCreated = false; }; } diff --git a/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-tool-description.txt b/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-tool-description.txt index 8311920db..c976b7e4d 100644 --- a/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-tool-description.txt +++ b/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-tool-description.txt @@ -1,4 +1,4 @@ -Creates report files with markdown content. Reports are used to document findings, analysis results, and insights in a structured markdown format. **This tool supports creating multiple reports in a single call; prefer using bulk creation over creating reports one by one.** +Creates a report file with markdown content. Reports are used to document findings, analysis results, and insights in a structured markdown format. **This tool creates one report per call.** # REPORT TYPES & FLOWS diff --git a/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-tool.ts b/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-tool.ts index d4e2e82df..69ed17c1f 100644 --- a/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-tool.ts +++ b/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/create-reports-tool.ts @@ -9,7 +9,8 @@ import CREATE_REPORTS_TOOL_DESCRIPTION from './create-reports-tool-description.t export const CREATE_REPORTS_TOOL_NAME = 'createReports'; -const CreateReportsInputFileSchema = z.object({ +// Input schema for the create reports tool - now accepts a single report +const CreateReportsInputSchema = z.object({ name: z .string() .describe( @@ -18,36 +19,21 @@ const CreateReportsInputFileSchema = z.object({ content: z .string() .describe( - 'The markdown content for the report. Should be well-structured with headers, sections, and clear analysis. Multiple reports can be created in one call by providing multiple entries in the files array. **Prefer creating reports in bulk.**' + 'The markdown content for the report. Should be well-structured with headers, sections, and clear analysis.' ), }); -// Input schema for the create reports tool -const CreateReportsInputSchema = z.object({ - files: z - .array(CreateReportsInputFileSchema) - .min(1) - .describe( - 'List of report file parameters to create. Each report should contain comprehensive markdown content with analysis, findings, and recommendations.' - ), -}); - -const CreateReportsOutputFileSchema = z.object({ - id: z.string(), - name: z.string(), - version_number: z.number(), -}); - -const CreateReportsOutputFailedFileSchema = z.object({ - name: z.string(), - error: z.string(), -}); - -// Output schema for the create reports tool +// Output schema for the create reports tool - now returns a single report or error const CreateReportsOutputSchema = z.object({ message: z.string(), - files: z.array(CreateReportsOutputFileSchema), - failed_files: z.array(CreateReportsOutputFailedFileSchema), + file: z + .object({ + id: z.string(), + name: z.string(), + version_number: z.number(), + }) + .optional(), + error: z.string().optional(), }); // Context schema for the create reports tool @@ -75,19 +61,17 @@ const CreateReportStateFileSchema = z.object({ const CreateReportsStateSchema = z.object({ toolCallId: z.string().optional(), argsText: z.string().optional(), - files: z.array(CreateReportStateFileSchema).optional(), + file: CreateReportStateFileSchema.optional(), // Changed from array to single file startTime: z.number().optional(), initialEntriesCreated: z.boolean().optional(), - responseMessagesCreated: z.set(z.string()).optional(), - reportsModifiedInMessage: z.set(z.string()).optional(), + responseMessageCreated: z.boolean().optional(), // Changed from set to boolean + reportModifiedInMessage: z.boolean().optional(), // Changed from set to boolean }); // Export types export type CreateReportsInput = z.infer; export type CreateReportsOutput = z.infer; export type CreateReportsContext = z.infer; -export type CreateReportsOutputFile = z.infer; -export type CreateReportsOutputFailedFile = z.infer; export type CreateReportsState = z.infer; export type CreateReportStateFile = z.infer; @@ -96,9 +80,9 @@ export function createCreateReportsTool(context: CreateReportsContext) { // Initialize state for streaming const state: CreateReportsState = { argsText: undefined, - files: [], + file: undefined, toolCallId: undefined, - reportsModifiedInMessage: new Set(), + reportModifiedInMessage: false, }; // Create all functions with the context and state passed diff --git a/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/helpers/create-reports-tool-transform-helper.ts b/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/helpers/create-reports-tool-transform-helper.ts index b76a25047..2fb120a8e 100644 --- a/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/helpers/create-reports-tool-transform-helper.ts +++ b/packages/ai/src/tools/visualization-tools/reports/create-reports-tool/helpers/create-reports-tool-transform-helper.ts @@ -19,61 +19,44 @@ export function createCreateReportsReasoningEntry( ): ChatMessageReasoningMessage | undefined { state.toolCallId = toolCallId; - if (!state.files || state.files.length === 0) { + if (!state.file || !state.file.file_name) { return undefined; } // Build Record as required by schema const filesRecord: Record = {}; const fileIds: string[] = []; - for (const f of state.files) { - // Skip undefined entries or entries that do not yet have a file_name - if (!f || !f.file_name) continue; - // Type assertion to ensure proper typing - const file = f as CreateReportStateFile; - const id = file.id; - fileIds.push(id); - filesRecord[id] = { - id, - file_type: 'report_file', - file_name: file.file_name ?? '', - version_number: file.version_number, - status: file.status, - file: { - text: file.file?.text || '', - }, - }; - } - - // If nothing valid to show yet, skip emitting a files reasoning message - if (fileIds.length === 0) return undefined; + // Type assertion to ensure proper typing + const file = state.file as CreateReportStateFile; + const id = file.id; + fileIds.push(id); + filesRecord[id] = { + id, + file_type: 'report_file', + file_name: file.file_name ?? '', + version_number: file.version_number, + status: file.status, + file: { + text: file.file?.text || '', + }, + }; // Calculate title and status based on completion state - let title = 'Creating reports...'; + let title = 'Creating report...'; let status: 'loading' | 'completed' | 'failed' = 'loading'; - // Check if all files have been processed (state has completion status) - const completedFiles = state.files.filter((f) => f?.status === 'completed').length; - const failedFiles = state.files.filter((f) => f?.status === 'failed').length; - const totalFiles = state.files.length; - - // If all files have a final status, we're complete - const isComplete = completedFiles + failedFiles === totalFiles && totalFiles > 0; - if (isComplete) { - if (failedFiles === 0) { - title = `Created ${completedFiles} ${completedFiles === 1 ? 'report_file' : 'reports'}`; - status = 'completed'; - } else if (completedFiles === 0) { - title = `Failed to create ${failedFiles} ${failedFiles === 1 ? 'report_file' : 'reports'}`; - status = 'failed'; - } else { - title = `Created ${completedFiles} of ${totalFiles} reports`; - status = 'failed'; // Partial success is considered failed - } + // Check if file has been processed (state has completion status) + if (state.file.status === 'completed') { + title = 'Created report_file'; + status = 'completed'; + } else if (state.file.status === 'failed') { + title = 'Failed to create report_file'; + status = 'failed'; } // Calculate elapsed time if complete + const isComplete = state.file.status === 'completed' || state.file.status === 'failed'; const secondaryTitle = isComplete ? formatElapsedTime(state.startTime) : undefined; return { @@ -94,8 +77,15 @@ export function createCreateReportsRawLlmMessageEntry( state: CreateReportsState, toolCallId: string ): ModelMessage | undefined { - // If we don't have files yet, skip emitting raw LLM entry - if (!state.files || state.files.length === 0) return undefined; + // If we don't have a file yet, skip emitting raw LLM entry + if (!state.file) return undefined; + + const typedFile = state.file as CreateReportStateFile; + + // Only emit if we have valid name and content + if (!typedFile.file_name || !typedFile.file?.text) { + return undefined; + } return { role: 'assistant', @@ -105,17 +95,8 @@ export function createCreateReportsRawLlmMessageEntry( toolCallId, toolName: CREATE_REPORTS_TOOL_NAME, input: { - files: state.files - .filter((file) => file != null) // Filter out null/undefined entries first - .map((file) => { - const typedFile = file as CreateReportStateFile; - return { - name: typedFile.file_name ?? '', - yml_content: typedFile.file?.text ?? '', - }; - }) - // Filter out clearly invalid entries - .filter((f) => f.name && f.yml_content), + name: typedFile.file_name, + content: typedFile.file.text, }, }, ],