diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/resume.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/resume.py index d09eec6af..0bb477b6b 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/resume.py +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/resume.py @@ -22,7 +22,12 @@ def hitlrequest_action_count(pending_value: Any) -> int: def fan_out_decisions_to_match(resume_value: Any, expected_count: int) -> Any: - """Pad a single-decision resume to N entries so an ``action_requests=N`` bundle accepts it.""" + """Legacy fallback: pad a 1-decision resume to N for an ``action_requests=N`` bundle. + + Modern frontend submits N decisions per bundle (one per action_request) so + this is a no-op; kept for backwards compatibility with old in-flight + threads or non-bundle clients that send a single decision. + """ if expected_count <= 1: return resume_value if not isinstance(resume_value, dict): diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/task_tool.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/task_tool.py index e7458dde9..2458f72c4 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/task_tool.py +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/task_tool.py @@ -139,13 +139,6 @@ def build_task_tool_with_parent_config( if resume_value is not None: expected = hitlrequest_action_count(pending_value) resume_value = fan_out_decisions_to_match(resume_value, expected) - logger.info( - "Forwarding surfsense_resume_value into subagent %r " - "(action_requests=%d, targeted_id=%s)", - subagent_type, - expected, - pending_id is not None, - ) result = subagent.invoke( build_resume_command(resume_value, pending_id), config=sub_config, @@ -211,13 +204,6 @@ def build_task_tool_with_parent_config( if resume_value is not None: expected = hitlrequest_action_count(pending_value) resume_value = fan_out_decisions_to_match(resume_value, expected) - logger.info( - "Forwarding surfsense_resume_value into subagent %r " - "(action_requests=%d, targeted_id=%s)", - subagent_type, - expected, - pending_id is not None, - ) result = await subagent.ainvoke( build_resume_command(resume_value, pending_id), config=sub_config, diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/main_agent_tool_routing.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/main_agent_tool_routing.md index d31e24ce9..e91075c35 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/main_agent_tool_routing.md +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/main_agent_tool_routing.md @@ -13,4 +13,15 @@ later in this system prompt, after citation rules). Do not treat live third-party state as if it were already in the indexed knowledge base; reach it via **task**. + +Never emit more than one **task** tool call in the same turn. Bundle related work +for the same specialist into a single **task** invocation (the subagent itself can +call its own tools in parallel inside that one run). Parallel **task** calls would +fan out into multiple concurrent subagent runs whose human-approval interrupts +cannot be coordinated; one **task** at a time is required. + + \ No newline at end of file diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 7d359b7ab..8288fb75a 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -2758,7 +2758,6 @@ async def stream_resume_chat( fs_mode, fs_platform, ) - from app.services.token_tracking_service import start_turn accumulator = start_turn() diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 21fc4cf1a..533aefa89 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -97,6 +97,7 @@ import { type NewChatUserImagePayload, } from "@/lib/chat/user-turn-api-parts"; import { NotFoundError } from "@/lib/error"; +import { type BundleSubmit, HitlBundleProvider } from "@/lib/hitl"; import { trackChatCreated, trackChatError, @@ -147,28 +148,59 @@ function markInterruptsCompleted(contentParts: Array<{ type: string; result?: un } /** - * Most recent pending tool-call card with this name, so a new HITL interrupt - * does not overwrite an already-approved card with the same tool name. + * Generate a synthetic ``toolCallId`` for an action_request that has no + * matching streamed tool-call card (HITL-blocked subagent calls don't surface + * as tool-call events). Suffixes a counter when the base id is already taken + * โ€” sequential interrupts for the same tool name otherwise collide on + * ``interrupt-${name}-${i}`` and crash assistant-ui with a duplicate-key error. */ -function findHitlTargetToolCallId( +function freshSynthToolCallId( + toolCallIndices: Map, + toolName: string, + index: number +): string { + const base = `interrupt-${toolName}-${index}`; + if (!toolCallIndices.has(base)) return base; + let n = 1; + while (toolCallIndices.has(`${base}-${n}`)) n++; + return `${base}-${n}`; +} + +/** + * Pair each ``action_request`` to a unique pending tool-call card, preserving + * order so ``decisions[i]`` lines up with ``action_requests[i]`` on the wire. + * + * Same-name bundles (e.g. three ``create_jira_issue``) used to collapse onto + * one card because the matcher keyed by name; this consumes each card via the + * ``claimed`` set and walks forward in DOM order. + */ +function pairBundleToolCallIds( toolCallIndices: Map, contentParts: Array<{ type: string; toolName?: string; result?: unknown; }>, - toolName: string -): string | null { - const entries = Array.from(toolCallIndices.entries()); - for (let i = entries.length - 1; i >= 0; i--) { - const [tcId, idx] = entries[i]; - const part = contentParts[idx]; - if (!part || part.type !== "tool-call" || part.toolName !== toolName) continue; - const result = part.result as Record | undefined | null; - if (result == null) return tcId; - if (result.__interrupt__ === true && !result.__decided__) return tcId; + actionRequests: ReadonlyArray<{ name: string }> +): Array { + const claimed = new Set(); + const paired: Array = []; + for (const action of actionRequests) { + let matched: string | null = null; + for (const [tcId, idx] of toolCallIndices) { + if (claimed.has(tcId)) continue; + const part = contentParts[idx]; + if (!part || part.type !== "tool-call" || part.toolName !== action.name) continue; + const result = part.result as Record | undefined | null; + if (result == null || (result.__interrupt__ === true && !result.__decided__)) { + matched = tcId; + claimed.add(tcId); + break; + } + } + paired.push(matched); } - return null; + return paired; } /** @@ -249,6 +281,7 @@ export default function NewChatPage() { threadId: number; assistantMsgId: string; interruptData: Record; + bundleToolCallIds: string[]; } | null>(null); const toolsWithUI = TOOLS_WITH_UI_ALL; @@ -973,23 +1006,26 @@ export default function NewChatPage() { name: string; args: Record; }>; - for (const action of actionRequests) { - const targetTcId = findHitlTargetToolCallId( - toolCallIndices, - contentParts, - action.name - ); - if (targetTcId) { - updateToolCall(contentPartsState, targetTcId, { - result: { __interrupt__: true, ...interruptData }, - }); - } else { - const tcId = `interrupt-${action.name}`; - addToolCall(contentPartsState, toolsWithUI, tcId, action.name, action.args, true); - updateToolCall(contentPartsState, tcId, { - result: { __interrupt__: true, ...interruptData }, - }); + const paired = pairBundleToolCallIds(toolCallIndices, contentParts, actionRequests); + const bundleToolCallIds: string[] = []; + for (let i = 0; i < actionRequests.length; i++) { + const action = actionRequests[i]; + let targetTcId = paired[i]; + if (!targetTcId) { + targetTcId = freshSynthToolCallId(toolCallIndices, action.name, i); + addToolCall( + contentPartsState, + toolsWithUI, + targetTcId, + action.name, + action.args, + true + ); } + updateToolCall(contentPartsState, targetTcId, { + result: { __interrupt__: true, ...interruptData }, + }); + bundleToolCallIds.push(targetTcId); } setMessages((prev) => prev.map((m) => @@ -1003,6 +1039,7 @@ export default function NewChatPage() { threadId: currentThreadId, assistantMsgId, interruptData, + bundleToolCallIds, }); } break; @@ -1414,29 +1451,26 @@ export default function NewChatPage() { name: string; args: Record; }>; - for (const action of actionRequests) { - const targetTcId = findHitlTargetToolCallId( - toolCallIndices, - contentParts, - action.name - ); - if (targetTcId) { - updateToolCall(contentPartsState, targetTcId, { - result: { - __interrupt__: true, - ...interruptData, - }, - }); - } else { - const tcId = `interrupt-${action.name}`; - addToolCall(contentPartsState, toolsWithUI, tcId, action.name, action.args, true); - updateToolCall(contentPartsState, tcId, { - result: { - __interrupt__: true, - ...interruptData, - }, - }); + const paired = pairBundleToolCallIds(toolCallIndices, contentParts, actionRequests); + const bundleToolCallIds: string[] = []; + for (let i = 0; i < actionRequests.length; i++) { + const action = actionRequests[i]; + let targetTcId = paired[i]; + if (!targetTcId) { + targetTcId = freshSynthToolCallId(toolCallIndices, action.name, i); + addToolCall( + contentPartsState, + toolsWithUI, + targetTcId, + action.name, + action.args, + true + ); } + updateToolCall(contentPartsState, targetTcId, { + result: { __interrupt__: true, ...interruptData }, + }); + bundleToolCallIds.push(targetTcId); } setMessages((prev) => prev.map((m) => @@ -1449,6 +1483,7 @@ export default function NewChatPage() { threadId: resumeThreadId, assistantMsgId, interruptData, + bundleToolCallIds, }); break; } @@ -1538,76 +1573,116 @@ export default function NewChatPage() { edited_action?: { name: string; args: Record }; }>; }; - if (detail?.decisions && pendingInterrupt) { - const decision = detail.decisions[0]; - const decisionType = decision?.type as "approve" | "reject" | "edit"; + if (!detail?.decisions || !pendingInterrupt) return; + const incoming = detail.decisions; + if (incoming.length === 0) return; + const tcIds = pendingInterrupt.bundleToolCallIds; + const N = tcIds.length; - // Fan a single click out to N decisions when the backend bundled - // N tool calls into one HITLRequest (one Approve/Reject covers - // the whole batch until per-card decisions land). - const interruptData = pendingInterrupt.interruptData as - | { action_requests?: unknown[] } - | undefined; - const expectedCount = Array.isArray(interruptData?.action_requests) - ? interruptData.action_requests.length - : detail.decisions.length; - const submittedDecisions = - detail.decisions.length >= expectedCount || expectedCount <= 1 - ? detail.decisions - : [ - ...detail.decisions, - ...Array.from({ length: expectedCount - detail.decisions.length }, () => ({ - ...detail.decisions[detail.decisions.length - 1], - })), - ]; - - setMessages((prev) => - prev.map((m) => { - if (m.id !== pendingInterrupt.assistantMsgId) return m; - const parts = m.content as unknown as Array>; - const newContent = parts.map((part) => { - if ( - part.type === "tool-call" && - typeof part.result === "object" && - part.result !== null && - "__interrupt__" in part.result - ) { - // For edit decisions, also update the displayed args - if (decisionType === "edit" && decision.edited_action) { - return { - ...part, - args: decision.edited_action.args, // Update displayed args - // Sync argsText so the rendered card shows - // the edited inputs โ€” assistant-ui prefers - // caller-supplied argsText over - // JSON.stringify(args). - argsText: JSON.stringify(decision.edited_action.args, null, 2), - result: { - ...(part.result as Record), - __decided__: decisionType, - }, - }; - } - return { - ...part, - result: { - ...(part.result as Record), - __decided__: decisionType, - }, - }; - } - return part; - }); - return { ...m, content: newContent as unknown as ThreadMessageLike["content"] }; - }) - ); - handleResume(submittedDecisions); + // Build a per-card decision map. Bundle path: one decision per + // action_request in order. Legacy single-click on a multi-card + // interrupt: replay the last decision across the bundle. + const byTcId = new Map(); + if (incoming.length === N) { + for (let i = 0; i < N; i++) byTcId.set(tcIds[i], incoming[i]); + } else { + const fallback = incoming[incoming.length - 1]; + for (const tcId of tcIds) byTcId.set(tcId, fallback); } + const submittedDecisions = tcIds.map((id) => byTcId.get(id)!); + + setMessages((prev) => + prev.map((m) => { + if (m.id !== pendingInterrupt.assistantMsgId) return m; + const parts = m.content as unknown as Array>; + const newContent = parts.map((part) => { + const tcId = part.toolCallId as string | undefined; + const d = tcId ? byTcId.get(tcId) : undefined; + if (!d || part.type !== "tool-call") return part; + if (typeof part.result !== "object" || part.result === null) return part; + if (!("__interrupt__" in (part.result as Record))) return part; + const decided = d.type as "approve" | "reject" | "edit"; + if (decided === "edit" && d.edited_action) { + return { + ...part, + args: d.edited_action.args, + // Sync argsText so the card renders the edited + // inputs (assistant-ui prefers it over JSON.stringify). + argsText: JSON.stringify(d.edited_action.args, null, 2), + result: { + ...(part.result as Record), + __decided__: decided, + }, + }; + } + return { + ...part, + result: { + ...(part.result as Record), + __decided__: decided, + }, + }; + }); + return { ...m, content: newContent as unknown as ThreadMessageLike["content"] }; + }) + ); + handleResume(submittedDecisions); }; window.addEventListener("hitl-decision", handler); return () => window.removeEventListener("hitl-decision", handler); }, [handleResume, pendingInterrupt]); + // Mirror staged bundle decisions onto the cards visually so prev/next nav + // reflects past choices instead of re-prompting. Submit's ``hitl-decision`` + // handler still runs the actual resume. + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail as { + toolCallId: string; + decision: { + type: string; + message?: string; + edited_action?: { name: string; args: Record }; + }; + }; + if (!detail?.toolCallId || !detail?.decision || !pendingInterrupt) return; + setMessages((prev) => + prev.map((m) => { + if (m.id !== pendingInterrupt.assistantMsgId) return m; + const parts = m.content as unknown as Array>; + const newContent = parts.map((part) => { + if (part.toolCallId !== detail.toolCallId) return part; + if (part.type !== "tool-call") return part; + if (typeof part.result !== "object" || part.result === null) return part; + if (!("__interrupt__" in (part.result as Record))) return part; + const decided = detail.decision.type as "approve" | "reject" | "edit"; + if (decided === "edit" && detail.decision.edited_action) { + return { + ...part, + args: detail.decision.edited_action.args, + argsText: JSON.stringify(detail.decision.edited_action.args, null, 2), + result: { + ...(part.result as Record), + __decided__: decided, + }, + }; + } + return { + ...part, + result: { + ...(part.result as Record), + __decided__: decided, + }, + }; + }); + return { ...m, content: newContent as unknown as ThreadMessageLike["content"] }; + }) + ); + }; + window.addEventListener("hitl-stage", handler); + return () => window.removeEventListener("hitl-stage", handler); + }, [pendingInterrupt]); + // Convert message (pass through since already in correct format) const convertMessage = useCallback( (message: ThreadMessageLike): ThreadMessageLike => message, @@ -2151,6 +2226,12 @@ export default function NewChatPage() { [handleRegenerate, messages, agentActionItems] ); + const handleBundleSubmit = useCallback((orderedDecisions) => { + window.dispatchEvent( + new CustomEvent("hitl-decision", { detail: { decisions: orderedDecisions } }) + ); + }, []); + const handleEditDialogChoice = useCallback( async (choice: EditMessageDialogChoice) => { const pending = editDialogState; @@ -2220,14 +2301,19 @@ export default function NewChatPage() { -
-
- + +
+
+ +
+ + +
- - - -
+ { diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index bfe0434b4..048837c89 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -40,6 +40,7 @@ import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container"; import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet"; +import { withBundleStep } from "@/components/hitl-bundle-pager"; import type { SerializableCitation } from "@/components/tool-ui/citation"; import { openSafeNavigationHref, @@ -484,6 +485,51 @@ const MessageInfoDropdown: FC = () => { ); }; +// Wrap each tool-ui card with ``withBundleStep`` so multi-card HITL bundles +// page through them and stage decisions instead of firing one resume per card. +const TOOLS_BY_NAME = { + generate_report: withBundleStep(GenerateReportToolUI), + generate_resume: withBundleStep(GenerateResumeToolUI), + generate_podcast: withBundleStep(GeneratePodcastToolUI), + generate_video_presentation: withBundleStep(GenerateVideoPresentationToolUI), + display_image: withBundleStep(GenerateImageToolUI), + generate_image: withBundleStep(GenerateImageToolUI), + update_memory: withBundleStep(UpdateMemoryToolUI), + execute: withBundleStep(SandboxExecuteToolUI), + execute_code: withBundleStep(SandboxExecuteToolUI), + create_notion_page: withBundleStep(CreateNotionPageToolUI), + update_notion_page: withBundleStep(UpdateNotionPageToolUI), + delete_notion_page: withBundleStep(DeleteNotionPageToolUI), + create_linear_issue: withBundleStep(CreateLinearIssueToolUI), + update_linear_issue: withBundleStep(UpdateLinearIssueToolUI), + delete_linear_issue: withBundleStep(DeleteLinearIssueToolUI), + create_google_drive_file: withBundleStep(CreateGoogleDriveFileToolUI), + delete_google_drive_file: withBundleStep(DeleteGoogleDriveFileToolUI), + create_onedrive_file: withBundleStep(CreateOneDriveFileToolUI), + delete_onedrive_file: withBundleStep(DeleteOneDriveFileToolUI), + create_dropbox_file: withBundleStep(CreateDropboxFileToolUI), + delete_dropbox_file: withBundleStep(DeleteDropboxFileToolUI), + create_calendar_event: withBundleStep(CreateCalendarEventToolUI), + update_calendar_event: withBundleStep(UpdateCalendarEventToolUI), + delete_calendar_event: withBundleStep(DeleteCalendarEventToolUI), + create_gmail_draft: withBundleStep(CreateGmailDraftToolUI), + update_gmail_draft: withBundleStep(UpdateGmailDraftToolUI), + send_gmail_email: withBundleStep(SendGmailEmailToolUI), + trash_gmail_email: withBundleStep(TrashGmailEmailToolUI), + create_jira_issue: withBundleStep(CreateJiraIssueToolUI), + update_jira_issue: withBundleStep(UpdateJiraIssueToolUI), + delete_jira_issue: withBundleStep(DeleteJiraIssueToolUI), + create_confluence_page: withBundleStep(CreateConfluencePageToolUI), + update_confluence_page: withBundleStep(UpdateConfluencePageToolUI), + delete_confluence_page: withBundleStep(DeleteConfluencePageToolUI), + web_search: () => null, + link_preview: () => null, + multi_link_preview: () => null, + scrape_webpage: () => null, +} as const; + +const TOOLS_FALLBACK = withBundleStep(ToolFallback); + const AssistantMessageInner: FC = () => { const isMobile = !useMediaQuery("(min-width: 768px)"); @@ -495,47 +541,8 @@ const AssistantMessageInner: FC = () => { Text: MarkdownText, Reasoning: ReasoningMessagePart, tools: { - by_name: { - generate_report: GenerateReportToolUI, - generate_resume: GenerateResumeToolUI, - generate_podcast: GeneratePodcastToolUI, - generate_video_presentation: GenerateVideoPresentationToolUI, - display_image: GenerateImageToolUI, - generate_image: GenerateImageToolUI, - update_memory: UpdateMemoryToolUI, - execute: SandboxExecuteToolUI, - execute_code: SandboxExecuteToolUI, - create_notion_page: CreateNotionPageToolUI, - update_notion_page: UpdateNotionPageToolUI, - delete_notion_page: DeleteNotionPageToolUI, - create_linear_issue: CreateLinearIssueToolUI, - update_linear_issue: UpdateLinearIssueToolUI, - delete_linear_issue: DeleteLinearIssueToolUI, - create_google_drive_file: CreateGoogleDriveFileToolUI, - delete_google_drive_file: DeleteGoogleDriveFileToolUI, - create_onedrive_file: CreateOneDriveFileToolUI, - delete_onedrive_file: DeleteOneDriveFileToolUI, - create_dropbox_file: CreateDropboxFileToolUI, - delete_dropbox_file: DeleteDropboxFileToolUI, - create_calendar_event: CreateCalendarEventToolUI, - update_calendar_event: UpdateCalendarEventToolUI, - delete_calendar_event: DeleteCalendarEventToolUI, - create_gmail_draft: CreateGmailDraftToolUI, - update_gmail_draft: UpdateGmailDraftToolUI, - send_gmail_email: SendGmailEmailToolUI, - trash_gmail_email: TrashGmailEmailToolUI, - create_jira_issue: CreateJiraIssueToolUI, - update_jira_issue: UpdateJiraIssueToolUI, - delete_jira_issue: DeleteJiraIssueToolUI, - create_confluence_page: CreateConfluencePageToolUI, - update_confluence_page: UpdateConfluencePageToolUI, - delete_confluence_page: DeleteConfluencePageToolUI, - web_search: () => null, - link_preview: () => null, - multi_link_preview: () => null, - scrape_webpage: () => null, - }, - Fallback: ToolFallback, + by_name: TOOLS_BY_NAME, + Fallback: TOOLS_FALLBACK, }, }} /> diff --git a/surfsense_web/components/hitl-bundle-pager/index.ts b/surfsense_web/components/hitl-bundle-pager/index.ts new file mode 100644 index 000000000..ce434d224 --- /dev/null +++ b/surfsense_web/components/hitl-bundle-pager/index.ts @@ -0,0 +1,2 @@ +export { PagerChrome } from "./pager-chrome"; +export { withBundleStep } from "./with-bundle-step"; diff --git a/surfsense_web/components/hitl-bundle-pager/pager-chrome.tsx b/surfsense_web/components/hitl-bundle-pager/pager-chrome.tsx new file mode 100644 index 000000000..77d75fb6d --- /dev/null +++ b/surfsense_web/components/hitl-bundle-pager/pager-chrome.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useHitlBundle } from "@/lib/hitl"; + +/** + * Prev/next nav and Submit for the current step of an active HITL bundle. + * Submission is gated on every action_request having a staged decision. + */ +export function PagerChrome() { + const bundle = useHitlBundle(); + if (!bundle) return null; + + const total = bundle.toolCallIds.length; + const step = bundle.currentStep; + const allStaged = bundle.stagedCount === total; + + return ( +
+ + + {step + 1} / {total} + + ยท + + {bundle.stagedCount} of {total} decided + + +
+ +
+
+ ); +} diff --git a/surfsense_web/components/hitl-bundle-pager/with-bundle-step.tsx b/surfsense_web/components/hitl-bundle-pager/with-bundle-step.tsx new file mode 100644 index 000000000..64ac801fb --- /dev/null +++ b/surfsense_web/components/hitl-bundle-pager/with-bundle-step.tsx @@ -0,0 +1,37 @@ +"use client"; + +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; +import type { ComponentType } from "react"; +import { ToolCallIdProvider, useHitlBundle } from "@/lib/hitl"; +import { PagerChrome } from "./pager-chrome"; + +/** + * Wrap a tool-ui card so that, when a multi-card HITL bundle is active: + * - cards belonging to the bundle but not the current step render ``null``; + * - the current-step card renders normally and is followed by ``PagerChrome``. + * + * Cards stay completely unchanged โ€” the wrapper provides the + * ``ToolCallIdContext`` that ``useHitlDecision`` reads to stage decisions + * against the right ``toolCallId`` instead of firing the global event. + */ +export function withBundleStep

>( + Component: ComponentType

+): ComponentType

{ + function BundleStepWrapped(props: P) { + const bundle = useHitlBundle(); + const toolCallId = props.toolCallId; + const inBundle = bundle?.isInBundle(toolCallId) ?? false; + const isStep = bundle?.isCurrentStep(toolCallId) ?? false; + + if (bundle && inBundle && !isStep) return null; + + return ( + + + {bundle && isStep ? : null} + + ); + } + BundleStepWrapped.displayName = `withBundleStep(${Component.displayName ?? Component.name ?? "ToolUI"})`; + return BundleStepWrapped as ComponentType

; +} diff --git a/surfsense_web/lib/hitl/bundle-context.tsx b/surfsense_web/lib/hitl/bundle-context.tsx new file mode 100644 index 000000000..3f52ee4d0 --- /dev/null +++ b/surfsense_web/lib/hitl/bundle-context.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react"; +import type { HitlDecision } from "./types"; + +export type BundleSubmit = (orderedDecisions: HitlDecision[]) => void; + +export interface HitlBundleAPI { + toolCallIds: readonly string[]; + currentStep: number; + stagedCount: number; + isInBundle: (toolCallId: string) => boolean; + isCurrentStep: (toolCallId: string) => boolean; + getStaged: (toolCallId: string) => HitlDecision | undefined; + stage: (toolCallId: string, decision: HitlDecision) => void; + goToStep: (i: number) => void; + next: () => void; + prev: () => void; + submit: () => void; +} + +const HitlBundleContext = createContext(null); +const ToolCallIdContext = createContext(null); + +export function useHitlBundle(): HitlBundleAPI | null { + return useContext(HitlBundleContext); +} + +export function useToolCallIdContext(): string | null { + return useContext(ToolCallIdContext); +} + +export function ToolCallIdProvider({ + toolCallId, + children, +}: { + toolCallId: string; + children: ReactNode; +}) { + return {children}; +} + +interface HitlBundleProviderProps { + toolCallIds: readonly string[] | null; + onSubmit: BundleSubmit; + children: ReactNode; +} + +/** + * Activates only when ``toolCallIds`` has 2+ entries; single-card interrupts + * keep their direct ``window`` dispatch path so N=1 UX is unchanged. + */ +export function HitlBundleProvider({ toolCallIds, onSubmit, children }: HitlBundleProviderProps) { + const active = toolCallIds !== null && toolCallIds.length >= 2; + const ids = useMemo(() => (active ? [...toolCallIds] : []), [active, toolCallIds]); + const bundleKey = ids.join("|"); + + // Derived-state-from-props: reset staging + step when the bundle changes. + const [prevBundleKey, setPrevBundleKey] = useState(bundleKey); + const [staged, setStaged] = useState>(() => new Map()); + const [currentStep, setCurrentStep] = useState(0); + if (bundleKey !== prevBundleKey) { + setPrevBundleKey(bundleKey); + setStaged(new Map()); + setCurrentStep(0); + } + + const isInBundle = useCallback((tcId: string) => ids.includes(tcId), [ids]); + const isCurrentStep = useCallback( + (tcId: string) => active === true && ids[currentStep] === tcId, + [active, ids, currentStep] + ); + const getStaged = useCallback((tcId: string) => staged.get(tcId), [staged]); + const stage = useCallback( + (tcId: string, decision: HitlDecision) => { + if (!active || !ids.includes(tcId)) return; + setStaged((prev) => { + const next = new Map(prev); + next.set(tcId, decision); + return next; + }); + // Mirror the staged decision onto the card immediately so prev/next + // nav doesn't re-show approve/reject buttons for already-decided cards. + // Submit's ``hitl-decision`` event re-applies these (no-op) and runs + // the actual resume. + window.dispatchEvent( + new CustomEvent("hitl-stage", { detail: { toolCallId: tcId, decision } }) + ); + const idx = ids.indexOf(tcId); + if (idx >= 0 && idx < ids.length - 1) { + setCurrentStep(idx + 1); + } + }, + [active, ids] + ); + const goToStep = useCallback( + (i: number) => { + if (i < 0 || i >= ids.length) return; + setCurrentStep(i); + }, + [ids.length] + ); + const next = useCallback(() => { + setCurrentStep((s) => Math.min(s + 1, Math.max(0, ids.length - 1))); + }, [ids.length]); + const prev = useCallback(() => { + setCurrentStep((s) => Math.max(s - 1, 0)); + }, []); + + const submit = useCallback(() => { + if (!active) return; + if (staged.size !== ids.length) return; + const ordered: HitlDecision[] = []; + for (const tcId of ids) { + const d = staged.get(tcId); + if (!d) return; + ordered.push(d); + } + onSubmit(ordered); + }, [active, ids, staged, onSubmit]); + + const value = useMemo(() => { + if (!active) return null; + return { + toolCallIds: ids, + currentStep, + stagedCount: staged.size, + isInBundle, + isCurrentStep, + getStaged, + stage, + goToStep, + next, + prev, + submit, + }; + }, [ + active, + ids, + currentStep, + staged, + isInBundle, + isCurrentStep, + getStaged, + stage, + goToStep, + next, + prev, + submit, + ]); + + return {children}; +} diff --git a/surfsense_web/lib/hitl/index.ts b/surfsense_web/lib/hitl/index.ts index decf5980d..4bb15e8b5 100644 --- a/surfsense_web/lib/hitl/index.ts +++ b/surfsense_web/lib/hitl/index.ts @@ -1,3 +1,11 @@ +export { + type BundleSubmit, + type HitlBundleAPI, + HitlBundleProvider, + ToolCallIdProvider, + useHitlBundle, + useToolCallIdContext, +} from "./bundle-context"; export type { HitlDecision, InterruptActionRequest, diff --git a/surfsense_web/lib/hitl/use-hitl-decision.ts b/surfsense_web/lib/hitl/use-hitl-decision.ts index 439f35f21..203c04b16 100644 --- a/surfsense_web/lib/hitl/use-hitl-decision.ts +++ b/surfsense_web/lib/hitl/use-hitl-decision.ts @@ -1,17 +1,31 @@ /** * Shared hook for dispatching HITL decisions. * - * All tool-ui components that handle approve/reject/edit should use this - * instead of manually constructing `CustomEvent("hitl-decision", ...)`. + * Tool-ui cards always call ``dispatch([decision])``. When a multi-card bundle + * is active (``HitlBundleProvider``), the dispatch is intercepted and staged + * against this card's ``toolCallId`` so the orchestrator can submit one + * ordered N-decision payload. With no bundle active (N=1 path), it falls back + * to the legacy ``window`` event the host listens for in ``page.tsx``. */ import { useCallback } from "react"; +import { useHitlBundle, useToolCallIdContext } from "./bundle-context"; import type { HitlDecision } from "./types"; export function useHitlDecision() { - const dispatch = useCallback((decisions: HitlDecision[]) => { - window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions } })); - }, []); + const bundle = useHitlBundle(); + const toolCallId = useToolCallIdContext(); + + const dispatch = useCallback( + (decisions: HitlDecision[]) => { + if (bundle && toolCallId && bundle.isInBundle(toolCallId) && decisions.length > 0) { + bundle.stage(toolCallId, decisions[0]); + return; + } + window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions } })); + }, + [bundle, toolCallId] + ); return { dispatch }; }