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<DocxResult> => {
       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<ExcelResult> => {
-      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<PdfResult> => {
-      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<TextResult> => {
-      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.',
       ),
