diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/middleware/action_log.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/middleware/action_log.py index f419f285c..88e01bfd7 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/middleware/action_log.py +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/middleware/action_log.py @@ -6,7 +6,6 @@ import logging from app.agents.shared.feature_flags import AgentFeatureFlags from app.agents.shared.middleware import ActionLogMiddleware -from app.agents.shared.tools.registry import BUILTIN_TOOLS from app.agents.multi_agent_chat.shared.middleware.flags import enabled @@ -21,12 +20,13 @@ def build_action_log_mw( if not enabled(flags, "enable_action_log") or thread_id is None: return None try: - tool_defs_by_name = {td.name: td for td in BUILTIN_TOOLS} + # No built-in tool declares a ``reverse`` callable yet, so the action + # log runs without a tool_definitions map. Reversibility is opt-in per + # tool via ``ToolDefinition.reverse`` and can be wired here when used. return ActionLogMiddleware( thread_id=thread_id, search_space_id=search_space_id, user_id=user_id, - tool_definitions=tool_defs_by_name, ) except Exception: # pragma: no cover - defensive logging.warning( diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/registry.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/registry.py index 2fc79fb14..f43dfcdfd 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/registry.py +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/registry.py @@ -5,15 +5,13 @@ connector integrations, MCP, and deliverables are delegated to ``task`` subagents (see :mod:`app.agents.multi_agent_chat.main_agent.tools.index`). This module is the *building* counterpart to that name list: it owns the -factories for those few tools and nothing else. It is deliberately decoupled -from :mod:`app.agents.shared.tools.registry` (the app-wide ``BUILTIN_TOOLS`` -metadata catalog, which imports every connector) so the main agent's tool +factories for those few tools and nothing else, so the main agent's tool surface stays self-contained and connector-free. -The ``BUILTIN_TOOLS`` catalog still exists and is still used elsewhere for -tool *metadata* — the ``/agent/tools`` listing endpoint and the action-log -revert/dedup resolvers (which must cover subagent-executed connector tools). -This registry only governs what the main agent actually builds and binds. +Tool *display* metadata for the whole app (the ``/agent/tools`` listing +endpoint) lives separately in :mod:`app.agents.shared.tools.catalog`, a +pure-data module that imports no connectors. This registry only governs what +the main agent actually builds and binds. """ from __future__ import annotations @@ -71,8 +69,7 @@ def _build_update_memory_tool(deps: dict[str, Any]) -> BaseTool: ) -# Ordered to match the historical binding order produced by the shared -# ``build_tools`` (which iterated ``BUILTIN_TOOLS`` in declaration order): +# Ordered to match the historical main-agent binding order: # scrape_webpage, web_search, create_automation, update_memory. # Each entry is ``(factory, required_dependency_names)``. _MAIN_AGENT_TOOL_FACTORIES: dict[ diff --git a/surfsense_backend/app/agents/shared/agent_cache.py b/surfsense_backend/app/agents/shared/agent_cache.py index fa8e6fb72..ee51b4176 100644 --- a/surfsense_backend/app/agents/shared/agent_cache.py +++ b/surfsense_backend/app/agents/shared/agent_cache.py @@ -113,12 +113,11 @@ def tools_signature( MCP tools loaded for the user changes, gating rules flip, etc.). * The available connectors / document types for the search space change (new connector added, last connector removed, new document - type indexed). Because :func:`get_connector_gated_tools` derives - ``modified_disabled_tools`` from ``available_connectors``, the - tool surface is technically already covered — but we hash the - connector list separately so an empty-list "no tools changed" - situation still rotates the key when, say, the user re-adds a - connector that gates a tool we were already not exposing. + type indexed). Connector gating derives disabled tools from + ``available_connectors``, so the tool surface is technically already + covered — but we hash the connector list separately so an empty-list + "no tools changed" situation still rotates the key when, say, the user + re-adds a connector that gates a tool we were already not exposing. Stays stable across: diff --git a/surfsense_backend/app/agents/shared/middleware/__init__.py b/surfsense_backend/app/agents/shared/middleware/__init__.py index 737c51f25..234a7ee29 100644 --- a/surfsense_backend/app/agents/shared/middleware/__init__.py +++ b/surfsense_backend/app/agents/shared/middleware/__init__.py @@ -1,6 +1,9 @@ """Middleware components for the SurfSense new chat agent.""" -from app.agents.shared.middleware.action_log import ActionLogMiddleware +from app.agents.shared.middleware.action_log import ( + ActionLogMiddleware, + ToolDefinition, +) from app.agents.shared.middleware.anonymous_document import ( AnonymousDocumentMiddleware, ) @@ -76,6 +79,7 @@ __all__ = [ "SpillingContextEditingMiddleware", "SurfSenseCompactionMiddleware", "ToolCallNameRepairMiddleware", + "ToolDefinition", "build_skills_backend_factory", "commit_staged_filesystem_state", "create_surfsense_compaction_middleware", diff --git a/surfsense_backend/app/agents/shared/middleware/action_log.py b/surfsense_backend/app/agents/shared/middleware/action_log.py index 370b143bf..02d20d96e 100644 --- a/surfsense_backend/app/agents/shared/middleware/action_log.py +++ b/surfsense_backend/app/agents/shared/middleware/action_log.py @@ -3,8 +3,8 @@ Wraps every tool call via :meth:`AgentMiddleware.awrap_tool_call` and writes a row to :class:`~app.db.AgentActionLog` after the tool returns. Tools opt into reversibility by declaring a ``reverse`` callable on their -:class:`~app.agents.shared.tools.registry.ToolDefinition`; the rendered -descriptor is persisted in ``reverse_descriptor`` for use by +:class:`ToolDefinition`; the rendered descriptor is persisted in +``reverse_descriptor`` for use by ``/api/threads/{thread_id}/revert/{action_id}``. Design points: @@ -27,6 +27,7 @@ from __future__ import annotations import json import logging from collections.abc import Awaitable, Callable +from dataclasses import dataclass from typing import TYPE_CHECKING, Any from langchain.agents.middleware import AgentMiddleware @@ -39,15 +40,35 @@ if TYPE_CHECKING: # pragma: no cover - type-only from langchain.agents.middleware.types import ToolCallRequest from langgraph.types import Command - # Type-only import: ToolDefinition is only referenced in annotations, and a - # runtime import would close a module-load cycle (tools.registry imports - # shared.middleware.dedup_tool_calls). - from app.agents.shared.tools.registry import ToolDefinition - logger = logging.getLogger(__name__) +@dataclass +class ToolDefinition: + """Reversibility descriptor consumed by :class:`ActionLogMiddleware`. + + Only ``name`` and ``reverse`` are read by the middleware; the remaining + fields let callers and tests describe a tool declaratively. A tool is + marked reversible in the action log when ``reverse`` is set and renders a + descriptor without raising. + + Attributes: + name: Unique identifier for the tool. + description: Human-readable description of what the tool does. + factory: Optional callable that builds the tool (unused by the + middleware; retained for declarative call sites/tests). + reverse: Optional callable that, given the tool's ``(args, result)``, + returns a ``ReverseDescriptor`` describing the inverse invocation. + + """ + + name: str + description: str = "" + factory: Callable[[dict[str, Any]], Any] | None = None + reverse: Callable[[dict[str, Any], Any], dict[str, Any]] | None = None + + # Cap for the persisted ``args`` JSON to avoid bloating the action log with # accidentally-huge inputs. Values are truncated and a flag is set in the # stored payload so consumers can detect truncation. diff --git a/surfsense_backend/app/agents/shared/middleware/dedup_tool_calls.py b/surfsense_backend/app/agents/shared/middleware/dedup_tool_calls.py index a6d2ce310..69b107dbe 100644 --- a/surfsense_backend/app/agents/shared/middleware/dedup_tool_calls.py +++ b/surfsense_backend/app/agents/shared/middleware/dedup_tool_calls.py @@ -9,12 +9,12 @@ 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. -Dedup-key resolution order: +Dedup-key resolution order (read from each tool's own ``metadata``): -1. :class:`ToolDefinition.dedup_key` — callable provided by the registry - entry. This is the canonical mechanism. -2. ``tool.metadata["hitl_dedup_key"]`` — string with a primary arg name; - used by MCP / Composio tools whose schemas the registry doesn't see. +1. ``tool.metadata["dedup_key"]`` — callable mapping the args dict to a + stable signature string. This is the canonical mechanism. +2. ``tool.metadata["hitl_dedup_key"]`` — string naming a primary arg; + used by MCP / Composio tools that only expose a single key field. A tool with no resolver from either path simply opts out of dedup. """ @@ -39,17 +39,10 @@ DedupResolver = Callable[[dict[str, Any]], str] def wrap_dedup_key_by_arg_name(arg_name: str) -> DedupResolver: """Adapt a string-arg name into a :data:`DedupResolver`. - Convenience helper used by registry entries that just want to dedupe - on a single arg's lowercased value (the most common case for native - HITL tools like ``send_gmail_email`` keyed on ``subject``). - - Example:: - - ToolDefinition( - name="send_gmail_email", - ..., - dedup_key=wrap_dedup_key_by_arg_name("subject"), - ) + Convenience helper for tools that just want to dedupe on a single arg's + lowercased value (the most common case for HITL tools like + ``send_gmail_email`` keyed on ``subject``). Set the result on the tool's + ``metadata["dedup_key"]``. """ def _resolver(args: dict[str, Any]) -> str: @@ -84,9 +77,8 @@ class DedupHITLToolCallsMiddleware(AgentMiddleware): # type: ignore[type-arg] The dedup-resolver map is built from two sources, in priority order: - 1. ``tool.metadata["dedup_key"]`` — callable provided by the registry's - ``ToolDefinition.dedup_key``. Receives the args dict and returns - a string signature. This is the canonical mechanism. + 1. ``tool.metadata["dedup_key"]`` — callable that receives the args dict + and returns a string signature. This is the canonical mechanism. 2. ``tool.metadata["hitl_dedup_key"]`` — string with a primary arg name; primarily used by MCP / Composio tools. """ diff --git a/surfsense_backend/app/agents/shared/middleware/tool_call_repair.py b/surfsense_backend/app/agents/shared/middleware/tool_call_repair.py index ddf003862..7a0e7c4c6 100644 --- a/surfsense_backend/app/agents/shared/middleware/tool_call_repair.py +++ b/surfsense_backend/app/agents/shared/middleware/tool_call_repair.py @@ -118,9 +118,8 @@ class ToolCallNameRepairMiddleware( return call # Stage 2 — invalid fallback - # Local import avoids a module-load cycle: tools.registry imports - # shared.middleware (dedup_tool_calls), so importing tools at module - # scope here would close the loop. + # Local import keeps the middleware module import-light and avoids any + # tools <-> middleware import-order coupling at module scope. from app.agents.shared.tools.invalid_tool import INVALID_TOOL_NAME if INVALID_TOOL_NAME in registered: diff --git a/surfsense_backend/app/agents/shared/tools/__init__.py b/surfsense_backend/app/agents/shared/tools/__init__.py index 2cd965219..a7c8c71a3 100644 --- a/surfsense_backend/app/agents/shared/tools/__init__.py +++ b/surfsense_backend/app/agents/shared/tools/__init__.py @@ -18,19 +18,16 @@ from .knowledge_base import ( format_documents_for_context, search_knowledge_base_async, ) +from .catalog import TOOL_CATALOG, ToolMetadata from .podcast import create_generate_podcast_tool -from .registry import ( - BUILTIN_TOOLS, - ToolDefinition, -) from .video_presentation import create_generate_video_presentation_tool __all__ = [ - # Registry - "BUILTIN_TOOLS", + # Tool catalog (display metadata) + "TOOL_CATALOG", # Knowledge base utilities "CONNECTOR_DESCRIPTIONS", - "ToolDefinition", + "ToolMetadata", # Tool factories "create_generate_image_tool", "create_generate_podcast_tool", diff --git a/surfsense_backend/app/agents/shared/tools/catalog.py b/surfsense_backend/app/agents/shared/tools/catalog.py new file mode 100644 index 000000000..b8be4109a --- /dev/null +++ b/surfsense_backend/app/agents/shared/tools/catalog.py @@ -0,0 +1,84 @@ +"""Pure-data catalog of built-in agent tools. + +This module advertises *what* tools exist and their display metadata. It is +intentionally free of any tool implementation imports (no connectors, no +factories) so it can be consumed without pulling the whole tool dependency +graph — and so connector packages stay independently deletable. + +The single live consumer is the ``GET /agent/tools`` endpoint, which renders +the tool picker in the web UI. Tool *construction* lives elsewhere: + +* main-agent tools -> ``app.agents.multi_agent_chat.main_agent.tools.registry`` +* subagent / connector tools -> ``app.agents.multi_agent_chat.subagents.*`` +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ToolMetadata: + """Display metadata for a single built-in tool. + + Attributes: + name: Unique identifier for the tool. + description: Human-readable description of what the tool does. + enabled_by_default: Whether the tool is on when no explicit config + is provided. + hidden: WIP tools that should be excluded from public listings. + + """ + + name: str + description: str + enabled_by_default: bool = True + hidden: bool = False + + +# Catalog of all built-in tools. Contributors: add new tools here so they show +# up in the UI tool picker. This list carries metadata only — wire the actual +# implementation in the relevant builder/registry module. +TOOL_CATALOG: list[ToolMetadata] = [ + ToolMetadata(name="generate_podcast", description="Generate an audio podcast from provided content"), + ToolMetadata(name="generate_video_presentation", description="Generate a video presentation with slides and narration from provided content"), + ToolMetadata(name="generate_report", description="Generate a structured report from provided content and export it"), + ToolMetadata(name="generate_resume", description="Generate a professional resume as a Typst document"), + ToolMetadata(name="generate_image", description="Generate images from text descriptions using AI image models"), + ToolMetadata(name="scrape_webpage", description="Scrape and extract the main content from a webpage"), + ToolMetadata(name="web_search", description="Search the web for real-time information using configured search engines"), + ToolMetadata(name="get_connected_accounts", description="Discover connected accounts for a service and their metadata"), + ToolMetadata(name="create_automation", description="Draft an automation from an NL intent; user approves the card; tool saves"), + ToolMetadata(name="update_memory", description="Save important long-term facts, preferences, and instructions to the (personal or team) memory"), + ToolMetadata(name="create_notion_page", description="Create a new page in the user's Notion workspace"), + ToolMetadata(name="update_notion_page", description="Append new content to an existing Notion page"), + ToolMetadata(name="delete_notion_page", description="Delete an existing Notion page"), + ToolMetadata(name="create_google_drive_file", description="Create a new Google Doc or Google Sheet in Google Drive"), + ToolMetadata(name="delete_google_drive_file", description="Move an indexed Google Drive file to trash"), + ToolMetadata(name="create_dropbox_file", description="Create a new file in Dropbox"), + ToolMetadata(name="delete_dropbox_file", description="Delete a file from Dropbox"), + ToolMetadata(name="create_onedrive_file", description="Create a new file in Microsoft OneDrive"), + ToolMetadata(name="delete_onedrive_file", description="Move a OneDrive file to the recycle bin"), + ToolMetadata(name="search_calendar_events", description="Search Google Calendar events within a date range"), + ToolMetadata(name="create_calendar_event", description="Create a new event on Google Calendar"), + ToolMetadata(name="update_calendar_event", description="Update an existing indexed Google Calendar event"), + ToolMetadata(name="delete_calendar_event", description="Delete an existing indexed Google Calendar event"), + ToolMetadata(name="search_gmail", description="Search emails in Gmail using Gmail search syntax"), + ToolMetadata(name="read_gmail_email", description="Read the full content of a specific Gmail email"), + ToolMetadata(name="create_gmail_draft", description="Create a draft email in Gmail"), + ToolMetadata(name="send_gmail_email", description="Send an email via Gmail"), + ToolMetadata(name="trash_gmail_email", description="Move an indexed email to trash in Gmail"), + ToolMetadata(name="update_gmail_draft", description="Update an existing Gmail draft"), + ToolMetadata(name="create_confluence_page", description="Create a new page in the user's Confluence space"), + ToolMetadata(name="update_confluence_page", description="Update an existing indexed Confluence page"), + ToolMetadata(name="delete_confluence_page", description="Delete an existing indexed Confluence page"), + ToolMetadata(name="list_discord_channels", description="List text channels in the connected Discord server"), + ToolMetadata(name="read_discord_messages", description="Read recent messages from a Discord text channel"), + ToolMetadata(name="send_discord_message", description="Send a message to a Discord text channel"), + ToolMetadata(name="list_teams_channels", description="List Microsoft Teams and their channels"), + ToolMetadata(name="read_teams_messages", description="Read recent messages from a Microsoft Teams channel"), + ToolMetadata(name="send_teams_message", description="Send a message to a Microsoft Teams channel"), + ToolMetadata(name="list_luma_events", description="List upcoming and recent Luma events"), + ToolMetadata(name="read_luma_event", description="Read detailed information about a specific Luma event"), + ToolMetadata(name="create_luma_event", description="Create a new event on Luma"), +] diff --git a/surfsense_backend/app/agents/shared/tools/registry.py b/surfsense_backend/app/agents/shared/tools/registry.py deleted file mode 100644 index d9d0fd8ef..000000000 --- a/surfsense_backend/app/agents/shared/tools/registry.py +++ /dev/null @@ -1,743 +0,0 @@ -"""Tools registry for SurfSense deep agent. - -This module provides a registry pattern for managing tools in the SurfSense agent. -It makes it easy for OSS contributors to add new tools by: -1. Creating a tool factory function in a new file in this directory -2. Registering the tool in the BUILTIN_TOOLS list below - -Example of adding a new tool: ------------------------------- -1. Create your tool file (e.g., `tools/my_tool.py`): - - from langchain_core.tools import tool - from sqlalchemy.ext.asyncio import AsyncSession - - def create_my_tool(search_space_id: int, db_session: AsyncSession): - @tool - async def my_tool(param: str) -> dict: - '''My tool description.''' - # Your implementation - return {"result": "success"} - return my_tool - -2. Import and register in this file: - - from .my_tool import create_my_tool - - # Add to BUILTIN_TOOLS list: - ToolDefinition( - name="my_tool", - description="Description of what your tool does", - factory=lambda deps: create_my_tool( - search_space_id=deps["search_space_id"], - db_session=deps["db_session"], - ), - requires=["search_space_id", "db_session"], - ), -""" - -import logging -from collections.abc import Callable -from dataclasses import dataclass, field -from typing import Any - -from langchain_core.tools import BaseTool - -from app.agents.shared.middleware.dedup_tool_calls import ( - wrap_dedup_key_by_arg_name, -) -from app.db import ChatVisibility - -from .confluence import ( - create_create_confluence_page_tool, - create_delete_confluence_page_tool, - create_update_confluence_page_tool, -) -from .connected_accounts import create_get_connected_accounts_tool -from .discord import ( - create_list_discord_channels_tool, - create_read_discord_messages_tool, - create_send_discord_message_tool, -) -from .dropbox import ( - create_create_dropbox_file_tool, - create_delete_dropbox_file_tool, -) -from .generate_image import create_generate_image_tool -from .gmail import ( - create_create_gmail_draft_tool, - create_read_gmail_email_tool, - create_search_gmail_tool, - create_send_gmail_email_tool, - create_trash_gmail_email_tool, - create_update_gmail_draft_tool, -) -from .google_calendar import ( - create_create_calendar_event_tool, - create_delete_calendar_event_tool, - create_search_calendar_events_tool, - create_update_calendar_event_tool, -) -from .google_drive import ( - create_create_google_drive_file_tool, - create_delete_google_drive_file_tool, -) -from .luma import ( - create_create_luma_event_tool, - create_list_luma_events_tool, - create_read_luma_event_tool, -) -from .notion import ( - create_create_notion_page_tool, - create_delete_notion_page_tool, - create_update_notion_page_tool, -) -from .onedrive import ( - create_create_onedrive_file_tool, - create_delete_onedrive_file_tool, -) -from .podcast import create_generate_podcast_tool -from .report import create_generate_report_tool -from .resume import create_generate_resume_tool -from .teams import ( - create_list_teams_channels_tool, - create_read_teams_messages_tool, - create_send_teams_message_tool, -) -from .video_presentation import create_generate_video_presentation_tool -from .web_search import create_web_search_tool - -logger = logging.getLogger(__name__) - -# ============================================================================= -# Tool Definition -# ============================================================================= - - -@dataclass -class ToolDefinition: - """Definition of a tool that can be added to the agent. - - Attributes: - name: Unique identifier for the tool - description: Human-readable description of what the tool does - factory: Callable that creates the tool. Receives a dict of dependencies. - requires: List of dependency names this tool needs (e.g., "search_space_id", "db_session") - enabled_by_default: Whether the tool is enabled when no explicit config is provided - required_connector: Searchable type string (e.g. ``"LINEAR_CONNECTOR"``) - that must be in ``available_connectors`` for the tool to be enabled. - dedup_key: Optional callable that maps a tool's ``args`` dict to a - string signature used by :class:`DedupHITLToolCallsMiddleware` - to drop duplicate calls within a single LLM response. - reverse: Optional callable that, given the tool's ``(args, result)``, - returns a ``ReverseDescriptor`` describing the inverse tool - invocation. Consumed by the snapshot/revert pipeline. - - """ - - name: str - description: str - factory: Callable[[dict[str, Any]], BaseTool] - requires: list[str] = field(default_factory=list) - enabled_by_default: bool = True - hidden: bool = False - required_connector: str | None = None - dedup_key: Callable[[dict[str, Any]], str] | None = None - reverse: Callable[[dict[str, Any], Any], dict[str, Any]] | None = None - - -# ============================================================================= -# Deferred-import factories -# ============================================================================= -# Used for tools whose impls live under ``multi_agent_chat``. Importing those -# at module-load time would cycle (``multi_agent_chat`` middleware imports -# this registry). The import inside the factory runs only when -# ``build_tools`` is called, by which point ``multi_agent_chat`` is fully -# initialised. - - -def _build_create_automation_tool(deps: dict[str, Any]) -> BaseTool: - from app.agents.multi_agent_chat.main_agent.tools.automation import ( - create_create_automation_tool, - ) - - return create_create_automation_tool( - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - llm=deps["llm"], - ) - - -def _build_scrape_webpage_tool(deps: dict[str, Any]) -> BaseTool: - # ``scrape_webpage`` is owned by the main agent (its sole live consumer); - # deferred import keeps this catalog free of a ``multi_agent_chat`` cycle. - from app.agents.multi_agent_chat.main_agent.tools.scrape_webpage import ( - create_scrape_webpage_tool, - ) - - return create_scrape_webpage_tool(firecrawl_api_key=deps.get("firecrawl_api_key")) - - -def _build_update_memory_tool(deps: dict[str, Any]) -> BaseTool: - # ``update_memory`` is owned by the main agent; deferred import (see above). - from app.agents.multi_agent_chat.main_agent.tools.update_memory import ( - create_update_memory_tool, - create_update_team_memory_tool, - ) - - if deps["thread_visibility"] == ChatVisibility.SEARCH_SPACE: - return create_update_team_memory_tool( - search_space_id=deps["search_space_id"], - db_session=deps["db_session"], - llm=deps.get("llm"), - ) - return create_update_memory_tool( - user_id=deps["user_id"], - db_session=deps["db_session"], - llm=deps.get("llm"), - ) - - -# ============================================================================= -# Built-in Tools Registry -# ============================================================================= - -# Registry of all built-in tools -# Contributors: Add your new tools here! -BUILTIN_TOOLS: list[ToolDefinition] = [ - # Podcast generation tool - ToolDefinition( - name="generate_podcast", - description="Generate an audio podcast from provided content", - factory=lambda deps: create_generate_podcast_tool( - search_space_id=deps["search_space_id"], - db_session=deps["db_session"], - thread_id=deps["thread_id"], - ), - requires=["search_space_id", "db_session", "thread_id"], - ), - # Video presentation generation tool - ToolDefinition( - name="generate_video_presentation", - description="Generate a video presentation with slides and narration from provided content", - factory=lambda deps: create_generate_video_presentation_tool( - search_space_id=deps["search_space_id"], - db_session=deps["db_session"], - thread_id=deps["thread_id"], - ), - requires=["search_space_id", "db_session", "thread_id"], - ), - # Report generation tool (inline, short-lived sessions for DB ops) - # Supports internal KB search via source_strategy so the agent does not - # need a separate search step before generating. - ToolDefinition( - name="generate_report", - description="Generate a structured report from provided content and export it", - factory=lambda deps: create_generate_report_tool( - search_space_id=deps["search_space_id"], - thread_id=deps["thread_id"], - connector_service=deps.get("connector_service"), - available_connectors=deps.get("available_connectors"), - available_document_types=deps.get("available_document_types"), - ), - requires=["search_space_id", "thread_id"], - # connector_service, available_connectors, and available_document_types - # are optional — when missing, source_strategy="kb_search" degrades - # gracefully to "provided" - ), - # Resume generation tool (Typst-based, uses rendercv package) - ToolDefinition( - name="generate_resume", - description="Generate a professional resume as a Typst document", - factory=lambda deps: create_generate_resume_tool( - search_space_id=deps["search_space_id"], - thread_id=deps["thread_id"], - ), - requires=["search_space_id", "thread_id"], - ), - # Generate image tool - creates images using AI models (DALL-E, GPT Image, etc.) - ToolDefinition( - name="generate_image", - description="Generate images from text descriptions using AI image models", - factory=lambda deps: create_generate_image_tool( - search_space_id=deps["search_space_id"], - db_session=deps["db_session"], - ), - requires=["search_space_id", "db_session"], - ), - # Web scraping tool - extracts content from webpages - ToolDefinition( - name="scrape_webpage", - description="Scrape and extract the main content from a webpage", - factory=_build_scrape_webpage_tool, - requires=[], # firecrawl_api_key is optional - ), - # Web search tool — real-time web search via SearXNG + user-configured engines - ToolDefinition( - name="web_search", - description="Search the web for real-time information using configured search engines", - factory=lambda deps: create_web_search_tool( - search_space_id=deps.get("search_space_id"), - available_connectors=deps.get("available_connectors"), - ), - requires=[], - ), - # ========================================================================= - # SERVICE ACCOUNT DISCOVERY - # Generic tool for the LLM to discover connected accounts and resolve - # service-specific identifiers (e.g. Jira cloudId, Slack team, etc.) - # ========================================================================= - ToolDefinition( - name="get_connected_accounts", - description="Discover connected accounts for a service and their metadata", - factory=lambda deps: create_get_connected_accounts_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - ), - # ========================================================================= - # AUTOMATION AUTHORING - single HITL tool. The tool takes an NL ``intent`` - # from the main agent, drafts the full AutomationCreate JSON via a focused - # sub-LLM, surfaces it on an approval card, and persists on approval. The - # factory defers its import because the impl lives under ``multi_agent_chat`` - # and that package transitively pulls this registry via middleware; - # deferring to ``build_tools`` call-time breaks the cycle without a - # parallel registry. - # ========================================================================= - ToolDefinition( - name="create_automation", - description="Draft an automation from an NL intent; user approves the card; tool saves", - factory=_build_create_automation_tool, - requires=["search_space_id", "user_id", "llm"], - ), - # ========================================================================= - # MEMORY TOOL - single update_memory, private or team by thread_visibility - # ========================================================================= - ToolDefinition( - name="update_memory", - description="Save important long-term facts, preferences, and instructions to the (personal or team) memory", - factory=_build_update_memory_tool, - requires=[ - "user_id", - "search_space_id", - "db_session", - "thread_visibility", - "llm", - ], - ), - # ========================================================================= - # NOTION TOOLS - create, update, delete pages - # Auto-disabled when no Notion connector is configured # ========================================================================= - ToolDefinition( - name="create_notion_page", - description="Create a new page in the user's Notion workspace", - factory=lambda deps: create_create_notion_page_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="NOTION_CONNECTOR", - dedup_key=wrap_dedup_key_by_arg_name("title"), - ), - ToolDefinition( - name="update_notion_page", - description="Append new content to an existing Notion page", - factory=lambda deps: create_update_notion_page_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="NOTION_CONNECTOR", - dedup_key=wrap_dedup_key_by_arg_name("page_title"), - ), - ToolDefinition( - name="delete_notion_page", - description="Delete an existing Notion page", - factory=lambda deps: create_delete_notion_page_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="NOTION_CONNECTOR", - dedup_key=wrap_dedup_key_by_arg_name("page_title"), - ), - # ========================================================================= - # GOOGLE DRIVE TOOLS - create files, delete files - # Auto-disabled when no Google Drive connector is configured # ========================================================================= - ToolDefinition( - name="create_google_drive_file", - description="Create a new Google Doc or Google Sheet in Google Drive", - factory=lambda deps: create_create_google_drive_file_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="GOOGLE_DRIVE_FILE", - dedup_key=wrap_dedup_key_by_arg_name("file_name"), - ), - ToolDefinition( - name="delete_google_drive_file", - description="Move an indexed Google Drive file to trash", - factory=lambda deps: create_delete_google_drive_file_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="GOOGLE_DRIVE_FILE", - dedup_key=wrap_dedup_key_by_arg_name("file_name"), - ), - # ========================================================================= - # DROPBOX TOOLS - create and trash files - # Auto-disabled when no Dropbox connector is configured # ========================================================================= - ToolDefinition( - name="create_dropbox_file", - description="Create a new file in Dropbox", - factory=lambda deps: create_create_dropbox_file_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="DROPBOX_FILE", - dedup_key=wrap_dedup_key_by_arg_name("file_name"), - ), - ToolDefinition( - name="delete_dropbox_file", - description="Delete a file from Dropbox", - factory=lambda deps: create_delete_dropbox_file_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="DROPBOX_FILE", - dedup_key=wrap_dedup_key_by_arg_name("file_name"), - ), - # ========================================================================= - # ONEDRIVE TOOLS - create and trash files - # Auto-disabled when no OneDrive connector is configured # ========================================================================= - ToolDefinition( - name="create_onedrive_file", - description="Create a new file in Microsoft OneDrive", - factory=lambda deps: create_create_onedrive_file_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="ONEDRIVE_FILE", - dedup_key=wrap_dedup_key_by_arg_name("file_name"), - ), - ToolDefinition( - name="delete_onedrive_file", - description="Move a OneDrive file to the recycle bin", - factory=lambda deps: create_delete_onedrive_file_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="ONEDRIVE_FILE", - dedup_key=wrap_dedup_key_by_arg_name("file_name"), - ), - # ========================================================================= - # GOOGLE CALENDAR TOOLS - search, create, update, delete events - # Auto-disabled when no Google Calendar connector is configured - # ========================================================================= - ToolDefinition( - name="search_calendar_events", - description="Search Google Calendar events within a date range", - factory=lambda deps: create_search_calendar_events_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="GOOGLE_CALENDAR_CONNECTOR", - ), - ToolDefinition( - name="create_calendar_event", - description="Create a new event on Google Calendar", - factory=lambda deps: create_create_calendar_event_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="GOOGLE_CALENDAR_CONNECTOR", - dedup_key=wrap_dedup_key_by_arg_name("title"), - ), - ToolDefinition( - name="update_calendar_event", - description="Update an existing indexed Google Calendar event", - factory=lambda deps: create_update_calendar_event_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="GOOGLE_CALENDAR_CONNECTOR", - dedup_key=wrap_dedup_key_by_arg_name("event_title_or_id"), - ), - ToolDefinition( - name="delete_calendar_event", - description="Delete an existing indexed Google Calendar event", - factory=lambda deps: create_delete_calendar_event_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="GOOGLE_CALENDAR_CONNECTOR", - dedup_key=wrap_dedup_key_by_arg_name("event_title_or_id"), - ), - # ========================================================================= - # GMAIL TOOLS - search, read, create drafts, update drafts, send, trash - # Auto-disabled when no Gmail connector is configured - # ========================================================================= - ToolDefinition( - name="search_gmail", - description="Search emails in Gmail using Gmail search syntax", - factory=lambda deps: create_search_gmail_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="GOOGLE_GMAIL_CONNECTOR", - ), - ToolDefinition( - name="read_gmail_email", - description="Read the full content of a specific Gmail email", - factory=lambda deps: create_read_gmail_email_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="GOOGLE_GMAIL_CONNECTOR", - ), - ToolDefinition( - name="create_gmail_draft", - description="Create a draft email in Gmail", - factory=lambda deps: create_create_gmail_draft_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="GOOGLE_GMAIL_CONNECTOR", - dedup_key=wrap_dedup_key_by_arg_name("subject"), - ), - ToolDefinition( - name="send_gmail_email", - description="Send an email via Gmail", - factory=lambda deps: create_send_gmail_email_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="GOOGLE_GMAIL_CONNECTOR", - dedup_key=wrap_dedup_key_by_arg_name("subject"), - ), - ToolDefinition( - name="trash_gmail_email", - description="Move an indexed email to trash in Gmail", - factory=lambda deps: create_trash_gmail_email_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="GOOGLE_GMAIL_CONNECTOR", - dedup_key=wrap_dedup_key_by_arg_name("email_subject_or_id"), - ), - ToolDefinition( - name="update_gmail_draft", - description="Update an existing Gmail draft", - factory=lambda deps: create_update_gmail_draft_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="GOOGLE_GMAIL_CONNECTOR", - dedup_key=wrap_dedup_key_by_arg_name("draft_subject_or_id"), - ), - # ========================================================================= - # CONFLUENCE TOOLS - create, update, delete pages - # Auto-disabled when no Confluence connector is configured # ========================================================================= - ToolDefinition( - name="create_confluence_page", - description="Create a new page in the user's Confluence space", - factory=lambda deps: create_create_confluence_page_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="CONFLUENCE_CONNECTOR", - dedup_key=wrap_dedup_key_by_arg_name("title"), - ), - ToolDefinition( - name="update_confluence_page", - description="Update an existing indexed Confluence page", - factory=lambda deps: create_update_confluence_page_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="CONFLUENCE_CONNECTOR", - dedup_key=wrap_dedup_key_by_arg_name("page_title_or_id"), - ), - ToolDefinition( - name="delete_confluence_page", - description="Delete an existing indexed Confluence page", - factory=lambda deps: create_delete_confluence_page_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="CONFLUENCE_CONNECTOR", - dedup_key=wrap_dedup_key_by_arg_name("page_title_or_id"), - ), - # ========================================================================= - # DISCORD TOOLS - list channels, read messages, send messages - # Auto-disabled when no Discord connector is configured - # ========================================================================= - ToolDefinition( - name="list_discord_channels", - description="List text channels in the connected Discord server", - factory=lambda deps: create_list_discord_channels_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="DISCORD_CONNECTOR", - ), - ToolDefinition( - name="read_discord_messages", - description="Read recent messages from a Discord text channel", - factory=lambda deps: create_read_discord_messages_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="DISCORD_CONNECTOR", - ), - ToolDefinition( - name="send_discord_message", - description="Send a message to a Discord text channel", - factory=lambda deps: create_send_discord_message_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="DISCORD_CONNECTOR", - ), - # ========================================================================= - # TEAMS TOOLS - list channels, read messages, send messages - # Auto-disabled when no Teams connector is configured - # ========================================================================= - ToolDefinition( - name="list_teams_channels", - description="List Microsoft Teams and their channels", - factory=lambda deps: create_list_teams_channels_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="TEAMS_CONNECTOR", - ), - ToolDefinition( - name="read_teams_messages", - description="Read recent messages from a Microsoft Teams channel", - factory=lambda deps: create_read_teams_messages_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="TEAMS_CONNECTOR", - ), - ToolDefinition( - name="send_teams_message", - description="Send a message to a Microsoft Teams channel", - factory=lambda deps: create_send_teams_message_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="TEAMS_CONNECTOR", - ), - # ========================================================================= - # LUMA TOOLS - list events, read event details, create events - # Auto-disabled when no Luma connector is configured - # ========================================================================= - ToolDefinition( - name="list_luma_events", - description="List upcoming and recent Luma events", - factory=lambda deps: create_list_luma_events_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="LUMA_CONNECTOR", - ), - ToolDefinition( - name="read_luma_event", - description="Read detailed information about a specific Luma event", - factory=lambda deps: create_read_luma_event_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="LUMA_CONNECTOR", - ), - ToolDefinition( - name="create_luma_event", - description="Create a new event on Luma", - factory=lambda deps: create_create_luma_event_tool( - db_session=deps["db_session"], - search_space_id=deps["search_space_id"], - user_id=deps["user_id"], - ), - requires=["db_session", "search_space_id", "user_id"], - required_connector="LUMA_CONNECTOR", - ), -] - - -# ============================================================================= -# Registry Functions -# ============================================================================= - - -def get_connector_gated_tools( - available_connectors: list[str] | None, -) -> list[str]: - """Return tool names to disable""" - available = set() if available_connectors is None else set(available_connectors) - - disabled: list[str] = [] - for tool_def in BUILTIN_TOOLS: - if tool_def.required_connector and tool_def.required_connector not in available: - disabled.append(tool_def.name) - return disabled diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index da7c27be5..55b825f8b 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -1668,7 +1668,7 @@ async def list_agent_tools( Hidden (WIP) tools are excluded from the response. """ - from app.agents.shared.tools.registry import BUILTIN_TOOLS + from app.agents.shared.tools.catalog import TOOL_CATALOG return [ AgentToolInfo( @@ -1676,7 +1676,7 @@ async def list_agent_tools( description=t.description, enabled_by_default=t.enabled_by_default, ) - for t in BUILTIN_TOOLS + for t in TOOL_CATALOG if not t.hidden ] diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_action_log.py b/surfsense_backend/tests/unit/agents/new_chat/test_action_log.py index 387d67e61..3e8c7ffaf 100644 --- a/surfsense_backend/tests/unit/agents/new_chat/test_action_log.py +++ b/surfsense_backend/tests/unit/agents/new_chat/test_action_log.py @@ -12,7 +12,7 @@ from langchain_core.tools import tool from app.agents.shared.feature_flags import AgentFeatureFlags from app.agents.shared.middleware.action_log import ActionLogMiddleware -from app.agents.shared.tools.registry import ToolDefinition +from app.agents.shared.middleware.action_log import ToolDefinition @dataclass diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_dedup_tool_calls.py b/surfsense_backend/tests/unit/agents/new_chat/test_dedup_tool_calls.py index 65c2c578a..6996a717f 100644 --- a/surfsense_backend/tests/unit/agents/new_chat/test_dedup_tool_calls.py +++ b/surfsense_backend/tests/unit/agents/new_chat/test_dedup_tool_calls.py @@ -91,10 +91,9 @@ def test_no_agent_tools_means_no_dedup() -> None: """After the cleanup tier removed the legacy ``_NATIVE_HITL_TOOL_DEDUP_KEYS`` map, dedup is purely declarative — no resolvers means no dedup runs. - Coverage for the previously hardcoded native HITL tools now lives on - each :class:`ToolDefinition.dedup_key` in - :mod:`app.agents.shared.tools.registry`, which is wired through to - ``tool.metadata`` by :func:`build_tools`. + Dedup is purely declarative: tools opt in by carrying a ``dedup_key`` + (callable) or ``hitl_dedup_key`` (arg name) in their ``metadata``. With no + agent tools, there are no resolvers and dedup is a no-op. """ mw = DedupHITLToolCallsMiddleware(agent_tools=None) state = { @@ -109,27 +108,6 @@ def test_no_agent_tools_means_no_dedup() -> None: assert out is None -def test_registry_propagates_dedup_key_to_tool_metadata() -> None: - """Smoke-check the wiring path that replaced the legacy native map. - - ``ToolDefinition.dedup_key`` set in the registry must be copied onto - the constructed tool's ``metadata`` so :class:`DedupHITLToolCallsMiddleware` - can pick it up at agent build time. - """ - from app.agents.shared.tools.registry import ( - BUILTIN_TOOLS, - wrap_dedup_key_by_arg_name, - ) - - notion_tool_defs = [t for t in BUILTIN_TOOLS if t.name == "create_notion_page"] - assert notion_tool_defs, "registry should still expose create_notion_page" - tool_def = notion_tool_defs[0] - assert tool_def.dedup_key is not None - # Same wrapping helper used in the registry — sanity check identity - sample = wrap_dedup_key_by_arg_name("title")({"title": "Plan"}) - assert sample == "plan" - - def test_full_args_dedup_keeps_distinct_calls_sharing_a_field() -> None: """Regression: MCP tools (e.g. ``createJiraIssue``) used to dedup on the schema's first required field, which is often the workspace /