diff --git a/surfsense_backend/app/agents/new_chat/chat_deepagent.py b/surfsense_backend/app/agents/new_chat/chat_deepagent.py index 4e831265f..3b72cabf0 100644 --- a/surfsense_backend/app/agents/new_chat/chat_deepagent.py +++ b/surfsense_backend/app/agents/new_chat/chat_deepagent.py @@ -20,6 +20,9 @@ from langgraph.types import Checkpointer from sqlalchemy.ext.asyncio import AsyncSession from app.agents.new_chat.context import SurfSenseContextSchema +from app.agents.new_chat.middleware.dedup_tool_calls import ( + DedupHITLToolCallsMiddleware, +) from app.agents.new_chat.llm_config import AgentConfig from app.agents.new_chat.system_prompt import ( build_configurable_system_prompt, @@ -384,6 +387,7 @@ async def create_surfsense_deep_agent( system_prompt=system_prompt, context_schema=SurfSenseContextSchema, checkpointer=checkpointer, + middleware=[DedupHITLToolCallsMiddleware()], **deep_agent_kwargs, ) _perf_log.info( diff --git a/surfsense_backend/app/agents/new_chat/middleware/__init__.py b/surfsense_backend/app/agents/new_chat/middleware/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/new_chat/middleware/dedup_tool_calls.py b/surfsense_backend/app/agents/new_chat/middleware/dedup_tool_calls.py new file mode 100644 index 000000000..085301570 --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/middleware/dedup_tool_calls.py @@ -0,0 +1,89 @@ +"""Middleware that deduplicates HITL tool calls within a single LLM response. + +When the LLM emits multiple calls to the same HITL tool with the same +primary argument (e.g. two ``delete_calendar_event("Doctor Appointment")``), +only the first call is kept. Non-HITL tools are never touched. + +This runs in the ``after_model`` hook — **before** any tool executes — so +the duplicate call is stripped from the AIMessage that gets checkpointed. +That means it is also safe across LangGraph ``interrupt()`` boundaries: +the removed call will never appear on graph resume. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from langchain.agents.middleware import AgentMiddleware, AgentState +from langgraph.runtime import Runtime + +logger = logging.getLogger(__name__) + +_HITL_TOOL_DEDUP_KEYS: dict[str, str] = { + "delete_calendar_event": "event_title_or_id", + "update_calendar_event": "event_title_or_id", + "trash_gmail_email": "email_subject_or_id", + "update_gmail_draft": "draft_subject_or_id", + "delete_google_drive_file": "file_name", + "delete_notion_page": "page_title", + "update_notion_page": "page_title", + "delete_linear_issue": "issue_ref", + "update_linear_issue": "issue_ref", +} + + +class DedupHITLToolCallsMiddleware(AgentMiddleware): # type: ignore[type-arg] + """Remove duplicate HITL tool calls from a single LLM response. + + Only the **first** occurrence of each (tool-name, primary-arg-value) + pair is kept; subsequent duplicates are silently dropped. + """ + + tools = () + + def after_model( + self, state: AgentState, runtime: Runtime[Any] + ) -> dict[str, Any] | None: + return self._dedup(state) + + async def aafter_model( + self, state: AgentState, runtime: Runtime[Any] + ) -> dict[str, Any] | None: + return self._dedup(state) + + @staticmethod + def _dedup(state: AgentState) -> dict[str, Any] | None: # type: ignore[type-arg] + messages = state.get("messages") + if not messages: + return None + + last_msg = messages[-1] + if last_msg.type != "ai" or not getattr(last_msg, "tool_calls", None): + return None + + tool_calls: list[dict[str, Any]] = last_msg.tool_calls + seen: set[tuple[str, str]] = set() + deduped: list[dict[str, Any]] = [] + + for tc in tool_calls: + name = tc.get("name", "") + dedup_key_arg = _HITL_TOOL_DEDUP_KEYS.get(name) + if dedup_key_arg is not None: + arg_val = str(tc.get("args", {}).get(dedup_key_arg, "")).lower() + key = (name, arg_val) + if key in seen: + logger.info( + "Dedup: dropped duplicate HITL tool call %s(%s)", + name, + arg_val, + ) + continue + seen.add(key) + deduped.append(tc) + + if len(deduped) == len(tool_calls): + return None + + updated_msg = last_msg.model_copy(update={"tool_calls": deduped}) + return {"messages": [updated_msg]} diff --git a/surfsense_backend/app/agents/new_chat/tools/gmail/trash_email.py b/surfsense_backend/app/agents/new_chat/tools/gmail/trash_email.py index 84d97d89c..b129888f9 100644 --- a/surfsense_backend/app/agents/new_chat/tools/gmail/trash_email.py +++ b/surfsense_backend/app/agents/new_chat/tools/gmail/trash_email.py @@ -22,9 +22,9 @@ def create_trash_gmail_email_tool( email_subject_or_id: str, delete_from_kb: bool = False, ) -> dict[str, Any]: - """Move an email to trash in Gmail. + """Move an email or draft to trash in Gmail. - Use when the user asks to delete, remove, or trash an email. + Use when the user asks to delete, remove, or trash an email or draft. Args: email_subject_or_id: The exact subject line or message ID of the @@ -47,12 +47,6 @@ def create_trash_gmail_email_tool( to verify the email subject or check if it has been indexed. - If status is "insufficient_permissions", the connector lacks the required OAuth scope. Inform the user they need to re-authenticate and do NOT retry this tool. - - ONLY call this tool ONCE per user request. The system automatically picks the - most relevant match when multiple emails share the same subject. The user will - see the exact email details (sender, date) in the approval card and can reject - if it is not the right one. Do NOT call this tool multiple times for the same - email subject. - Examples: - "Delete the email about 'Meeting Cancelled'" - "Trash the email from Bob about the project" diff --git a/surfsense_backend/app/agents/new_chat/tools/gmail/update_draft.py b/surfsense_backend/app/agents/new_chat/tools/gmail/update_draft.py index d43d0a760..f5864bd58 100644 --- a/surfsense_backend/app/agents/new_chat/tools/gmail/update_draft.py +++ b/surfsense_backend/app/agents/new_chat/tools/gmail/update_draft.py @@ -39,7 +39,8 @@ def create_update_gmail_draft_tool( context. The user will review and can freely edit the content in the approval card before confirming. - IMPORTANT: This tool is ONLY for Gmail drafts, NOT for Notion pages, + IMPORTANT: This tool is ONLY for modifying Gmail draft content, NOT for + deleting/trashing drafts (use trash_gmail_email instead), Notion pages, calendar events, or any other content type. Args: @@ -63,10 +64,6 @@ def create_update_gmail_draft_tool( Respond with a brief acknowledgment and do NOT retry or suggest alternatives. - If status is "not_found", relay the exact message to the user and ask them to verify the draft subject or check if it has been indexed. - - ONLY call this tool ONCE per user request. The system automatically picks the - most relevant match when multiple drafts share the same subject. The user will - see the exact draft details in the approval card and can reject if it is not - the right one. Do NOT call this tool multiple times for the same draft subject. - If status is "insufficient_permissions", the connector lacks the required OAuth scope. Inform the user they need to re-authenticate and do NOT retry the action. diff --git a/surfsense_backend/app/agents/new_chat/tools/google_calendar/delete_event.py b/surfsense_backend/app/agents/new_chat/tools/google_calendar/delete_event.py index 704ae9725..8baf866b5 100644 --- a/surfsense_backend/app/agents/new_chat/tools/google_calendar/delete_event.py +++ b/surfsense_backend/app/agents/new_chat/tools/google_calendar/delete_event.py @@ -46,12 +46,6 @@ def create_delete_calendar_event_tool( acknowledgment and do NOT retry or suggest alternatives. - If status is "not_found", relay the exact message to the user and ask them to verify the event name or check if it has been indexed. - - ONLY call this tool ONCE per user request. The system automatically picks the - most relevant match when multiple events share the same name. The user will - see the exact event details (date, time, location) in the approval card and - can reject if it is not the right one. Do NOT call this tool multiple times - for the same event name. - Examples: - "Delete the team standup event" - "Cancel my dentist appointment on Friday" diff --git a/surfsense_backend/app/agents/new_chat/tools/google_calendar/update_event.py b/surfsense_backend/app/agents/new_chat/tools/google_calendar/update_event.py index 5c6dce026..ba60b6036 100644 --- a/surfsense_backend/app/agents/new_chat/tools/google_calendar/update_event.py +++ b/surfsense_backend/app/agents/new_chat/tools/google_calendar/update_event.py @@ -54,12 +54,6 @@ def create_update_calendar_event_tool( acknowledgment and do NOT retry or suggest alternatives. - If status is "not_found", relay the exact message to the user and ask them to verify the event name or check if it has been indexed. - - ONLY call this tool ONCE per user request. The system automatically picks the - most relevant match when multiple events share the same name. The user will - see the exact event details (date, time, location) in the approval card and - can reject if it is not the right one. Do NOT call this tool multiple times - for the same event name. - Examples: - "Reschedule the team standup to 3pm" - "Change the location of my dentist appointment" diff --git a/surfsense_backend/app/agents/new_chat/tools/google_drive/trash_file.py b/surfsense_backend/app/agents/new_chat/tools/google_drive/trash_file.py index edee120c3..742e84ad0 100644 --- a/surfsense_backend/app/agents/new_chat/tools/google_drive/trash_file.py +++ b/surfsense_backend/app/agents/new_chat/tools/google_drive/trash_file.py @@ -47,11 +47,6 @@ def create_delete_google_drive_file_tool( to verify the file name or check if it has been indexed. - If status is "insufficient_permissions", the connector lacks the required OAuth scope. Inform the user they need to re-authenticate and do NOT retry this tool. - - ONLY call this tool ONCE per user request. The system automatically picks the - most relevant match when multiple files share the same name. The user will - see the exact file details in the approval card and can reject if it is not - the right one. Do NOT call this tool multiple times for the same file name. - Examples: - "Delete the 'Meeting Notes' file from Google Drive" - "Trash the 'Old Budget' spreadsheet" diff --git a/surfsense_backend/app/agents/new_chat/tools/linear/delete_issue.py b/surfsense_backend/app/agents/new_chat/tools/linear/delete_issue.py index b13cdbfa5..9f4a60953 100644 --- a/surfsense_backend/app/agents/new_chat/tools/linear/delete_issue.py +++ b/surfsense_backend/app/agents/new_chat/tools/linear/delete_issue.py @@ -64,11 +64,6 @@ def create_delete_linear_issue_tool( - If status is "not_found", inform the user conversationally using the exact message provided. Do NOT treat this as an error. Simply relay the message and ask the user to verify the issue title or identifier, or check if it has been indexed. - - ONLY call this tool ONCE per user request. The system automatically picks the - most relevant match when multiple issues share the same title. The user will - see the exact issue details in the approval card and can reject if it is not - the right one. Do NOT call this tool multiple times for the same issue reference. - Examples: - "Delete the 'Fix login bug' Linear issue" - "Archive ENG-42" diff --git a/surfsense_backend/app/agents/new_chat/tools/linear/update_issue.py b/surfsense_backend/app/agents/new_chat/tools/linear/update_issue.py index 117fdb8ed..19af851c1 100644 --- a/surfsense_backend/app/agents/new_chat/tools/linear/update_issue.py +++ b/surfsense_backend/app/agents/new_chat/tools/linear/update_issue.py @@ -78,10 +78,6 @@ def create_update_linear_issue_tool( - If status is "not_found", inform the user conversationally using the exact message provided. Do NOT treat this as an error. Simply relay the message and ask the user to verify the issue title or identifier, or check if it has been indexed. - - ONLY call this tool ONCE per user request. The system automatically picks the - most relevant match when multiple issues share the same title. The user will - see the exact issue details in the approval card and can reject if it is not - the right one. Do NOT call this tool multiple times for the same issue reference. Examples: - "Mark the 'Fix login bug' issue as done" diff --git a/surfsense_backend/app/agents/new_chat/tools/notion/delete_page.py b/surfsense_backend/app/agents/new_chat/tools/notion/delete_page.py index 9ce15a28a..91c31519a 100644 --- a/surfsense_backend/app/agents/new_chat/tools/notion/delete_page.py +++ b/surfsense_backend/app/agents/new_chat/tools/notion/delete_page.py @@ -54,11 +54,6 @@ def create_delete_notion_page_tool( - message: Success or error message - deleted_from_kb: Whether the page was also removed from knowledge base (if success) - ONLY call this tool ONCE per user request. The system automatically picks the - most relevant match when multiple pages share the same title. The user will - see the exact page details in the approval card and can reject if it is not - the right one. Do NOT call this tool multiple times for the same page title. - Examples: - "Delete the 'Meeting Notes' Notion page" - "Remove the 'Old Project Plan' Notion page" diff --git a/surfsense_backend/app/agents/new_chat/tools/notion/update_page.py b/surfsense_backend/app/agents/new_chat/tools/notion/update_page.py index 69c119cf4..2d8d234fa 100644 --- a/surfsense_backend/app/agents/new_chat/tools/notion/update_page.py +++ b/surfsense_backend/app/agents/new_chat/tools/notion/update_page.py @@ -63,11 +63,6 @@ def create_update_notion_page_tool( Example: "I couldn't find the page '[page_title]' in your indexed Notion pages. [message details]" Do NOT treat this as an error. Do NOT invent information. Simply relay the message and ask the user to verify the page title or check if it's been indexed. - - ONLY call this tool ONCE per user request. The system automatically picks the - most relevant match when multiple pages share the same title. The user will - see the exact page details in the approval card and can reject if it is not - the right one. Do NOT call this tool multiple times for the same page title. - Examples: - "Add today's meeting notes to the 'Meeting Notes' Notion page" - "Update the 'Project Plan' page with a status update on phase 1" diff --git a/surfsense_web/components/tool-ui/gmail/trash-email.tsx b/surfsense_web/components/tool-ui/gmail/trash-email.tsx index 1db4ec4f7..705f311aa 100644 --- a/surfsense_web/components/tool-ui/gmail/trash-email.tsx +++ b/surfsense_web/components/tool-ui/gmail/trash-email.tsx @@ -5,7 +5,6 @@ import { CalendarIcon, CornerDownLeftIcon, MailIcon, - Trash2Icon, UserIcon, } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; @@ -185,7 +184,6 @@ function ApprovalCard({ {/* Header */}
-

{decided === "reject"