From 7e6e006fbc04d00477f4dc36699e274e367a3d4f Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Thu, 19 Mar 2026 07:13:20 +0800 Subject: [PATCH 1/2] refactor: use discriminated union for all multi-operation agent tool args Replace flat z.object schemas with z.discriminatedUnion('operation', [...]) across all 9 multi-operation agent tools: docx, excel, pdf, text, pptx, product_read, customer_read, workflow_read, and database_schema. This provides better type safety via TypeScript narrowing, enforces required fields per operation at the schema level, and removes redundant runtime validation checks. --- .../customers/customer_read_tool.ts | 99 +++++------ .../database/database_schema_tool.ts | 30 ++-- .../convex/agent_tools/files/docx_tool.ts | 153 ++++++++--------- .../convex/agent_tools/files/excel_tool.ts | 109 +++++-------- .../convex/agent_tools/files/pdf_tool.ts | 154 +++++++----------- .../convex/agent_tools/files/pptx_tool.ts | 123 ++++++-------- .../convex/agent_tools/files/text_tool.ts | 124 ++++---------- .../agent_tools/products/product_read_tool.ts | 104 ++++++------ .../workflows/workflow_read_tool.ts | 116 +++++-------- 9 files changed, 392 insertions(+), 620 deletions(-) diff --git a/services/platform/convex/agent_tools/customers/customer_read_tool.ts b/services/platform/convex/agent_tools/customers/customer_read_tool.ts index 6f9abe1aa6..ca12759572 100644 --- a/services/platform/convex/agent_tools/customers/customer_read_tool.ts +++ b/services/platform/convex/agent_tools/customers/customer_read_tool.ts @@ -27,50 +27,51 @@ import { readCustomerByEmail } from './helpers/read_customer_by_email'; import { readCustomerById } from './helpers/read_customer_by_id'; import { readCustomerList } from './helpers/read_customer_list'; -// Use a flat object schema instead of discriminatedUnion to ensure OpenAI-compatible JSON Schema -// (discriminatedUnion produces anyOf/oneOf which some providers reject as "type: None") -const customerReadArgs = z.object({ - operation: z - .enum(['get_by_id', 'get_by_email', 'list', 'count']) - .describe( - "Operation to perform: 'get_by_id' (fetch by ID), 'get_by_email' (fetch by email), 'list' (paginate all), or 'count' (count total customers)", - ), - // For get_by_id operation - customerId: z - .string() - .optional() - .describe( - 'Required for \'get_by_id\': Convex Id<"customers"> (string format) for the target customer', - ), - // For get_by_email operation - email: z - .string() - .optional() - .describe( - "Required for 'get_by_email': Customer email address to search for", - ), - // Common fields for all operations - fields: z - .array(z.string()) - .optional() - .describe( - "Optional list of fields to return. Default: ['_id','name','email','status','source','locale']", - ), - // For list operation - cursor: z - .string() - .nullable() - .optional() - .describe( - "For 'list' operation: Pagination cursor from previous response, or null/omitted for first page", - ), - numItems: z - .number() - .optional() - .describe( - "For 'list' operation: Number of items per page (default: 200). Fewer fields = more items allowed.", - ), -}); +const customerReadArgs = z.discriminatedUnion('operation', [ + z.object({ + operation: z.literal('get_by_id'), + customerId: z + .string() + .describe( + 'Convex Id<"customers"> (string format) for the target customer', + ), + fields: z + .array(z.string()) + .optional() + .describe( + "Fields to return. Default: ['_id','name','email','status','source','locale']", + ), + }), + z.object({ + operation: z.literal('get_by_email'), + email: z.string().describe('Customer email address to search for'), + fields: z + .array(z.string()) + .optional() + .describe( + "Fields to return. Default: ['_id','name','email','status','source','locale']", + ), + }), + z.object({ + operation: z.literal('list'), + cursor: z + .string() + .nullable() + .optional() + .describe( + 'Pagination cursor from previous response, or null/omitted for first page', + ), + numItems: z + .number() + .optional() + .describe( + 'Number of items per page (default: 200). Fewer fields = more items allowed.', + ), + }), + z.object({ + operation: z.literal('count'), + }), +]); export const customerReadTool: ToolDefinition = { name: 'customer_read', @@ -126,11 +127,6 @@ BEST PRACTICES: | CustomerReadCountResult > => { if (args.operation === 'get_by_id') { - if (!args.customerId) { - throw new Error( - "Missing required 'customerId' for get_by_id operation", - ); - } return readCustomerById(ctx, { customerId: args.customerId, fields: args.fields, @@ -138,11 +134,6 @@ BEST PRACTICES: } if (args.operation === 'get_by_email') { - if (!args.email) { - throw new Error( - "Missing required 'email' for get_by_email operation", - ); - } return readCustomerByEmail(ctx, { email: args.email, fields: args.fields, diff --git a/services/platform/convex/agent_tools/database/database_schema_tool.ts b/services/platform/convex/agent_tools/database/database_schema_tool.ts index 9c58f94ac2..810be188ec 100644 --- a/services/platform/convex/agent_tools/database/database_schema_tool.ts +++ b/services/platform/convex/agent_tools/database/database_schema_tool.ts @@ -24,19 +24,17 @@ import { type DatabaseSchemaGetTableResult, } from './helpers/types'; -const databaseSchemaArgs = z.object({ - operation: z - .enum(['list_tables', 'get_table_schema']) - .describe( - "Operation: 'list_tables' to see all tables, 'get_table_schema' to get fields for a specific table", - ), - tableName: z - .string() - .optional() - .describe( - "Required for 'get_table_schema': table name (e.g., 'conversations', 'customers', 'approvals')", - ), -}); +const databaseSchemaArgs = z.discriminatedUnion('operation', [ + z.object({ + operation: z.literal('list_tables'), + }), + z.object({ + operation: z.literal('get_table_schema'), + tableName: z + .string() + .describe("Table name (e.g., 'conversations', 'customers', 'approvals')"), + }), +]); export const databaseSchemaTool: ToolDefinition = { name: 'database_schema', @@ -92,12 +90,6 @@ FILTER EXPRESSION EXAMPLES: } // operation === 'get_table_schema' - if (!args.tableName) { - throw new Error( - "Missing required 'tableName' for get_table_schema operation", - ); - } - const schema = getTableSchema(args.tableName); if (!schema) { const availableTables = getSupportedTables() diff --git a/services/platform/convex/agent_tools/files/docx_tool.ts b/services/platform/convex/agent_tools/files/docx_tool.ts index c33a0be449..0a63197950 100644 --- a/services/platform/convex/agent_tools/files/docx_tool.ts +++ b/services/platform/convex/agent_tools/files/docx_tool.ts @@ -82,74 +82,68 @@ const sectionSchema = z.object({ .describe('Table rows (2D array)'), }); -const docxArgs = z.object({ - operation: z - .enum(['list_templates', 'generate', 'parse']) - .optional() - .describe( - "Operation to perform: 'list_templates', 'generate' (default), or 'parse' (extract text from DOCX).", - ), - // For list_templates operation - limit: z - .number() - .optional() - .describe( - "For 'list_templates': Maximum number of DOCX documents/templates to return (default: 50)", - ), - // For generate operation (optional template support) - templateStorageId: z - .string() - .optional() - .describe( - 'Convex storage ID of a DOCX template. When provided, the template is used as base, preserving headers, footers, fonts, and page setup.', - ), - fileName: z - .string() - .optional() - .describe( - "For 'generate': Base name for the DOCX file (without extension). Required for generate.", - ), - title: z.string().optional().describe('Document title'), - subtitle: z.string().optional().describe('Document subtitle'), - sections: z - .array(sectionSchema) - .optional() - .describe( - "For 'generate': Content sections. Each section can be a heading, paragraph, bullets, numbered list, table, quote, or code block.", - ), - // For generate from markdown/html (same content as PDF tool) - sourceType: z - .enum(['markdown', 'html']) - .optional() - .describe( - "For 'generate': Source type when generating from markdown or HTML content instead of sections. Use this to quickly convert existing markdown/HTML to DOCX.", - ), - content: z - .string() - .optional() - .describe( - "For 'generate': Markdown or HTML text content. Use with sourceType. This is the fastest way to generate DOCX from the same content used for PDF generation.", - ), - // For parse operation - fileId: z - .string() - .optional() - .describe( - "For 'parse': **REQUIRED** - Convex storage ID (e.g., 'kg2bazp7fbgt9srq63knfagjrd7yfenj'). Get this from the file attachment context.", - ), - filename: z - .string() - .optional() - .describe( - "For 'parse': Original filename (e.g., 'document.docx'). Optional — auto-resolved from file metadata if omitted.", - ), - user_input: z - .string() - .optional() - .describe( - "For 'parse': **REQUIRED** - The user's question or instruction about the document content", - ), -}); +const docxArgs = z.discriminatedUnion('operation', [ + z.object({ + operation: z.literal('list_templates'), + limit: z + .number() + .optional() + .describe( + 'Maximum number of DOCX documents/templates to return (default: 50)', + ), + }), + z.object({ + operation: z.literal('generate'), + templateStorageId: z + .string() + .optional() + .describe( + 'Convex storage ID of a DOCX template. When provided, the template is used as base, preserving headers, footers, fonts, and page setup.', + ), + fileName: z + .string() + .describe('Base name for the DOCX file (without extension)'), + title: z.string().optional().describe('Document title'), + subtitle: z.string().optional().describe('Document subtitle'), + sections: z + .array(sectionSchema) + .optional() + .describe( + 'Content sections. Each section can be a heading, paragraph, bullets, numbered list, table, quote, or code block.', + ), + sourceType: z + .enum(['markdown', 'html']) + .optional() + .describe( + 'Source type when generating from markdown or HTML content instead of sections. Use this to quickly convert existing markdown/HTML to DOCX.', + ), + content: z + .string() + .optional() + .describe( + 'Markdown or HTML text content. Use with sourceType. This is the fastest way to generate DOCX from the same content used for PDF generation.', + ), + }), + z.object({ + operation: z.literal('parse'), + fileId: z + .string() + .describe( + "Convex storage ID (e.g., 'kg2bazp7fbgt9srq63knfagjrd7yfenj'). Get this from the file attachment context.", + ), + filename: z + .string() + .optional() + .describe( + "Original filename (e.g., 'document.docx'). Optional — auto-resolved from file metadata if omitted.", + ), + user_input: z + .string() + .describe( + "The user's question or instruction about the document content", + ), + }), +]); export const docxTool = { name: 'docx' as const, @@ -204,10 +198,8 @@ AFTER GENERATING: The file automatically appears as a download card in the chat. args: docxArgs, handler: async (ctx: ToolCtx, args): Promise => { const { organizationId } = ctx; - const operation = args.operation ?? 'generate'; - // Handle list_templates operation - if (operation === 'list_templates') { + if (args.operation === 'list_templates') { if (!organizationId) { return { operation: 'list_templates', @@ -267,19 +259,7 @@ AFTER GENERATING: The file automatically appears as a download card in the chat. } } - // Handle parse operation - if (operation === 'parse') { - if (!args.fileId) { - throw new Error( - "Missing required 'fileId' for parse operation. Get the fileId from the file attachment context.", - ); - } - if (!args.user_input) { - throw new Error( - "Missing required 'user_input' for parse operation. Provide the user's question or instruction about the document.", - ); - } - + if (args.operation === 'parse') { const model = getAgentModelId(ctx); const result = await parseFile( ctx, @@ -292,13 +272,10 @@ AFTER GENERATING: The file automatically appears as a download card in the chat. return { operation: 'parse', ...result }; } - // Default / generate operation + // operation === 'generate' if (!organizationId) { throw new Error('organizationId is required to generate a document'); } - if (!args.fileName) { - throw new Error("Missing required 'fileName' for generate operation"); - } // Mode A: Generate from markdown/html content if (args.sourceType && args.content) { diff --git a/services/platform/convex/agent_tools/files/excel_tool.ts b/services/platform/convex/agent_tools/files/excel_tool.ts index a55742f940..23152d3d4d 100644 --- a/services/platform/convex/agent_tools/files/excel_tool.ts +++ b/services/platform/convex/agent_tools/files/excel_tool.ts @@ -47,55 +47,46 @@ interface ParseExcelResult { type ExcelResult = GenerateExcelResult | ParseExcelResult; -const excelArgs = z.object({ - operation: z - .enum(['generate', 'parse']) - .optional() - .describe( - "Operation: 'generate' (default) or 'parse' (extract data from Excel).", - ), - // For generate operation - fileName: z - .string() - .optional() - .describe( - "For 'generate': Base name for the Excel file (without extension). Required for generate.", - ), - sheets: z - .array( - z.object({ - name: z.string().describe('Sheet name'), - headers: z - .array(z.string()) - .nonempty() - .describe( - "Column headers for the sheet (must align with each row's columns)", - ), - rows: z - .array( - z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])), - ) - .describe('2D array of cell values (rows x columns)'), - }), - ) - .optional() - .describe( - "For 'generate': Sheets to include in the Excel file. Required for generate.", - ), - // For parse operation - fileId: z - .string() - .optional() - .describe( - "For 'parse': **REQUIRED** - Convex storage ID (e.g., 'kg2bazp7fbgt9srq63knfagjrd7yfenj'). Get this from the file attachment context.", - ), - filename: z - .string() - .optional() - .describe( - "For 'parse': Original filename (e.g., 'report.xlsx'). Optional — auto-resolved from file metadata if omitted.", - ), -}); +const excelArgs = z.discriminatedUnion('operation', [ + z.object({ + operation: z.literal('generate'), + fileName: z + .string() + .describe('Base name for the Excel file (without extension)'), + sheets: z + .array( + z.object({ + name: z.string().describe('Sheet name'), + headers: z + .array(z.string()) + .nonempty() + .describe( + "Column headers for the sheet (must align with each row's columns)", + ), + rows: z + .array( + z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])), + ) + .describe('2D array of cell values (rows x columns)'), + }), + ) + .describe('Sheets to include in the Excel file'), + }), + z.object({ + operation: z.literal('parse'), + fileId: z + .string() + .describe( + "Convex storage ID (e.g., 'kg2bazp7fbgt9srq63knfagjrd7yfenj'). Get this from the file attachment context.", + ), + filename: z + .string() + .optional() + .describe( + "Original filename (e.g., 'report.xlsx'). Optional — auto-resolved from file metadata if omitted.", + ), + }), +]); export const excelTool = { name: 'excel' as const, @@ -126,16 +117,7 @@ AFTER GENERATING: The file automatically appears as a download card in the chat. `, args: excelArgs, handler: async (ctx: ToolCtx, args): Promise => { - const operation = args.operation ?? 'generate'; - - // Handle parse operation - if (operation === 'parse') { - if (!args.fileId) { - throw new Error( - "Missing required 'fileId' for parse operation. Get the fileId from the file attachment context.", - ); - } - + if (args.operation === 'parse') { const resolvedFilename = await resolveFileName( ctx, args.fileId, @@ -188,14 +170,7 @@ AFTER GENERATING: The file automatically appears as a download card in the chat. } } - // Default: generate operation - if (!args.fileName) { - throw new Error("Missing required 'fileName' for generate operation"); - } - if (!args.sheets || args.sheets.length === 0) { - throw new Error("Missing required 'sheets' for generate operation"); - } - + // operation === 'generate' debugLog('tool:excel generate start', { fileName: args.fileName, sheetCount: args.sheets.length, diff --git a/services/platform/convex/agent_tools/files/pdf_tool.ts b/services/platform/convex/agent_tools/files/pdf_tool.ts index 774478c70f..9a2357f9e1 100644 --- a/services/platform/convex/agent_tools/files/pdf_tool.ts +++ b/services/platform/convex/agent_tools/files/pdf_tool.ts @@ -73,92 +73,64 @@ EXAMPLES: AFTER GENERATING: The file automatically appears as a download card in the chat. Do NOT mention downloading, do NOT include a link, and do NOT say "you can download it" — the card handles this. To also save the file to a folder in the documents hub, call document_write with the returned fileStorageId and the desired folderPath. `, - args: z.object({ - operation: z - .enum(['generate', 'parse']) - .optional() - .describe("Operation: 'generate' (default) or 'parse'"), - // For generate operation - fileName: z - .string() - .optional() - .describe( - "For 'generate': Base name for the PDF file (without extension)", - ), - sourceType: z - .enum(['markdown', 'html', 'url']) - .optional() - .describe("For 'generate': Type of source content"), - content: z - .string() - .optional() - .describe( - "For 'generate': Markdown text, HTML content, or URL to capture", - ), - pdfOptions: z - .object({ - format: z.string().optional(), - landscape: z.boolean().optional(), - marginTop: z.string().optional(), - marginBottom: z.string().optional(), - marginLeft: z.string().optional(), - marginRight: z.string().optional(), - printBackground: z.boolean().optional(), - }) - .optional() - .describe("For 'generate': Advanced PDF options"), - urlOptions: z - .object({ - waitUntil: z - .enum(['load', 'domcontentloaded', 'networkidle', 'commit']) - .optional(), - }) - .optional() - .describe("For 'generate': Options for URL capture"), - extraCss: z - .string() - .optional() - .describe("For 'generate': Additional CSS to inject"), - wrapInTemplate: z - .boolean() - .optional() - .describe("For 'generate': Whether to wrap in HTML template"), - // For parse operation - fileId: z - .string() - .optional() - .describe( - "For 'parse': **REQUIRED** - Convex storage ID (e.g., 'kg2bazp7fbgt9srq63knfagjrd7yfenj'). Get this from the file attachment context.", - ), - filename: z - .string() - .optional() - .describe( - "For 'parse': Original filename (e.g., 'report.pdf'). Optional — auto-resolved from file metadata if omitted.", - ), - user_input: z - .string() - .optional() - .describe( - "For 'parse': **REQUIRED** - The user's question or instruction about the PDF content", - ), - }), + args: z.discriminatedUnion('operation', [ + z.object({ + operation: z.literal('generate'), + fileName: z + .string() + .describe('Base name for the PDF file (without extension)'), + sourceType: z + .enum(['markdown', 'html', 'url']) + .describe('Type of source content'), + content: z + .string() + .describe('Markdown text, HTML content, or URL to capture'), + pdfOptions: z + .object({ + format: z.string().optional(), + landscape: z.boolean().optional(), + marginTop: z.string().optional(), + marginBottom: z.string().optional(), + marginLeft: z.string().optional(), + marginRight: z.string().optional(), + printBackground: z.boolean().optional(), + }) + .optional() + .describe('Advanced PDF options'), + urlOptions: z + .object({ + waitUntil: z + .enum(['load', 'domcontentloaded', 'networkidle', 'commit']) + .optional(), + }) + .optional() + .describe('Options for URL capture'), + extraCss: z.string().optional().describe('Additional CSS to inject'), + wrapInTemplate: z + .boolean() + .optional() + .describe('Whether to wrap in HTML template'), + }), + z.object({ + operation: z.literal('parse'), + fileId: z + .string() + .describe( + "Convex storage ID (e.g., 'kg2bazp7fbgt9srq63knfagjrd7yfenj'). Get this from the file attachment context.", + ), + filename: z + .string() + .optional() + .describe( + "Original filename (e.g., 'report.pdf'). Optional — auto-resolved from file metadata if omitted.", + ), + user_input: z + .string() + .describe("The user's question or instruction about the PDF content"), + }), + ]), handler: async (ctx: ToolCtx, args): Promise => { - const operation = args.operation ?? 'generate'; - - // Handle parse operation - if (operation === 'parse') { - if (!args.fileId) { - throw new Error( - "Missing required 'fileId' for parse operation. Get the fileId from the file attachment context.", - ); - } - if (!args.user_input) { - throw new Error( - "Missing required 'user_input' for parse operation. Provide the user's question or instruction about the PDF.", - ); - } - + if (args.operation === 'parse') { const model = getAgentModelId(ctx); const result = await parseFile( ctx, @@ -171,17 +143,7 @@ AFTER GENERATING: The file automatically appears as a download card in the chat. return { operation: 'parse', ...result }; } - // Default: generate operation - if (!args.fileName) { - throw new Error("Missing required 'fileName' for generate operation"); - } - if (!args.sourceType) { - throw new Error("Missing required 'sourceType' for generate operation"); - } - if (!args.content) { - throw new Error("Missing required 'content' for generate operation"); - } - + // operation === 'generate' const { organizationId } = ctx; if (!organizationId) { throw new Error('organizationId is required to generate a PDF'); diff --git a/services/platform/convex/agent_tools/files/pptx_tool.ts b/services/platform/convex/agent_tools/files/pptx_tool.ts index 09a59f97ca..82049734b0 100644 --- a/services/platform/convex/agent_tools/files/pptx_tool.ts +++ b/services/platform/convex/agent_tools/files/pptx_tool.ts @@ -67,61 +67,52 @@ const brandingSchema = z.object({ accentColor: z.string().optional().describe('Accent color as hex'), }); -// Use a flat object schema for OpenAI-compatible JSON Schema -const pptxArgs = z.object({ - operation: z - .enum(['list_templates', 'generate', 'parse']) - .describe( - "Operation to perform: 'list_templates', 'generate', or 'parse' (extract text from PPTX)", - ), - // For list_templates operation - limit: z - .number() - .optional() - .describe( - "For 'list_templates': Maximum number of templates to return (default: 50)", - ), - // Required for generate operation - templateStorageId: z - .string() - .optional() - .describe( - "Convex storage ID of the PPTX template. Required for 'generate'. The template is used as base, preserving all styling, backgrounds, and decorative elements.", - ), - // For generate operation - fileName: z - .string() - .optional() - .describe( - "Required for 'generate': Base name for the PPTX file (without extension)", - ), - slidesContent: z - .array(slideContentSchema) - .optional() - .describe("For 'generate': Content for each slide in the presentation"), - branding: brandingSchema - .optional() - .describe("For 'generate': Optional additional branding overrides"), - // For parse operation - fileId: z - .string() - .optional() - .describe( - "For 'parse': **REQUIRED** - Convex storage ID (e.g., 'kg2bazp7fbgt9srq63knfagjrd7yfenj'). Get this from the file attachment context.", - ), - filename: z - .string() - .optional() - .describe( - "For 'parse': Original filename (e.g., 'presentation.pptx'). Optional — auto-resolved from file metadata if omitted.", - ), - user_input: z - .string() - .optional() - .describe( - "For 'parse': **REQUIRED** - The user's question or instruction about the presentation content", - ), -}); +const pptxArgs = z.discriminatedUnion('operation', [ + z.object({ + operation: z.literal('list_templates'), + limit: z + .number() + .optional() + .describe('Maximum number of templates to return (default: 50)'), + }), + z.object({ + operation: z.literal('generate'), + templateStorageId: z + .string() + .optional() + .describe( + 'Convex storage ID of the PPTX template. The template is used as base, preserving all styling, backgrounds, and decorative elements.', + ), + fileName: z + .string() + .describe('Base name for the PPTX file (without extension)'), + slidesContent: z + .array(slideContentSchema) + .describe('Content for each slide in the presentation'), + branding: brandingSchema + .optional() + .describe('Optional additional branding overrides'), + }), + z.object({ + operation: z.literal('parse'), + fileId: z + .string() + .describe( + "Convex storage ID (e.g., 'kg2bazp7fbgt9srq63knfagjrd7yfenj'). Get this from the file attachment context.", + ), + filename: z + .string() + .optional() + .describe( + "Original filename (e.g., 'presentation.pptx'). Optional — auto-resolved from file metadata if omitted.", + ), + user_input: z + .string() + .describe( + "The user's question or instruction about the presentation content", + ), + }), +]); // Result types interface ListTemplatesResult { @@ -261,19 +252,7 @@ AFTER GENERATING: The file automatically appears as a download card in the chat. } } - // Handle parse operation if (args.operation === 'parse') { - if (!args.fileId) { - throw new Error( - "Missing required 'fileId' for parse operation. Get the fileId from the file attachment context.", - ); - } - if (!args.user_input) { - throw new Error( - "Missing required 'user_input' for parse operation. Provide the user's question or instruction about the presentation.", - ); - } - const model = getAgentModelId(ctx); const result = await parseFile( ctx, @@ -293,21 +272,13 @@ AFTER GENERATING: The file automatically appears as a download card in the chat. success: false, fileStorageId: '', downloadUrl: '', - fileName: args.fileName || '', + fileName: args.fileName, contentType: '', size: 0, error: 'templateStorageId is required. Call list_templates first to get available templates. If no templates exist, the user must upload a .pptx template to the Knowledge Base (Documents page) — not in chat.', }; } - if (!args.fileName) { - throw new Error("Missing required 'fileName' for generate operation"); - } - if (!args.slidesContent || args.slidesContent.length === 0) { - throw new Error( - "Missing required 'slidesContent' for generate operation", - ); - } if (!organizationId) { throw new Error( diff --git a/services/platform/convex/agent_tools/files/text_tool.ts b/services/platform/convex/agent_tools/files/text_tool.ts index 0421715cba..9154ff27e9 100644 --- a/services/platform/convex/agent_tools/files/text_tool.ts +++ b/services/platform/convex/agent_tools/files/text_tool.ts @@ -50,40 +50,32 @@ interface TextGenerateResult { type TextResult = TextParseResult | TextGenerateResult; -// Use a flat object schema instead of discriminatedUnion to ensure OpenAI-compatible JSON Schema -// (discriminatedUnion produces anyOf/oneOf which some providers reject as "type: None") -const textArgs = z.object({ - operation: z - .enum(['parse', 'generate']) - .optional() - .describe("Operation: 'parse' (default) or 'generate'"), - // For parse operation - fileId: z - .string() - .optional() - .describe( - "For 'parse': **REQUIRED** - Convex storage ID of the file (e.g., 'kg2bazp7fbgt9srq63knfagjrd7yfenj'). Get this from the file attachment context.", - ), - filename: z - .string() - .optional() - .describe( - "For 'parse': Original filename (e.g., 'data.txt', 'script.js'). Optional — auto-resolved from file metadata if omitted. For 'generate': Output filename (e.g., 'output.txt', 'notes.md').", - ), - user_input: z - .string() - .optional() - .describe( - "For 'parse': **REQUIRED** - The user's question or instruction about the file", - ), - // For generate operation - content: z - .string() - .optional() - .describe( - "For 'generate': **REQUIRED** - The text content to write to the file", - ), -}); +const textArgs = z.discriminatedUnion('operation', [ + z.object({ + operation: z.literal('parse'), + fileId: z + .string() + .describe( + "Convex storage ID of the file (e.g., 'kg2bazp7fbgt9srq63knfagjrd7yfenj'). Get this from the file attachment context.", + ), + filename: z + .string() + .optional() + .describe( + "Original filename (e.g., 'data.txt', 'script.js'). Optional — auto-resolved from file metadata if omitted.", + ), + user_input: z + .string() + .describe("The user's question or instruction about the file"), + }), + z.object({ + operation: z.literal('generate'), + filename: z + .string() + .describe("Output filename (e.g., 'output.txt', 'notes.md')"), + content: z.string().describe('The text content to write to the file'), + }), +]); export const textTool = { name: 'text' as const, @@ -121,38 +113,10 @@ AFTER GENERATING: The file automatically appears as a download card in the chat. `, args: textArgs, handler: async (ctx: ToolCtx, args): Promise => { - const operation = args.operation ?? 'parse'; - - if (operation === 'generate') { - const filename = args.filename ?? ''; - const content = args.content ?? ''; + if (args.operation === 'generate') { + const { filename, content } = args; try { - if (!filename) { - return { - operation: 'generate', - success: false, - fileStorageId: '', - downloadUrl: '', - filename: 'unknown', - char_count: 0, - line_count: 0, - error: "Missing required 'filename' for generate operation", - }; - } - if (!content) { - return { - operation: 'generate', - success: false, - fileStorageId: '', - downloadUrl: '', - filename, - char_count: 0, - line_count: 0, - error: "Missing required 'content' for generate operation", - }; - } - debugLog('tool:text generate start', { filename, contentLength: content.length, @@ -224,38 +188,8 @@ AFTER GENERATING: The file automatically appears as a download card in the chat. } } + // operation === 'parse' const { fileId, filename, user_input } = args; - - if (!fileId) { - return { - operation: 'parse', - success: false, - result: '', - filename: filename || 'unknown', - char_count: 0, - line_count: 0, - encoding: 'unknown', - chunked: false, - error: - "ERROR: Missing required 'fileId' parameter. For uploaded files, you MUST provide the fileId from the file attachment context (it looks like 'kg2bazp7fbgt9srq63knfagjrd7yfenj'). Please check the attachment info and retry with fileId.", - }; - } - - if (!user_input) { - return { - operation: 'parse', - success: false, - result: '', - filename: filename || 'unknown', - char_count: 0, - line_count: 0, - encoding: 'unknown', - chunked: false, - error: - "ERROR: Missing required 'user_input' parameter. Please provide the user's question or instruction about the file.", - }; - } - const model = getAgentModelId(ctx); const resolvedFilename = await resolveFileName(ctx, fileId, filename); diff --git a/services/platform/convex/agent_tools/products/product_read_tool.ts b/services/platform/convex/agent_tools/products/product_read_tool.ts index db9c4db03c..c75fd77807 100644 --- a/services/platform/convex/agent_tools/products/product_read_tool.ts +++ b/services/platform/convex/agent_tools/products/product_read_tool.ts @@ -24,52 +24,59 @@ import { countProducts } from './helpers/count_products'; import { readProductsByIds } from './helpers/read_product_by_id'; import { readProductList } from './helpers/read_product_list'; -// Use a flat object schema instead of discriminatedUnion to ensure OpenAI-compatible JSON Schema -// (discriminatedUnion produces anyOf/oneOf which some providers reject as "type: None") -const productReadArgs = z.object({ - operation: z - .enum(['get_by_id', 'list', 'count']) - .describe( - "Operation to perform: 'get_by_id' (fetch by IDs), 'list' (browse catalog), or 'count' (count total products)", - ), - // For get_by_id operation - productIds: z - .array(z.string()) - .optional() - .describe( - 'Required for \'get_by_id\': Array of Convex Id<"products"> strings. Can be single item or multiple.', - ), - // For get_by_id operation only - fields: z - .array(z.string()) - .optional() - .describe( - "For 'get_by_id' only: Fields to return. Default: ['_id','name','description','price','currency','status','category','imageUrl','stock']. Ignored for 'list'.", - ), - // For list and count operations - filters - status: z - .enum(['active', 'inactive', 'draft', 'archived']) - .optional() - .describe("For 'list' and 'count': Filter by product status"), - minStock: z - .number() - .optional() - .describe( - "For 'list' and 'count': Filter by minimum stock level. Only returns/counts products with stock >= minStock", - ), - // For list operation - pagination - cursor: z - .string() - .nullable() - .optional() - .describe( - "For 'list': Pagination cursor from previous response, or null/omitted for first page", - ), - numItems: z - .number() - .optional() - .describe("For 'list': Number of items per page (default: 50)"), -}); +const productReadArgs = z.discriminatedUnion('operation', [ + z.object({ + operation: z.literal('get_by_id'), + productIds: z + .array(z.string()) + .describe( + 'Array of Convex Id<"products"> strings. Can be single item or multiple.', + ), + fields: z + .array(z.string()) + .optional() + .describe( + "Fields to return. Default: ['_id','name','description','price','currency','status','category','imageUrl','stock'].", + ), + }), + z.object({ + operation: z.literal('list'), + status: z + .enum(['active', 'inactive', 'draft', 'archived']) + .optional() + .describe('Filter by product status'), + minStock: z + .number() + .optional() + .describe( + 'Filter by minimum stock level. Only returns products with stock >= minStock', + ), + cursor: z + .string() + .nullable() + .optional() + .describe( + 'Pagination cursor from previous response, or null/omitted for first page', + ), + numItems: z + .number() + .optional() + .describe('Number of items per page (default: 50)'), + }), + z.object({ + operation: z.literal('count'), + status: z + .enum(['active', 'inactive', 'draft', 'archived']) + .optional() + .describe('Filter by product status'), + minStock: z + .number() + .optional() + .describe( + 'Filter by minimum stock level. Only counts products with stock >= minStock', + ), + }), +]); export const productReadTool: ToolDefinition = { name: 'product_read', @@ -112,11 +119,6 @@ BEST PRACTICES: ProductReadGetByIdResult | ProductReadListResult | ProductReadCountResult > => { if (args.operation === 'get_by_id') { - if (!args.productIds || args.productIds.length === 0) { - throw new Error( - "Missing required 'productIds' for get_by_id operation", - ); - } return readProductsByIds(ctx, { productIds: args.productIds, fields: args.fields, diff --git a/services/platform/convex/agent_tools/workflows/workflow_read_tool.ts b/services/platform/convex/agent_tools/workflows/workflow_read_tool.ts index ecf66f8ee0..e84bb2b185 100644 --- a/services/platform/convex/agent_tools/workflows/workflow_read_tool.ts +++ b/services/platform/convex/agent_tools/workflows/workflow_read_tool.ts @@ -31,61 +31,48 @@ import { readAllWorkflows } from './helpers/read_all_workflows'; import { readVersionHistory } from './helpers/read_version_history'; import { readWorkflowStructure } from './helpers/read_workflow_structure'; -// Use a flat object schema instead of discriminatedUnion to ensure OpenAI-compatible JSON Schema -// (discriminatedUnion produces anyOf/oneOf which some providers reject as "type: None") -const workflowReadArgs = z.object({ - operation: z - .enum([ - 'get_structure', - 'get_step', - 'list_all', - 'get_active_version_steps', - 'list_version_history', - ]) - .describe( - "Operation to perform: 'get_structure', 'get_step', 'list_all', 'get_active_version_steps', or 'list_version_history'", - ), - // For get_structure operation - workflowId: z - .string() - .optional() - .describe( - 'Required for \'get_structure\': The workflow ID (Convex Id<"wfDefinitions">)', - ), - // For get_step operation - stepId: z - .string() - .optional() - .describe( - 'Required for \'get_step\': The step record ID (Convex Id<"wfStepDefs">)', - ), - // For list_all operation - status: workflowStatusSchema - .optional() - .describe( - "For 'list_all': Optional status filter ('draft', 'active', or 'archived'). If not provided, returns all workflows.", - ), - includeStepCount: z - .boolean() - .optional() - .describe( - "For 'list_all': Whether to include step count for each workflow (default: false). Setting to true may increase response time.", - ), - // For get_active_version_steps and list_version_history operations - workflowName: z - .string() - .optional() - .describe( - "Required for 'get_active_version_steps' and 'list_version_history': The workflow name to look up versions for.", - ), - // For list_version_history operation - includeSteps: z - .boolean() - .optional() - .describe( - "For 'list_version_history': Whether to include all steps for each version (default: false). Setting to true may increase response time significantly.", - ), -}); +const workflowReadArgs = z.discriminatedUnion('operation', [ + z.object({ + operation: z.literal('get_structure'), + workflowId: z + .string() + .describe('The workflow ID (Convex Id<"wfDefinitions">)'), + }), + z.object({ + operation: z.literal('get_step'), + stepId: z.string().describe('The step record ID (Convex Id<"wfStepDefs">)'), + }), + z.object({ + operation: z.literal('list_all'), + status: workflowStatusSchema + .optional() + .describe( + "Optional status filter ('draft', 'active', or 'archived'). If not provided, returns all workflows.", + ), + includeStepCount: z + .boolean() + .optional() + .describe( + 'Whether to include step count for each workflow (default: false). Setting to true may increase response time.', + ), + }), + z.object({ + operation: z.literal('get_active_version_steps'), + workflowName: z + .string() + .describe('The workflow name to look up versions for'), + }), + z.object({ + operation: z.literal('list_version_history'), + workflowName: z.string().describe('The workflow name'), + includeSteps: z + .boolean() + .optional() + .describe( + 'Whether to include all steps for each version (default: false). Setting to true may increase response time significantly.', + ), + }), +]); export const workflowReadTool: ToolDefinition = { name: 'workflow_read', @@ -118,19 +105,10 @@ BEST PRACTICES: | { success: boolean; step: unknown } > => { if (args.operation === 'get_structure') { - if (!args.workflowId) { - throw new Error( - "Missing required 'workflowId' for get_structure operation", - ); - } return readWorkflowStructure(ctx, { workflowId: args.workflowId }); } if (args.operation === 'get_step') { - if (!args.stepId) { - throw new Error("Missing required 'stepId' for get_step operation"); - } - // Get the step by ID using internal query const stepDoc = await ctx.runQuery( internal.wf_step_defs.internal_queries.getStepById, { @@ -147,20 +125,10 @@ BEST PRACTICES: } if (args.operation === 'get_active_version_steps') { - if (!args.workflowName) { - throw new Error( - "Missing required 'workflowName' for get_active_version_steps operation", - ); - } return readActiveVersionSteps(ctx, { workflowName: args.workflowName }); } if (args.operation === 'list_version_history') { - if (!args.workflowName) { - throw new Error( - "Missing required 'workflowName' for list_version_history operation", - ); - } return readVersionHistory(ctx, { workflowName: args.workflowName, includeSteps: args.includeSteps, From 347d83a28599ca46125738256401b78252b2c365 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Thu, 19 Mar 2026 07:20:20 +0800 Subject: [PATCH 2/2] fix: add nonempty validation for productIds and clean up excel_tool - product_read_tool: add .nonempty() to productIds schema to reject empty arrays (previously caught by runtime check) - excel_tool: remove unnecessary `as const` cast and dead `?? 'unknown.xlsx'` fallback (fileName is now required) --- services/platform/convex/agent_tools/files/excel_tool.ts | 4 ++-- .../platform/convex/agent_tools/products/product_read_tool.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/services/platform/convex/agent_tools/files/excel_tool.ts b/services/platform/convex/agent_tools/files/excel_tool.ts index 23152d3d4d..4921906fc1 100644 --- a/services/platform/convex/agent_tools/files/excel_tool.ts +++ b/services/platform/convex/agent_tools/files/excel_tool.ts @@ -244,11 +244,11 @@ AFTER GENERATING: The file automatically appears as a download card in the chat. error: message, }); return { - operation: 'generate' as const, + operation: 'generate', success: false, fileStorageId: '', downloadUrl: '', - fileName: args.fileName ?? 'unknown.xlsx', + fileName: args.fileName, rowCount: 0, sheetCount: 0, error: message, diff --git a/services/platform/convex/agent_tools/products/product_read_tool.ts b/services/platform/convex/agent_tools/products/product_read_tool.ts index c75fd77807..4920d03ef4 100644 --- a/services/platform/convex/agent_tools/products/product_read_tool.ts +++ b/services/platform/convex/agent_tools/products/product_read_tool.ts @@ -29,6 +29,7 @@ const productReadArgs = z.discriminatedUnion('operation', [ operation: z.literal('get_by_id'), productIds: z .array(z.string()) + .nonempty() .describe( 'Array of Convex Id<"products"> strings. Can be single item or multiple.', ),