mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
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:
parent
744ad1fa79
commit
ff6514a99f
13 changed files with 97 additions and 51 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue