Merge pull request #1035 from buster-so/dallin-bus-1868-disable-createreports-tool-from-creating-multiple-reports-at

dallin bus 1868 disable createreports tool from creating multiple reports at
This commit is contained in:
dal 2025-09-22 13:01:37 -06:00 committed by GitHub
commit d05fa10c62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 427 additions and 637 deletions

View File

@ -58,8 +58,8 @@ async function generateTitleWithLLM(messages: ModelMessage[]): Promise<string> {
const { object } = await generateObject({
model: Haiku35,
headers: {
'anthropic-beta': 'fine-grained-tool-streaming-2025-05-14,context-1m-2025-08-07',
anthropic_beta: 'fine-grained-tool-streaming-2025-05-14,context-1m-2025-08-07',
'anthropic-beta': 'fine-grained-tool-streaming-2025-05-14',
anthropic_beta: 'fine-grained-tool-streaming-2025-05-14',
},
schema: llmOutputSchema,
messages: titleMessages,

View File

@ -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,

View File

@ -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<unknown[]>(
// Extract name and content from parsed result
const name = getOptimisticValue<string>(parseResult.extractedValues, TOOL_KEYS.name, '');
const rawContent = getOptimisticValue<string>(
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<string, unknown>;
const name = getOptimisticValue<string>(
new Map(Object.entries(fileObj)),
TOOL_KEYS.name,
''
);
const rawContent = getOptimisticValue<string>(
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}`;
}
}
}

View File

@ -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
<metric metricId="24db2cc8-79b0-488f-bd45-8b5412d1bf08" />
Sales increased by 25%.
`,
},
],
name: 'Sales Report Q4',
content: `
# Sales Report Q4
<metric metricId="24db2cc8-79b0-488f-bd45-8b5412d1bf08" />
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: '<metric metricId="uuid-1" />',
},
{
name: 'Report Without Metrics',
content: 'Just text',
},
{
name: 'Another Report With Metrics',
content: '<metric metricId="uuid-2" />',
},
],
};
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: '<metric metricId="uuid-1" />',
},
],
name: 'Test Report',
content: '<metric metricId="uuid-1" />',
};
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: '<metric metricId="uuid-1" />',
},
{
name: 'Failed Report',
content: '<metric metricId="uuid-2" />',
},
],
name: 'Failed Report',
content: '<metric metricId="uuid-2" />',
};
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: '<metric metricId="uuid-1" />',
},
],
name: 'Test Report',
content: '<metric metricId="uuid-1" />',
};
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();
});
});

View File

@ -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<string>();
}
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<typeof updateMessageEntries>[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<typeof updateMessageEntries>[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;
}
},

View File

@ -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);

View File

@ -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<string>();
state.responseMessageCreated = false;
};
}

View File

@ -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

View File

@ -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<typeof CreateReportsInputSchema>;
export type CreateReportsOutput = z.infer<typeof CreateReportsOutputSchema>;
export type CreateReportsContext = z.infer<typeof CreateReportsContextSchema>;
export type CreateReportsOutputFile = z.infer<typeof CreateReportsOutputFileSchema>;
export type CreateReportsOutputFailedFile = z.infer<typeof CreateReportsOutputFailedFileSchema>;
export type CreateReportsState = z.infer<typeof CreateReportsStateSchema>;
export type CreateReportStateFile = z.infer<typeof CreateReportStateFileSchema>;
@ -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

View File

@ -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<string, ReasoningFile> as required by schema
const filesRecord: Record<string, ChatMessageReasoningMessage_File> = {};
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,
},
},
],