feat: add DedupHITLToolCallsMiddleware to prevent duplicate tool calls

- Introduced DedupHITLToolCallsMiddleware to prevent duplicate HITL tool calls within a single LLM response, ensuring only the first occurrence of each tool call is retained.
- Updated the create_surfsense_deep_agent function to include the new middleware, enhancing the efficiency of tool interactions.
- Added a new middleware file for better organization and maintainability of the codebase.
This commit is contained in:
Anish Sarkar 2026-03-21 03:47:30 +05:30
parent 744ad1fa79
commit ff6514a99f
13 changed files with 97 additions and 51 deletions

View file

@ -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(

View file

@ -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]}

View file

@ -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"

View file

@ -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.

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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 */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div className="flex items-center gap-2">
<Trash2Icon className="size-4 text-muted-foreground shrink-0" />
<div>
<p className="text-sm font-semibold text-foreground">
{decided === "reject"