feat: workflow bindings for custom agents#728
Conversation
There was a problem hiding this comment.
Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.
Allow custom agents to bind workflows as tools, enabling agents to trigger organization workflows during conversations. Includes schema changes, validation, bound workflow tool creation, UI selector, and test coverage.
…nalysis step Build proper Zod arg schemas from workflow input definitions instead of a generic parameters record, giving agents typed tool arguments that match each workflow's inputs directly. Also split the contract comparison analyze_differences step into separate text analysis and JSON clause extraction steps for cleaner separation of concerns.
b03af0e to
f2ca788
Compare
📝 WalkthroughWalkthroughThis PR adds comprehensive workflow bindings support to custom agents, enabling workflows to be bound and executed as dedicated tools. Key changes include: adding a Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Tip Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs). Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@services/platform/app/features/custom-agents/components/test-chat-panel/test-message-list.tsx`:
- Around line 145-149: The image preview behavior was removed causing
fileParts/attachments to be non-clickable; restore a preview path by
reintroducing an onImagePreview handler in this component and passing it into
the shared renderers (e.g., pass onImagePreview to FilePartDisplay and the
attachments renderer) or update the shared components to accept a prop like
clickable={true} that opens the full-size image; update the rendering logic
where message.fileParts and attachments are mapped (references: FilePartDisplay,
message.fileParts, attachments, onImagePreview) so thumbnails remain static but
still open a full-size preview when clicked.
In
`@services/platform/convex/agent_tools/workflows/__tests__/create_bound_workflow_tool.test.ts`:
- Around line 25-30: The mock for createTool currently returns only _handler and
_description so tests can't inspect the Zod args; update the vi.mock
implementation in create_bound_workflow_tool.test.ts to also capture def.args
(e.g., include _args: def.args on the mocked tool) and then add assertions that
use the tool's _args.parse to validate required vs optional fields (for example,
call _args.parse with a valid payload to assert no throw and with a missing
required field to assert it throws) so the suite verifies the generated schema.
In
`@services/platform/convex/agent_tools/workflows/create_bound_workflow_tool.ts`:
- Around line 92-163: The prep and approval creation flow currently only wraps
createWorkflowRunApproval in a try/catch; wrap the entire preparation path
(including the calls that can throw:
ctx.runQuery(internal.wf_definitions.internal_queries.resolveWorkflow) which
yields resolvedWf,
ctx.runQuery(internal.wf_definitions.internal_queries.getStartStepConfig) which
yields startStepConfig, extractInputSchema(startStepConfig) producing
runtimeInputSchema, validateWorkflowInput(args, runtimeInputSchema) producing
validation, and getApprovalThreadId(ctx, currentThreadId) producing threadId) in
a single try/catch around the block that leads into calling
internal.agent_tools.workflows.internal_mutations.createWorkflowRunApproval; in
the catch, do not return raw error.message to the caller—log the error details
internally and return a structured { success: false, message } with a safe,
generic message (e.g., "Failed to prepare workflow run") while preserving
existing failure shapes for validation/ownership/status checks.
- Around line 38-50: The buildArgsSchema function currently returns z.object({})
or z.object(shape) which is non-strict and will strip unknown keys; update both
return sites in buildArgsSchema to use strict mode (e.g., call .strict() on the
created Zod object) so unexpected workflow parameters are rejected before
hitting validateWorkflowInput; keep the existing optional/description handling
for properties and only append .strict() to the z.object(...) results to enforce
failure on extra keys.
In `@services/platform/convex/custom_agents/mutations.ts`:
- Around line 81-105: The validator currently only checks existence and org on
wfDefinitions; extend validateWorkflowBindings so that for each wfId it also
resolves the workflow's active/published version before accepting the binding:
after fetching wf (in validateWorkflowBindings), fetch the active version (e.g.
via wf.activeVersionId or similar field) using ctx.db.get(activeVersionId) and
ensure that the returned version record exists and is in a published/active
state (e.g. version.status === 'active' or version.published === true); if no
active version is found or it is archived/draft, throw an Error indicating the
binding points to a non-active workflow version. Ensure you reference the same
symbols: validateWorkflowBindings, workflowBindings, wfDefinitions, wf (and
wf.activeVersionId / wfVersion) so reviewers can locate the change.
In `@services/platform/convex/custom_agents/queries.ts`:
- Around line 280-319: The getAvailableWorkflows handler currently only checks
authentication via getAuthUserIdentity; before querying wfDefinitions you must
enforce organization access (membership/visibility) the same way other
org-scoped endpoints do: call the shared org-access guard used across the
codebase to validate args.organizationId (e.g., the
requireOrgMember/ensureOrgVisible helper your other endpoints call) after
obtaining authUser and before constructing workflowQuery, and if the check fails
return an empty array or appropriate permission error; keep the rest of the
logic unchanged and ensure the guard uses ctx and authUser to verify access for
the provided organizationId.
In `@services/platform/convex/lib/agent_chat/internal_actions.ts`:
- Around line 205-243: Serial hydration of bound workflows blocks on each
ctx.runQuery; instead parallelize by mapping agentConfig.workflowBindings to
Promise tasks: first call
internal.wf_definitions.internal_queries.getActiveVersionByRoot for all rootIds
with Promise.all, filter out missing or wrong-organization activeVersion results
(emit the same debugLog entries for skipped roots), then for the remaining
activeVersions issue Promise.all of
internal.wf_definitions.internal_queries.getStartStepConfig to fetch
startStepConfig for each activeVersion, call extractInputSchema and
createBoundWorkflowTool for each result, and finally populate workflowExtraTools
using the same toolKey generation (sanitizeWorkflowName + rootId.slice) and emit
the "Built bound workflow tools" debugLog if non-empty; keep references to
agentConfig.workflowBindings, ctx.runQuery, getActiveVersionByRoot,
getStartStepConfig, extractInputSchema, createBoundWorkflowTool,
sanitizeWorkflowName, and workflowExtraTools so reviewers can locate and verify
the change.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 7f38f404-eed7-4ed0-a2df-145d0045ed93
⛔ Files ignored due to path filters (1)
services/platform/convex/_generated/api.d.tsis excluded by!**/_generated/**
📒 Files selected for processing (20)
examples/workflows/contract-comparison/config.jsonservices/platform/app/features/custom-agents/components/test-chat-panel.tsxservices/platform/app/features/custom-agents/components/test-chat-panel/test-message-list.tsxservices/platform/app/features/custom-agents/components/tool-selector.tsxservices/platform/app/features/custom-agents/hooks/queries.tsservices/platform/app/features/custom-agents/hooks/use-test-chat.tsservices/platform/app/routes/dashboard/$id/custom-agents/$agentId/tools.tsxservices/platform/convex/agent_tools/workflows/__tests__/create_bound_workflow_tool.test.tsservices/platform/convex/agent_tools/workflows/create_bound_workflow_tool.tsservices/platform/convex/custom_agents/config.tsservices/platform/convex/custom_agents/mutations.tsservices/platform/convex/custom_agents/queries.tsservices/platform/convex/custom_agents/schema.tsservices/platform/convex/custom_agents/test_chat.test.tsservices/platform/convex/lib/agent_chat/internal_actions.tsservices/platform/convex/lib/agent_chat/types.tsservices/platform/convex/wf_definitions/internal_queries.tsservices/platform/convex/workflows/schema.tsservices/platform/lib/shared/file-types.tsservices/platform/messages/en.json
💤 Files with no reviewable changes (1)
- services/platform/app/features/custom-agents/components/test-chat-panel.tsx
| {message.fileParts && message.fileParts.length > 0 && ( | ||
| <div className="mb-2 flex flex-wrap gap-1"> | ||
| {message.fileParts.map((part) => | ||
| part.mediaType.startsWith('image/') ? ( | ||
| <button | ||
| key={part.url} | ||
| type="button" | ||
| aria-label={t('customAgents.testChat.previewImage', { | ||
| filename: part.filename || 'Image', | ||
| })} | ||
| onClick={() => | ||
| onImagePreview(part.url, part.filename || 'Image') | ||
| } | ||
| className="bg-muted focus:ring-ring size-11 cursor-pointer overflow-hidden rounded-lg bg-cover bg-center bg-no-repeat transition-opacity hover:opacity-90 focus:ring-2 focus:ring-offset-2 focus:outline-none" | ||
| > | ||
| <Image | ||
| src={part.url} | ||
| alt={part.filename || 'Image'} | ||
| className="size-full object-cover" | ||
| width={44} | ||
| height={44} | ||
| /> | ||
| </button> | ||
| ) : ( | ||
| <a | ||
| key={part.url} | ||
| href={part.url} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="bg-muted hover:bg-muted/80 flex max-w-[13.5rem] items-center gap-2 rounded-lg px-2 py-1.5 transition-colors" | ||
| > | ||
| <DocumentIcon fileName={part.filename || 'file'} /> | ||
| <div className="flex min-w-0 flex-1 flex-col"> | ||
| <Text as="div" variant="label" truncate> | ||
| {part.filename || 'File'} | ||
| </Text> | ||
| </div> | ||
| </a> | ||
| ), | ||
| )} | ||
| {message.fileParts.map((part) => ( | ||
| <FilePartDisplay key={part.url} filePart={part} /> | ||
| ))} |
There was a problem hiding this comment.
Restore an open/preview path for image files in test chat.
Lines 147-149 and 172-176 now delegate to shared display components that render images as static thumbnails. Combined with removing onImagePreview, image fileParts and attachments can no longer be opened or inspected at full size from this panel. That is a functional regression for test-chat workflows that rely on validating image inputs/outputs. Please keep a preview handler here or extend the shared file display components with a clickable image variant.
Also applies to: 170-178
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@services/platform/app/features/custom-agents/components/test-chat-panel/test-message-list.tsx`
around lines 145 - 149, The image preview behavior was removed causing
fileParts/attachments to be non-clickable; restore a preview path by
reintroducing an onImagePreview handler in this component and passing it into
the shared renderers (e.g., pass onImagePreview to FilePartDisplay and the
attachments renderer) or update the shared components to accept a prop like
clickable={true} that opens the full-size image; update the rendering logic
where message.fileParts and attachments are mapped (references: FilePartDisplay,
message.fileParts, attachments, onImagePreview) so thumbnails remain static but
still open a full-size preview when clicked.
| vi.mock('@convex-dev/agent', () => ({ | ||
| createTool: vi.fn((def) => ({ | ||
| _handler: def.handler, | ||
| _description: def.description, | ||
| })), | ||
| })); |
There was a problem hiding this comment.
Preserve def.args in the mock so the suite can verify the generated schema.
Right now the mock only exposes _handler and _description, so these tests never assert the typed Zod args that this PR is adding. A regression back to a generic/unvalidated parameter record would still pass. Capture def.args and add parse assertions for required vs optional fields.
Suggested test adjustment
vi.mock('@convex-dev/agent', () => ({
createTool: vi.fn((def) => ({
_handler: def.handler,
_description: def.description,
+ _args: def.args,
})),
}));const argsSchema = (tool as unknown as {
_args: { parse: (value: unknown) => unknown };
})._args;
expect(() => argsSchema.parse({ targetFolder: '/invoices' })).not.toThrow();
expect(() => argsSchema.parse({ daysBack: 30 })).toThrow();📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| vi.mock('@convex-dev/agent', () => ({ | |
| createTool: vi.fn((def) => ({ | |
| _handler: def.handler, | |
| _description: def.description, | |
| })), | |
| })); | |
| vi.mock('@convex-dev/agent', () => ({ | |
| createTool: vi.fn((def) => ({ | |
| _handler: def.handler, | |
| _description: def.description, | |
| _args: def.args, | |
| })), | |
| })); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@services/platform/convex/agent_tools/workflows/__tests__/create_bound_workflow_tool.test.ts`
around lines 25 - 30, The mock for createTool currently returns only _handler
and _description so tests can't inspect the Zod args; update the vi.mock
implementation in create_bound_workflow_tool.test.ts to also capture def.args
(e.g., include _args: def.args on the mocked tool) and then add assertions that
use the tool's _args.parse to validate required vs optional fields (for example,
call _args.parse with a valid payload to assert no throw and with a missing
required field to assert it throws) so the suite verifies the generated schema.
| function buildArgsSchema(inputSchema: WorkflowInputSchema | undefined) { | ||
| if (!inputSchema || Object.keys(inputSchema.properties).length === 0) { | ||
| return z.object({}); | ||
| } | ||
|
|
||
| const shape: Record<string, z.ZodTypeAny> = {}; | ||
| for (const [name, prop] of Object.entries(inputSchema.properties)) { | ||
| let field = (ZOD_TYPE_MAP[prop.type] ?? (() => z.unknown()))(); | ||
| if (prop.description) field = field.describe(prop.description); | ||
| if (!inputSchema.required?.includes(name)) field = field.optional(); | ||
| shape[name] = field; | ||
| } | ||
| return z.object(shape); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's examine the full file to understand context
fd -t f "create_bound_workflow_tool.ts" --exec cat -n {} \;Repository: tale-project/tale
Length of output: 8215
🏁 Script executed:
# Check for validateWorkflowInput implementation
rg "validateWorkflowInput" -B 3 -A 10 --max-count 5Repository: tale-project/tale
Length of output: 13806
🏁 Script executed:
# Check Zod usage patterns in the codebase, specifically with .strict()
rg "\.strict\(\)" -B 2 -A 2 --type ts --max-count 10Repository: tale-project/tale
Length of output: 43
🏁 Script executed:
# Look for other z.object patterns in agent_tools or workflow contexts
rg "z\.object" -B 2 -A 2 services/platform/convex/agent_tools --type tsRepository: tale-project/tale
Length of output: 20463
🌐 Web query:
Zod v4 z.object default behavior unknown keys strip or passthrough
💡 Result:
In Zod v4, z.object({...}) strips unknown keys by default (i.e., extra properties are removed from the parsed output). (odocs-zod.vercel.app)
To change that behavior:
- Keep unknown keys: use
z.looseObject(shape)(preferred v4 API) or.passthrough()(legacy but still available). (zod.dev) - Reject unknown keys (error): use
z.strictObject(shape)(preferred v4 API) or.strict()(legacy). (zod.dev) - Reset to default strip:
.strip()exists but is deprecated/legacy in v4 (since strip is already the default). (zod.dev)
Citations:
- 1: https://odocs-zod.vercel.app/
- 2: https://zod.dev/v4/changelog
- 3: https://zod.dev/v4/changelog
- 4: https://zod.dev/v4/changelog
Add .strict() to reject unexpected workflow parameters before they reach downstream handlers.
z.object(shape) strips unknown keys by default in Zod v4, which means hallucinated or misspelled optional parameters silently disappear before validateWorkflowInput() can flag them. This allows incomplete intent to pass validation. Apply strict mode to fail early when the LLM provides unexpected arguments.
Suggested fix
function buildArgsSchema(inputSchema: WorkflowInputSchema | undefined) {
if (!inputSchema || Object.keys(inputSchema.properties).length === 0) {
- return z.object({});
+ return z.object({}).strict();
}
const shape: Record<string, z.ZodTypeAny> = {};
for (const [name, prop] of Object.entries(inputSchema.properties)) {
let field = (ZOD_TYPE_MAP[prop.type] ?? (() => z.unknown()))();
if (prop.description) field = field.describe(prop.description);
if (!inputSchema.required?.includes(name)) field = field.optional();
shape[name] = field;
}
- return z.object(shape);
+ return z.object(shape).strict();
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function buildArgsSchema(inputSchema: WorkflowInputSchema | undefined) { | |
| if (!inputSchema || Object.keys(inputSchema.properties).length === 0) { | |
| return z.object({}); | |
| } | |
| const shape: Record<string, z.ZodTypeAny> = {}; | |
| for (const [name, prop] of Object.entries(inputSchema.properties)) { | |
| let field = (ZOD_TYPE_MAP[prop.type] ?? (() => z.unknown()))(); | |
| if (prop.description) field = field.describe(prop.description); | |
| if (!inputSchema.required?.includes(name)) field = field.optional(); | |
| shape[name] = field; | |
| } | |
| return z.object(shape); | |
| function buildArgsSchema(inputSchema: WorkflowInputSchema | undefined) { | |
| if (!inputSchema || Object.keys(inputSchema.properties).length === 0) { | |
| return z.object({}).strict(); | |
| } | |
| const shape: Record<string, z.ZodTypeAny> = {}; | |
| for (const [name, prop] of Object.entries(inputSchema.properties)) { | |
| let field = (ZOD_TYPE_MAP[prop.type] ?? (() => z.unknown()))(); | |
| if (prop.description) field = field.describe(prop.description); | |
| if (!inputSchema.required?.includes(name)) field = field.optional(); | |
| shape[name] = field; | |
| } | |
| return z.object(shape).strict(); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@services/platform/convex/agent_tools/workflows/create_bound_workflow_tool.ts`
around lines 38 - 50, The buildArgsSchema function currently returns
z.object({}) or z.object(shape) which is non-strict and will strip unknown keys;
update both return sites in buildArgsSchema to use strict mode (e.g., call
.strict() on the created Zod object) so unexpected workflow parameters are
rejected before hitting validateWorkflowInput; keep the existing
optional/description handling for properties and only append .strict() to the
z.object(...) results to enforce failure on extra keys.
| const resolvedWf = await ctx.runQuery( | ||
| internal.wf_definitions.internal_queries.resolveWorkflow, | ||
| { wfDefinitionId: toId<'wfDefinitions'>(String(wfDefinition._id)) }, | ||
| ); | ||
|
|
||
| if (!resolvedWf) { | ||
| return { | ||
| success: false, | ||
| message: `Workflow "${wfDefinition.name}" is no longer available.`, | ||
| }; | ||
| } | ||
|
|
||
| if (resolvedWf.organizationId !== organizationId) { | ||
| return { | ||
| success: false, | ||
| message: `Workflow "${wfDefinition.name}" does not belong to the current organization.`, | ||
| }; | ||
| } | ||
|
|
||
| if (resolvedWf.status === 'archived') { | ||
| return { | ||
| success: false, | ||
| message: `Workflow "${wfDefinition.name}" is archived and cannot be executed.`, | ||
| }; | ||
| } | ||
|
|
||
| const startStepConfig = await ctx.runQuery( | ||
| internal.wf_definitions.internal_queries.getStartStepConfig, | ||
| { wfDefinitionId: resolvedWf._id }, | ||
| ); | ||
|
|
||
| const runtimeInputSchema = extractInputSchema(startStepConfig); | ||
| const validation = validateWorkflowInput(args, runtimeInputSchema); | ||
|
|
||
| if (!validation.valid) { | ||
| return { | ||
| success: false, | ||
| message: `Invalid workflow parameters: ${validation.errors.join('; ')}`, | ||
| }; | ||
| } | ||
|
|
||
| const threadId = await getApprovalThreadId(ctx, currentThreadId); | ||
|
|
||
| try { | ||
| const approvalId = await ctx.runMutation( | ||
| internal.agent_tools.workflows.internal_mutations | ||
| .createWorkflowRunApproval, | ||
| { | ||
| organizationId, | ||
| workflowId: resolvedWf._id, | ||
| workflowName: resolvedWf.name, | ||
| workflowDescription: resolvedWf.description, | ||
| parameters: args, | ||
| threadId, | ||
| messageId, | ||
| }, | ||
| ); | ||
|
|
||
| return { | ||
| success: true, | ||
| requiresApproval: true, | ||
| approvalId, | ||
| approvalCreated: true, | ||
| approvalMessage: `APPROVAL CREATED SUCCESSFULLY: An approval card (ID: ${approvalId}) has been created to run workflow "${resolvedWf.name}". The user must approve this in the chat UI before execution begins.`, | ||
| message: `Workflow "${resolvedWf.name}" is ready to run. An approval card has been created. The workflow will start once the user approves it.`, | ||
| }; | ||
| } catch (error) { | ||
| return { | ||
| success: false, | ||
| message: `Failed to create workflow run approval: ${ | ||
| error instanceof Error ? error.message : 'Unknown error' | ||
| }`, |
There was a problem hiding this comment.
Handle the full preparation path inside the failure boundary.
Only createWorkflowRunApproval is wrapped in try/catch. If resolveWorkflow, getStartStepConfig, extractInputSchema, or getApprovalThreadId throws, this escapes as an unhandled tool error instead of the structured { success: false, message } contract you're returning elsewhere. Expanding the catch also lets you stop echoing raw backend exception text back into chat.
Suggested fix
- const resolvedWf = await ctx.runQuery(
- internal.wf_definitions.internal_queries.resolveWorkflow,
- { wfDefinitionId: toId<'wfDefinitions'>(String(wfDefinition._id)) },
- );
-
- if (!resolvedWf) {
- return {
- success: false,
- message: `Workflow "${wfDefinition.name}" is no longer available.`,
- };
- }
-
- if (resolvedWf.organizationId !== organizationId) {
- return {
- success: false,
- message: `Workflow "${wfDefinition.name}" does not belong to the current organization.`,
- };
- }
-
- if (resolvedWf.status === 'archived') {
- return {
- success: false,
- message: `Workflow "${wfDefinition.name}" is archived and cannot be executed.`,
- };
- }
-
- const startStepConfig = await ctx.runQuery(
- internal.wf_definitions.internal_queries.getStartStepConfig,
- { wfDefinitionId: resolvedWf._id },
- );
-
- const runtimeInputSchema = extractInputSchema(startStepConfig);
- const validation = validateWorkflowInput(args, runtimeInputSchema);
-
- if (!validation.valid) {
- return {
- success: false,
- message: `Invalid workflow parameters: ${validation.errors.join('; ')}`,
- };
- }
-
- const threadId = await getApprovalThreadId(ctx, currentThreadId);
-
try {
+ const resolvedWf = await ctx.runQuery(
+ internal.wf_definitions.internal_queries.resolveWorkflow,
+ { wfDefinitionId: toId<'wfDefinitions'>(String(wfDefinition._id)) },
+ );
+
+ if (!resolvedWf) {
+ return {
+ success: false,
+ message: `Workflow "${wfDefinition.name}" is no longer available.`,
+ };
+ }
+
+ if (resolvedWf.organizationId !== organizationId) {
+ return {
+ success: false,
+ message: `Workflow "${wfDefinition.name}" does not belong to the current organization.`,
+ };
+ }
+
+ if (resolvedWf.status === 'archived') {
+ return {
+ success: false,
+ message: `Workflow "${wfDefinition.name}" is archived and cannot be executed.`,
+ };
+ }
+
+ const startStepConfig = await ctx.runQuery(
+ internal.wf_definitions.internal_queries.getStartStepConfig,
+ { wfDefinitionId: resolvedWf._id },
+ );
+
+ const runtimeInputSchema = extractInputSchema(startStepConfig);
+ const validation = validateWorkflowInput(args, runtimeInputSchema);
+
+ if (!validation.valid) {
+ return {
+ success: false,
+ message: `Invalid workflow parameters: ${validation.errors.join('; ')}`,
+ };
+ }
+
+ const threadId = await getApprovalThreadId(ctx, currentThreadId);
+
const approvalId = await ctx.runMutation(
internal.agent_tools.workflows.internal_mutations
.createWorkflowRunApproval,
@@
return {
success: true,
@@
};
- } catch (error) {
+ } catch (error) {
+ console.error('Failed to prepare workflow run approval', error);
return {
success: false,
- message: `Failed to create workflow run approval: ${
- error instanceof Error ? error.message : 'Unknown error'
- }`,
+ message:
+ 'Failed to prepare the workflow run approval. Please try again.',
};
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const resolvedWf = await ctx.runQuery( | |
| internal.wf_definitions.internal_queries.resolveWorkflow, | |
| { wfDefinitionId: toId<'wfDefinitions'>(String(wfDefinition._id)) }, | |
| ); | |
| if (!resolvedWf) { | |
| return { | |
| success: false, | |
| message: `Workflow "${wfDefinition.name}" is no longer available.`, | |
| }; | |
| } | |
| if (resolvedWf.organizationId !== organizationId) { | |
| return { | |
| success: false, | |
| message: `Workflow "${wfDefinition.name}" does not belong to the current organization.`, | |
| }; | |
| } | |
| if (resolvedWf.status === 'archived') { | |
| return { | |
| success: false, | |
| message: `Workflow "${wfDefinition.name}" is archived and cannot be executed.`, | |
| }; | |
| } | |
| const startStepConfig = await ctx.runQuery( | |
| internal.wf_definitions.internal_queries.getStartStepConfig, | |
| { wfDefinitionId: resolvedWf._id }, | |
| ); | |
| const runtimeInputSchema = extractInputSchema(startStepConfig); | |
| const validation = validateWorkflowInput(args, runtimeInputSchema); | |
| if (!validation.valid) { | |
| return { | |
| success: false, | |
| message: `Invalid workflow parameters: ${validation.errors.join('; ')}`, | |
| }; | |
| } | |
| const threadId = await getApprovalThreadId(ctx, currentThreadId); | |
| try { | |
| const approvalId = await ctx.runMutation( | |
| internal.agent_tools.workflows.internal_mutations | |
| .createWorkflowRunApproval, | |
| { | |
| organizationId, | |
| workflowId: resolvedWf._id, | |
| workflowName: resolvedWf.name, | |
| workflowDescription: resolvedWf.description, | |
| parameters: args, | |
| threadId, | |
| messageId, | |
| }, | |
| ); | |
| return { | |
| success: true, | |
| requiresApproval: true, | |
| approvalId, | |
| approvalCreated: true, | |
| approvalMessage: `APPROVAL CREATED SUCCESSFULLY: An approval card (ID: ${approvalId}) has been created to run workflow "${resolvedWf.name}". The user must approve this in the chat UI before execution begins.`, | |
| message: `Workflow "${resolvedWf.name}" is ready to run. An approval card has been created. The workflow will start once the user approves it.`, | |
| }; | |
| } catch (error) { | |
| return { | |
| success: false, | |
| message: `Failed to create workflow run approval: ${ | |
| error instanceof Error ? error.message : 'Unknown error' | |
| }`, | |
| try { | |
| const resolvedWf = await ctx.runQuery( | |
| internal.wf_definitions.internal_queries.resolveWorkflow, | |
| { wfDefinitionId: toId<'wfDefinitions'>(String(wfDefinition._id)) }, | |
| ); | |
| if (!resolvedWf) { | |
| return { | |
| success: false, | |
| message: `Workflow "${wfDefinition.name}" is no longer available.`, | |
| }; | |
| } | |
| if (resolvedWf.organizationId !== organizationId) { | |
| return { | |
| success: false, | |
| message: `Workflow "${wfDefinition.name}" does not belong to the current organization.`, | |
| }; | |
| } | |
| if (resolvedWf.status === 'archived') { | |
| return { | |
| success: false, | |
| message: `Workflow "${wfDefinition.name}" is archived and cannot be executed.`, | |
| }; | |
| } | |
| const startStepConfig = await ctx.runQuery( | |
| internal.wf_definitions.internal_queries.getStartStepConfig, | |
| { wfDefinitionId: resolvedWf._id }, | |
| ); | |
| const runtimeInputSchema = extractInputSchema(startStepConfig); | |
| const validation = validateWorkflowInput(args, runtimeInputSchema); | |
| if (!validation.valid) { | |
| return { | |
| success: false, | |
| message: `Invalid workflow parameters: ${validation.errors.join('; ')}`, | |
| }; | |
| } | |
| const threadId = await getApprovalThreadId(ctx, currentThreadId); | |
| const approvalId = await ctx.runMutation( | |
| internal.agent_tools.workflows.internal_mutations | |
| .createWorkflowRunApproval, | |
| { | |
| organizationId, | |
| workflowId: resolvedWf._id, | |
| workflowName: resolvedWf.name, | |
| workflowDescription: resolvedWf.description, | |
| parameters: args, | |
| threadId, | |
| messageId, | |
| }, | |
| ); | |
| return { | |
| success: true, | |
| requiresApproval: true, | |
| approvalId, | |
| approvalCreated: true, | |
| approvalMessage: `APPROVAL CREATED SUCCESSFULLY: An approval card (ID: ${approvalId}) has been created to run workflow "${resolvedWf.name}". The user must approve this in the chat UI before execution begins.`, | |
| message: `Workflow "${resolvedWf.name}" is ready to run. An approval card has been created. The workflow will start once the user approves it.`, | |
| }; | |
| } catch (error) { | |
| console.error('Failed to prepare workflow run approval', error); | |
| return { | |
| success: false, | |
| message: | |
| 'Failed to prepare the workflow run approval. Please try again.', | |
| }; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@services/platform/convex/agent_tools/workflows/create_bound_workflow_tool.ts`
around lines 92 - 163, The prep and approval creation flow currently only wraps
createWorkflowRunApproval in a try/catch; wrap the entire preparation path
(including the calls that can throw:
ctx.runQuery(internal.wf_definitions.internal_queries.resolveWorkflow) which
yields resolvedWf,
ctx.runQuery(internal.wf_definitions.internal_queries.getStartStepConfig) which
yields startStepConfig, extractInputSchema(startStepConfig) producing
runtimeInputSchema, validateWorkflowInput(args, runtimeInputSchema) producing
validation, and getApprovalThreadId(ctx, currentThreadId) producing threadId) in
a single try/catch around the block that leads into calling
internal.agent_tools.workflows.internal_mutations.createWorkflowRunApproval; in
the catch, do not return raw error.message to the caller—log the error details
internally and return a structured { success: false, message } with a safe,
generic message (e.g., "Failed to prepare workflow run") while preserving
existing failure shapes for validation/ownership/status checks.
| async function validateWorkflowBindings( | ||
| ctx: MutationCtx, | ||
| workflowBindings: Id<'wfDefinitions'>[] | undefined, | ||
| organizationId: string, | ||
| ) { | ||
| if (!workflowBindings?.length) return; | ||
|
|
||
| if (workflowBindings.length > MAX_WORKFLOW_BINDINGS) { | ||
| throw new Error( | ||
| `Cannot bind more than ${MAX_WORKFLOW_BINDINGS} workflows to an agent`, | ||
| ); | ||
| } | ||
|
|
||
| for (const wfId of workflowBindings) { | ||
| const wf = await ctx.db.get(wfId); | ||
| if (!wf) { | ||
| throw new Error(`Workflow "${wfId}" not found`); | ||
| } | ||
| if (wf.organizationId !== organizationId) { | ||
| throw new Error( | ||
| `Workflow "${wf.name}" does not belong to this organization`, | ||
| ); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Reject bindings that do not resolve to an active workflow.
This helper only verifies that each wfDefinitions record exists in the same org. The selector/query is returning root ids, though, so a stale or malicious client can still save draft/archived bindings that later fail to materialize as callable tools or get rejected at execution time. Validate that each binding resolves to an active version before persisting it.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@services/platform/convex/custom_agents/mutations.ts` around lines 81 - 105,
The validator currently only checks existence and org on wfDefinitions; extend
validateWorkflowBindings so that for each wfId it also resolves the workflow's
active/published version before accepting the binding: after fetching wf (in
validateWorkflowBindings), fetch the active version (e.g. via wf.activeVersionId
or similar field) using ctx.db.get(activeVersionId) and ensure that the returned
version record exists and is in a published/active state (e.g. version.status
=== 'active' or version.published === true); if no active version is found or it
is archived/draft, throw an Error indicating the binding points to a non-active
workflow version. Ensure you reference the same symbols:
validateWorkflowBindings, workflowBindings, wfDefinitions, wf (and
wf.activeVersionId / wfVersion) so reviewers can locate the change.
| export const getAvailableWorkflows = query({ | ||
| args: { | ||
| organizationId: v.string(), | ||
| }, | ||
| handler: async ( | ||
| ctx, | ||
| args, | ||
| ): Promise<Array<{ id: string; name: string; description?: string }>> => { | ||
| const authUser = await getAuthUserIdentity(ctx); | ||
| if (!authUser) return []; | ||
|
|
||
| const seen = new Map< | ||
| string, | ||
| { id: string; name: string; description?: string; versionNumber: number } | ||
| >(); | ||
|
|
||
| const workflowQuery = ctx.db | ||
| .query('wfDefinitions') | ||
| .withIndex('by_org_status', (q) => | ||
| q.eq('organizationId', args.organizationId).eq('status', 'active'), | ||
| ); | ||
|
|
||
| for await (const wf of workflowQuery) { | ||
| const rootId = String(wf.rootVersionId ?? wf._id); | ||
| const existing = seen.get(rootId); | ||
| if (!existing || wf.versionNumber > existing.versionNumber) { | ||
| seen.set(rootId, { | ||
| id: rootId, | ||
| name: wf.name, | ||
| description: wf.description, | ||
| versionNumber: wf.versionNumber, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| return Array.from(seen.values()).map( | ||
| ({ versionNumber: _, ...rest }) => rest, | ||
| ); | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Guard getAvailableWorkflows with organization access, not just auth.
Line 288 only checks that the caller is signed in. As written, any authenticated user can pass an arbitrary organizationId and enumerate active workflow names/descriptions for that org. Add the same org-membership/visibility gate your other org-scoped public endpoints use before reading wfDefinitions.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@services/platform/convex/custom_agents/queries.ts` around lines 280 - 319,
The getAvailableWorkflows handler currently only checks authentication via
getAuthUserIdentity; before querying wfDefinitions you must enforce organization
access (membership/visibility) the same way other org-scoped endpoints do: call
the shared org-access guard used across the codebase to validate
args.organizationId (e.g., the requireOrgMember/ensureOrgVisible helper your
other endpoints call) after obtaining authUser and before constructing
workflowQuery, and if the check fails return an empty array or appropriate
permission error; keep the rest of the logic unchanged and ensure the guard uses
ctx and authUser to verify access for the provided organizationId.
| // Build bound workflow tools eagerly | ||
| let workflowExtraTools: Record<string, unknown> | undefined; | ||
| if (agentConfig.workflowBindings?.length) { | ||
| workflowExtraTools = {}; | ||
| for (const rootId of agentConfig.workflowBindings) { | ||
| const activeVersion = await ctx.runQuery( | ||
| internal.wf_definitions.internal_queries.getActiveVersionByRoot, | ||
| { rootVersionId: toId<'wfDefinitions'>(rootId) }, | ||
| ); | ||
|
|
||
| if (!activeVersion) { | ||
| debugLog('Skipping bound workflow (no active version)', { rootId }); | ||
| continue; | ||
| } | ||
|
|
||
| if (activeVersion.organizationId !== organizationId) { | ||
| debugLog('Skipping bound workflow (wrong org)', { rootId }); | ||
| continue; | ||
| } | ||
|
|
||
| const startStepConfig = await ctx.runQuery( | ||
| internal.wf_definitions.internal_queries.getStartStepConfig, | ||
| { wfDefinitionId: activeVersion._id }, | ||
| ); | ||
| const inputSchema = extractInputSchema(startStepConfig); | ||
|
|
||
| const toolKey = `workflow_${sanitizeWorkflowName(activeVersion.name)}_${rootId.slice(0, 6)}`; | ||
| workflowExtraTools[toolKey] = createBoundWorkflowTool( | ||
| activeVersion, | ||
| inputSchema, | ||
| ); | ||
| } | ||
|
|
||
| if (Object.keys(workflowExtraTools).length > 0) { | ||
| debugLog('Built bound workflow tools', { | ||
| names: Object.keys(workflowExtraTools), | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
Parallelize bound-workflow hydration.
Line 210 and Line 225 are inside a serial loop, so each binding adds two blocking internal round trips before generation can start. On the chat hot path that directly increases first-token latency for every turn with multiple workflow bindings.
⚡ Proposed refactor
- if (agentConfig.workflowBindings?.length) {
- workflowExtraTools = {};
- for (const rootId of agentConfig.workflowBindings) {
- const activeVersion = await ctx.runQuery(
- internal.wf_definitions.internal_queries.getActiveVersionByRoot,
- { rootVersionId: toId<'wfDefinitions'>(rootId) },
- );
-
- if (!activeVersion) {
- debugLog('Skipping bound workflow (no active version)', { rootId });
- continue;
- }
-
- if (activeVersion.organizationId !== organizationId) {
- debugLog('Skipping bound workflow (wrong org)', { rootId });
- continue;
- }
-
- const startStepConfig = await ctx.runQuery(
- internal.wf_definitions.internal_queries.getStartStepConfig,
- { wfDefinitionId: activeVersion._id },
- );
- const inputSchema = extractInputSchema(startStepConfig);
-
- const toolKey = `workflow_${sanitizeWorkflowName(activeVersion.name)}_${rootId.slice(0, 6)}`;
- workflowExtraTools[toolKey] = createBoundWorkflowTool(
- activeVersion,
- inputSchema,
- );
- }
+ if (agentConfig.workflowBindings?.length) {
+ const workflowEntries = await Promise.all(
+ agentConfig.workflowBindings.map(async (rootId) => {
+ const activeVersion = await ctx.runQuery(
+ internal.wf_definitions.internal_queries.getActiveVersionByRoot,
+ { rootVersionId: toId<'wfDefinitions'>(rootId) },
+ );
+
+ if (!activeVersion) {
+ debugLog('Skipping bound workflow (no active version)', { rootId });
+ return null;
+ }
+
+ if (activeVersion.organizationId !== organizationId) {
+ debugLog('Skipping bound workflow (wrong org)', { rootId });
+ return null;
+ }
+
+ const startStepConfig = await ctx.runQuery(
+ internal.wf_definitions.internal_queries.getStartStepConfig,
+ { wfDefinitionId: activeVersion._id },
+ );
+ const inputSchema = extractInputSchema(startStepConfig);
+ const toolKey = `workflow_${sanitizeWorkflowName(activeVersion.name)}_${rootId.slice(0, 6)}`;
+
+ return [
+ toolKey,
+ createBoundWorkflowTool(activeVersion, inputSchema),
+ ] as const;
+ }),
+ );
+
+ workflowExtraTools = Object.fromEntries(
+ workflowEntries.filter(
+ (entry): entry is readonly [string, unknown] => entry !== null,
+ ),
+ );🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@services/platform/convex/lib/agent_chat/internal_actions.ts` around lines 205
- 243, Serial hydration of bound workflows blocks on each ctx.runQuery; instead
parallelize by mapping agentConfig.workflowBindings to Promise tasks: first call
internal.wf_definitions.internal_queries.getActiveVersionByRoot for all rootIds
with Promise.all, filter out missing or wrong-organization activeVersion results
(emit the same debugLog entries for skipped roots), then for the remaining
activeVersions issue Promise.all of
internal.wf_definitions.internal_queries.getStartStepConfig to fetch
startStepConfig for each activeVersion, call extractInputSchema and
createBoundWorkflowTool for each result, and finally populate workflowExtraTools
using the same toolKey generation (sanitizeWorkflowName + rootId.slice) and emit
the "Built bound workflow tools" debugLog if non-empty; keep references to
agentConfig.workflowBindings, ctx.runQuery, getActiveVersionByRoot,
getStartStepConfig, extractInputSchema, createBoundWorkflowTool,
sanitizeWorkflowName, and workflowExtraTools so reviewers can locate and verify
the change.
Summary
analyze_differencesstep into separate text analysis and JSON clause extraction steps for cleaner separation of concernsTest plan
create_bound_workflow_tool.test.ts,test_chat.test.ts)🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Improvements
Changes