From 6e114aa5fe0e719fd30cc0d186013cda5d033b86 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Fri, 20 Mar 2026 13:46:16 +0800 Subject: [PATCH 1/9] feat(platform): add human input approvals to workflow engine and fix Outlook pagination Implement end-to-end human input request flow for workflow executions, allowing LLM agents to pause and request user input via approval cards. Adds approval queries, mutation handlers, and chat UI components for reviewing and responding to input requests. Also fixes Outlook connector cursor-based pagination with position-based skip to handle sub-second timestamp precision issues. --- examples/integrations/outlook/connector.ts | 41 ++- .../workflows/contract-generation/config.json | 4 +- .../chat/components/chat-interface.tsx | 30 ++- .../components/workflow-run-approval-card.tsx | 239 +++++++++++++++++- .../chat/hooks/use-execution-status.ts | 10 + .../human_input/internal_mutations.ts | 29 ++- .../agent_tools/human_input/mutations.ts | 39 +++ .../human_input/request_human_input_tool.ts | 10 + .../convex/approvals/internal_queries.ts | 70 +++++ services/platform/convex/approvals/queries.ts | 22 ++ .../platform/convex/wf_executions/queries.ts | 1 + .../conversation/conversation_action.ts | 8 + .../engine/dynamic_workflow_handler.ts | 49 +++- .../helpers/engine/execute_step_handler.ts | 51 +++- .../nodes/llm/execute_agent_with_tools.ts | 10 +- .../helpers/nodes/llm/execute_llm_node.ts | 31 +++ .../workflow_engine/internal_actions.ts | 2 + 17 files changed, 626 insertions(+), 20 deletions(-) diff --git a/examples/integrations/outlook/connector.ts b/examples/integrations/outlook/connector.ts index 40aa341f7..5fa85c457 100644 --- a/examples/integrations/outlook/connector.ts +++ b/examples/integrations/outlook/connector.ts @@ -310,16 +310,28 @@ function listMessages( headers: Record, params: Record, ) { - const top = Math.min((params.top as number) || 25, 100); + const requestedTop = Math.min((params.top as number) || 25, 100); const orderby = (params.orderby as string) || 'receivedDateTime desc'; - // Build filter: prefer explicit cursor param, fall back to raw filter string - const cursor = params.cursor as { receivedDateTime?: string } | undefined; + // Build filter: prefer explicit cursor param, fall back to raw filter string. + // Use `ge` (>=) so that messages at the cursor's exact second are included; + // the cursor message itself is excluded by position-based skip below. + const cursor = params.cursor as + | { receivedDateTime?: string; messageId?: string } + | undefined; let filter = (params.filter as string) || ''; if (cursor?.receivedDateTime && !filter) { - filter = 'receivedDateTime gt ' + cursor.receivedDateTime; + filter = 'receivedDateTime ge ' + cursor.receivedDateTime; } + // Over-fetch when cursor is present: Graph API may truncate receivedDateTime + // to seconds while filtering with sub-second precision internally, causing + // messages at the cursor's truncated second to re-appear. Extra buffer lets + // us filter them out and still return genuinely new messages. + const top = cursor?.receivedDateTime + ? Math.min(requestedTop + 10, 100) + : requestedTop; + // Graph API does not support $orderby combined with $filter on conversationId. // When this combination is detected, drop $orderby from the request and sort // the results in memory afterwards. @@ -369,7 +381,26 @@ function listMessages( const data = response.json() as Record; const accountEmail = getAccountEmail(http, headers); - const rawValues = (data.value || []) as GraphMessage[]; + let rawValues = (data.value || []) as GraphMessage[]; + + // Position-based skip: find the cursor message by ID in the asc-ordered + // results and take only messages after it. This avoids timestamp precision + // issues and correctly handles multiple messages within the same second. + // Then trim to the originally requested $top so the caller gets exactly + // the number of messages it asked for. + if (cursor?.messageId) { + const cursorIndex = rawValues.findIndex(function (msg) { + return msg.id === cursor.messageId; + }); + if (cursorIndex !== -1) { + rawValues = rawValues.slice( + cursorIndex + 1, + cursorIndex + 1 + requestedTop, + ); + } + // If cursor message not found (deleted or outside window), keep all results + } + const messages = params.format === 'email' ? rawValues.map(function (msg) { diff --git a/examples/workflows/contract-generation/config.json b/examples/workflows/contract-generation/config.json index 3678fd566..3f0b4602f 100644 --- a/examples/workflows/contract-generation/config.json +++ b/examples/workflows/contract-generation/config.json @@ -106,8 +106,8 @@ "stepType": "llm", "config": { "name": "Batch Modification Planner", - "tools": ["rag_search"], - "systemPrompt": "You are a professional contract lawyer.\n\nYour task is to edit legal text conservatively.\n\nEditing principles:\n- Make minimal necessary changes\n- Preserve at least 85% of the original wording\n- Do NOT rewrite the entire clause\n- Do NOT restructure paragraphs or sections\n- Preserve numbering, references, and formatting\n\nEditing discipline:\n- Prefer small, precise modifications over broad rewrites\n- Only change text where clearly necessary\n- If no meaningful improvement is needed, return the original text unchanged\n\nCRITICAL EDITING RULE:\n- You must REPLACE existing text, not append to it\n- Do NOT keep both original and modified versions\n- The result must be a clean, single, final version of the clause\n\nSTRICTLY AVOID:\n- Duplicated sentences\n- Conflicting formulations\n- Partially merged text\n\nIf a sentence is modified, remove or fully integrate the original version.\n\nConsistency:\n- Ensure the revised text remains internally consistent\n- Avoid introducing new legal concepts unless required by the instruction\n\nCROSS-BATCH CONSISTENCY:\nYou may receive PREVIOUS BATCH MODIFICATIONS showing changes from the immediately preceding batch. Use this to maintain consistency — if a term, cap, threshold, or defined term was changed, align your modifications accordingly.\n\nLANGUAGE PRESERVATION: Output text MUST be in the SAME LANGUAGE as the input paragraph. NEVER translate.\n\nRAG DISCOVERY:\n- ONLY search for paragraphs you have already decided need modification based on the user requirements. Do NOT search for paragraphs that do not need changes.\n- Use rag_search with the provided template file IDs to find relevant template language\n- Run 2 or 3 diverse queries per paragraph you plan to modify (clause topic, key legal terms)\n- Templates are the ONLY source of clause language — NEVER invent clause text\n- If rag_search returns no relevant language, do NOT modify that paragraph\n- Always pass topK: 2 in every rag_search call\n\nPARAGRAPH BOUNDARIES: Do NOT merge or split paragraphs. Each modification replaces exactly one paragraph.\nDEFINED TERMS: Preserve defined terms in their original language.\nNO PLACEHOLDERS: Do not use [X], [TBD], or similar.\nEMPTY PARAGRAPHS: Skip entirely.\n\nThe output clause must read like a clean, final legal draft ready for review by a lawyer.\n\nOUTPUT FORMAT:\nFor each paragraph that needs changes:\n--- MODIFICATION ---\nKey: [paragraph key]\nNew text: [complete replacement text]\n--- END ---\n\nOnly include paragraphs that actually need changes. If no paragraphs need changes: \"No modifications needed for this batch.\"", + "tools": ["rag_search", "request_human_input"], + "systemPrompt": "You are a professional contract lawyer.\n\nYour task is to edit legal text conservatively.\n\nEditing principles:\n- Make minimal necessary changes\n- Preserve at least 85% of the original wording\n- Do NOT rewrite the entire clause\n- Do NOT restructure paragraphs or sections\n- Preserve numbering, references, and formatting\n\nEditing discipline:\n- Prefer small, precise modifications over broad rewrites\n- Only change text where clearly necessary\n- If no meaningful improvement is needed, return the original text unchanged\n\nCRITICAL EDITING RULE:\n- You must REPLACE existing text, not append to it\n- Do NOT keep both original and modified versions\n- The result must be a clean, single, final version of the clause\n\nSTRICTLY AVOID:\n- Duplicated sentences\n- Conflicting formulations\n- Partially merged text\n\nIf a sentence is modified, remove or fully integrate the original version.\n\nConsistency:\n- Ensure the revised text remains internally consistent\n- Avoid introducing new legal concepts unless required by the instruction\n\nCROSS-BATCH CONSISTENCY:\nYou may receive PREVIOUS BATCH MODIFICATIONS showing changes from the immediately preceding batch. Use this to maintain consistency — if a term, cap, threshold, or defined term was changed, align your modifications accordingly.\n\nLANGUAGE PRESERVATION: Output text MUST be in the SAME LANGUAGE as the input paragraph. NEVER translate.\n\nRAG DISCOVERY:\n- ONLY search for paragraphs you have already decided need modification based on the user requirements. Do NOT search for paragraphs that do not need changes.\n- Use rag_search with the provided template file IDs to find relevant template language\n- Run 2 or 3 diverse queries per paragraph you plan to modify (clause topic, key legal terms)\n- Templates are the ONLY source of clause language — NEVER invent clause text\n- If rag_search returns no relevant language, do NOT modify that paragraph\n- Always pass topK: 2 in every rag_search call\n\nPARAGRAPH BOUNDARIES: Do NOT merge or split paragraphs. Each modification replaces exactly one paragraph.\nDEFINED TERMS: Preserve defined terms in their original language.\nPLACEHOLDERS: When you encounter fill-in placeholders ([●], [TBD], [___], [Datum], etc.), use the request_human_input tool to ask the user for the actual values. Do not invent values or leave placeholders unfilled.\nEMPTY PARAGRAPHS: Skip entirely.\n\nUSER CONFIRMATION:\nWhen you encounter ANY of the following, use the request_human_input tool to ask the user before proceeding:\n- Fill-in placeholders: [●], [___], [Datum], [TBD], or similar blank fields that need specific values\n- Missing key information: party names, addresses, dates, registration numbers, or other identifying details\n- Ambiguous business decisions: liability caps, warranty periods, indemnification limits, governing law, jurisdiction, or other terms that require a commercial decision\n- Unclear requirements: if the user's instructions are ambiguous about how to modify a specific clause\nWhen calling request_human_input:\n- Collect ALL uncertain items from the current batch into a SINGLE request (do not make multiple calls)\n- Use text_input format for open-ended questions, single_select for binary choices, yes_no for confirmations\n- Provide clear context about what the placeholder or decision is about\n- If prior user responses are available (via ), use those values directly — do NOT re-ask\n\nThe output clause must read like a clean, final legal draft ready for review by a lawyer.\n\nOUTPUT FORMAT:\nFor each paragraph that needs changes:\n--- MODIFICATION ---\nKey: [paragraph key]\nNew text: [complete replacement text]\n--- END ---\n\nOnly include paragraphs that actually need changes. If no paragraphs need changes: \"No modifications needed for this batch.\"", "userPrompt": "PREVIOUS BATCH MODIFICATIONS:\n{{variables.previousBatchMods}}\n\nPARAGRAPHS IN THIS BATCH:\n{{loop.item}}\n\nTEMPLATE FILE IDS (pass these as fileIds to rag_search):\n{{input.templateFileIds}}\n\nUSER REQUIREMENTS:\n{{input.requirements}}", "outputFormat": "text" }, diff --git a/services/platform/app/features/chat/components/chat-interface.tsx b/services/platform/app/features/chat/components/chat-interface.tsx index 74d4b8274..38f97a655 100644 --- a/services/platform/app/features/chat/components/chat-interface.tsx +++ b/services/platform/app/features/chat/components/chat-interface.tsx @@ -2,7 +2,7 @@ import { m, AnimatePresence } from 'framer-motion'; import { ArrowDown } from 'lucide-react'; -import { useRef, useEffect, useState, useCallback } from 'react'; +import { useRef, useEffect, useState, useCallback, useMemo } from 'react'; import { PanelFooter } from '@/app/components/layout/panel-footer'; import { FileUpload } from '@/app/components/ui/forms/file-upload'; @@ -142,6 +142,25 @@ export function ChatInterface({ threadId, ); + // Block input when any pending approval exists + const hasPendingApproval = useMemo( + () => + (integrationApprovals ?? []).some((a) => a.status === 'pending') || + (workflowCreationApprovals ?? []).some((a) => a.status === 'pending') || + (workflowUpdateApprovals ?? []).some((a) => a.status === 'pending') || + (workflowRunApprovals ?? []).some((a) => a.status === 'pending') || + (humanInputRequests ?? []).some((a) => a.status === 'pending') || + (documentWriteApprovals ?? []).some((a) => a.status === 'pending'), + [ + integrationApprovals, + workflowCreationApprovals, + workflowUpdateApprovals, + workflowRunApprovals, + humanInputRequests, + documentWriteApprovals, + ], + ); + // Merge messages with approvals and human input requests const mergedChatItems = useMergedChatItems({ messages, @@ -358,7 +377,14 @@ export function ChatInterface({ onSendMessage={handleSendMessage} onStopGenerating={stopGenerating} isLoading={isLoading} - disabled={hasNoAgents} + disabled={hasNoAgents || hasPendingApproval} + disabledReason={ + hasNoAgents + ? 'no-agents' + : hasPendingApproval + ? 'pending-approval' + : undefined + } organizationId={organizationId} attachments={attachments} uploadingFiles={uploadingFiles} diff --git a/services/platform/app/features/chat/components/workflow-run-approval-card.tsx b/services/platform/app/features/chat/components/workflow-run-approval-card.tsx index f714216a2..c2fc9fa80 100644 --- a/services/platform/app/features/chat/components/workflow-run-approval-card.tsx +++ b/services/platform/app/features/chat/components/workflow-run-approval-card.tsx @@ -7,16 +7,19 @@ import { ChevronRight, ExternalLink, Loader2, + MessageCircleQuestion, Play, + Send, Square, XCircle, } from 'lucide-react'; -import { memo, useEffect, useState } from 'react'; +import { memo, useCallback, useEffect, useState } from 'react'; import type { Id } from '@/convex/_generated/dataModel'; import type { WorkflowRunMetadata } from '@/convex/approvals/types'; import { Badge } from '@/app/components/ui/feedback/badge'; +import { Textarea } from '@/app/components/ui/forms/textarea'; import { ActionRow } from '@/app/components/ui/layout/action-row'; import { HStack, Stack } from '@/app/components/ui/layout/layout'; import { Tooltip } from '@/app/components/ui/overlays/tooltip'; @@ -26,9 +29,11 @@ import { useExecuteApprovedWorkflowRun, useUpdateApprovalStatus, } from '@/app/features/chat/hooks/mutations'; +import { useSubmitHumanInputResponse } from '@/app/features/chat/hooks/mutations'; import { useCancelExecution, useExecutionStatus, + useWorkflowHumanInputApproval, } from '@/app/features/chat/hooks/use-execution-status'; import { useAuth } from '@/app/hooks/use-convex-auth'; import { useT } from '@/lib/i18n/client'; @@ -100,6 +105,21 @@ function WorkflowRunApprovalCardComponent({ executionStatus?.status === 'pending' || executionStatus?.status === 'running'; + // Human input request for paused workflow + const waitingForApprovalId = + executionStatus?.status === 'running' && executionStatus?.waitingFor + ? executionStatus.waitingFor + : undefined; + const { data: humanInputApproval } = + useWorkflowHumanInputApproval(waitingForApprovalId); + const isWaitingForHumanInput = !!waitingForApprovalId && !!humanInputApproval; + + const { mutate: submitHumanInput, isPending: isSubmittingHumanInput } = + useSubmitHumanInputResponse(); + const [humanInputValue, setHumanInputValue] = useState(''); + const [humanInputSelected, setHumanInputSelected] = useState(''); + const [humanInputMulti, setHumanInputMulti] = useState([]); + useEffect(() => { if (!isRunning || !executionStatus?.startedAt) return; setElapsed(formatElapsed(executionStatus.startedAt)); @@ -240,8 +260,40 @@ function WorkflowRunApprovalCardComponent({ )} + {/* Human Input Request (paused workflow waiting for user response) */} + {isWaitingForHumanInput && humanInputApproval && ( + { + submitHumanInput( + { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- _id from query result is a string at runtime but typed as Id<'approvals'> by Convex + approvalId: humanInputApproval._id as Id<'approvals'>, + response, + }, + { + onError: (err) => { + setError( + err instanceof Error + ? err.message + : 'Failed to submit response', + ); + }, + }, + ); + }} + /> + )} + {/* Live Execution Status */} - {status === 'approved' && executionId && ( + {status === 'approved' && executionId && !isWaitingForHumanInput && ( {isRunning && ( <> @@ -436,6 +488,189 @@ function WorkflowRunApprovalCardComponent({ ); } +interface WorkflowHumanInputSectionProps { + approval: { + _id: string; + metadata?: Record | null; + }; + inputValue: string; + onInputChange: (value: string) => void; + selectedValue: string; + onSelectedChange: (value: string) => void; + multiValues: string[]; + onMultiChange: (values: string[]) => void; + isSubmitting: boolean; + onSubmit: (response: string | string[]) => void; +} + +function WorkflowHumanInputSection({ + approval, + inputValue, + onInputChange, + selectedValue, + onSelectedChange, + multiValues, + onMultiChange, + isSubmitting, + onSubmit, +}: WorkflowHumanInputSectionProps) { + const meta = approval.metadata ?? {}; + const question = typeof meta.question === 'string' ? meta.question : ''; + const context = typeof meta.context === 'string' ? meta.context : undefined; + const format = typeof meta.format === 'string' ? meta.format : 'text_input'; + const rawOptions = Array.isArray(meta.options) ? meta.options : []; + const options = rawOptions.filter( + (opt): opt is { label: string; value?: string; description?: string } => + typeof opt === 'object' && + opt !== null && + 'label' in opt && + typeof opt.label === 'string', + ); + + const getOptionValue = (opt: { label: string; value?: string }) => + opt.value ?? opt.label; + + const handleSubmit = useCallback(() => { + switch (format) { + case 'text_input': + if (inputValue.trim()) onSubmit(inputValue.trim()); + break; + case 'single_select': + case 'yes_no': + if (selectedValue) onSubmit(selectedValue); + break; + case 'multi_select': + if (multiValues.length > 0) onSubmit(multiValues); + break; + } + }, [format, inputValue, selectedValue, multiValues, onSubmit]); + + return ( + + + + + {question} + + + {context && ( + + {context} + + )} + + {format === 'text_input' && ( +