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 2458f72c4..cb387278b 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 @@ -136,20 +136,20 @@ def build_task_tool_with_parent_config( if pending_value is not None: resume_value = consume_surfsense_resume(runtime) - if resume_value is not None: - expected = hitlrequest_action_count(pending_value) - resume_value = fan_out_decisions_to_match(resume_value, expected) - result = subagent.invoke( - build_resume_command(resume_value, pending_id), - config=sub_config, + if resume_value is None: + # Bridge invariant: a queued resume must accompany any pending + # subagent interrupt. Fall-through replay would silently re-prompt + # the user; raise so the streaming layer surfaces a clear error. + raise RuntimeError( + f"Subagent {subagent_type!r} has a pending interrupt but no " + "surfsense_resume_value on config; resume bridge is broken." ) - else: - logger.warning( - "Subagent %r has pending interrupt but no surfsense_resume_value " - "on config — replaying with fresh state (interrupt will re-fire).", - subagent_type, - ) - result = subagent.invoke(subagent_state, config=sub_config) + expected = hitlrequest_action_count(pending_value) + resume_value = fan_out_decisions_to_match(resume_value, expected) + result = subagent.invoke( + build_resume_command(resume_value, pending_id), + config=sub_config, + ) else: result = subagent.invoke(subagent_state, config=sub_config) maybe_propagate_subagent_interrupt(subagent, sub_config, subagent_type) @@ -201,20 +201,17 @@ def build_task_tool_with_parent_config( if pending_value is not None: resume_value = consume_surfsense_resume(runtime) - if resume_value is not None: - expected = hitlrequest_action_count(pending_value) - resume_value = fan_out_decisions_to_match(resume_value, expected) - result = await subagent.ainvoke( - build_resume_command(resume_value, pending_id), - config=sub_config, + if resume_value is None: + raise RuntimeError( + f"Subagent {subagent_type!r} has a pending interrupt but no " + "surfsense_resume_value on config; resume bridge is broken." ) - else: - logger.warning( - "Subagent %r has pending interrupt but no surfsense_resume_value " - "on config — replaying with fresh state (interrupt will re-fire).", - subagent_type, - ) - result = await subagent.ainvoke(subagent_state, config=sub_config) + expected = hitlrequest_action_count(pending_value) + resume_value = fan_out_decisions_to_match(resume_value, expected) + result = await subagent.ainvoke( + build_resume_command(resume_value, pending_id), + config=sub_config, + ) else: result = await subagent.ainvoke(subagent_state, config=sub_config) await amaybe_propagate_subagent_interrupt(subagent, sub_config, subagent_type) 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 533aefa89..bb8f62703 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 @@ -1283,36 +1283,36 @@ export default function NewChatPage() { } } - // Merge edited args if present to fix race condition - if (decisions.length > 0 && decisions[0].type === "edit" && decisions[0].edited_action) { - const editedAction = decisions[0].edited_action; + // Apply each decision to its own card by toolCallId so mixed + // bundles (approve/edit/reject) and multi-edit bundles do not + // collapse onto ``decisions[0]``. Cards outside the bundle are + // untouched. Mirrors the host ``hitl-decision`` handler. + const decisionByTcId = new Map(); + const tcIds = pendingInterrupt.bundleToolCallIds; + if (decisions.length === tcIds.length) { + for (let i = 0; i < tcIds.length; i++) decisionByTcId.set(tcIds[i], decisions[i]); + } + if (decisionByTcId.size > 0) { for (const part of contentParts) { - if (part.type === "tool-call" && part.toolName === editedAction.name) { - const mergedArgs = { ...part.args, ...editedAction.args }; + if (part.type !== "tool-call") continue; + const tcId = part.toolCallId as string | undefined; + const d = tcId ? decisionByTcId.get(tcId) : undefined; + if (!d) continue; + if (typeof part.result !== "object" || part.result === null) continue; + if (!("__interrupt__" in (part.result as Record))) continue; + const decided = d.type as "approve" | "reject" | "edit"; + if (decided === "edit" && d.edited_action) { + const mergedArgs = { ...part.args, ...d.edited_action.args }; part.args = mergedArgs; // Sync argsText so the rendered card shows the - // edited inputs — assistant-ui prefers caller- - // supplied argsText over JSON.stringify(args). + // edited inputs (assistant-ui prefers it over + // JSON.stringify(args)). part.argsText = JSON.stringify(mergedArgs, null, 2); - break; - } - } - } - - const decisionType = decisions[0]?.type as "approve" | "reject" | undefined; - if (decisionType) { - for (const part of contentParts) { - if ( - part.type === "tool-call" && - typeof part.result === "object" && - part.result !== null && - "__interrupt__" in (part.result as Record) - ) { - part.result = { - ...(part.result as Record), - __decided__: decisionType, - }; } + part.result = { + ...(part.result as Record), + __decided__: decided, + }; } } @@ -1579,16 +1579,19 @@ export default function NewChatPage() { const tcIds = pendingInterrupt.bundleToolCallIds; const N = tcIds.length; - // 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); + // Bundles must submit exactly one decision per action_request. + // Refuse rather than silently broadcast a single decision across + // the bundle (would mis-apply rejects/edits and diverge from + // what handleResume sends to /resume). + if (N > 1 && incoming.length !== N) { + toast.error( + `Cannot resume: ${incoming.length} decision(s) submitted for ${N} pending actions.` + ); + return; } + + const byTcId = new Map(); + for (let i = 0; i < tcIds.length; i++) byTcId.set(tcIds[i], incoming[i]); const submittedDecisions = tcIds.map((id) => byTcId.get(id)!); setMessages((prev) => diff --git a/surfsense_web/lib/hitl/use-hitl-decision.ts b/surfsense_web/lib/hitl/use-hitl-decision.ts index 203c04b16..e2aaf8514 100644 --- a/surfsense_web/lib/hitl/use-hitl-decision.ts +++ b/surfsense_web/lib/hitl/use-hitl-decision.ts @@ -19,6 +19,16 @@ export function useHitlDecision() { const dispatch = useCallback( (decisions: HitlDecision[]) => { if (bundle && toolCallId && bundle.isInBundle(toolCallId) && decisions.length > 0) { + if (decisions.length > 1 && process.env.NODE_ENV !== "production") { + // Tool-ui cards stage one decision per call; a multi-decision + // dispatch into an active bundle would silently drop tail entries. + // eslint-disable-next-line no-console + console.warn( + "[hitl] dispatch received %d decisions inside an active bundle; only [0] will be staged for %s", + decisions.length, + toolCallId + ); + } bundle.stage(toolCallId, decisions[0]); return; }