refactor(agents): move tools package to app/agents/shared (slice 6)

Relocate the entire new_chat/tools/ package (62 files incl. registry, hitl, MCP
cluster, and all connector subpackages: gmail/slack/discord/teams/drive/etc.)
to the shared kernel. The package turned out to be a clean cohesive cluster:
its only references to non-tools new_chat modules were comments, and its
middleware deps were already flipped to shared in slice 5c.

Flip 33 live importers (multi-agent, flows, routes, services, anonymous_agent,
tests). Re-export shims remain for the frozen single-agent stack: a package
__init__ mirroring the public surface (new_chat.__init__ imports it) plus
invalid_tool + registry submodule shims (chat_deepagent imports those).

Resolves slice 5c's two transient back-edges: shared/middleware/action_log
(TYPE_CHECKING ToolDefinition) and tool_call_repair (local INVALID_TOOL_NAME)
now point at app.agents.shared.tools.
This commit is contained in:
CREDO23 2026-06-04 13:11:56 +02:00
parent a7fde2a48e
commit aab95b9130
98 changed files with 1232 additions and 1152 deletions

View file

@ -27,8 +27,8 @@ from app.agents.shared.filesystem_backends import build_backend_resolver
from app.agents.shared.filesystem_selection import FilesystemMode, FilesystemSelection from app.agents.shared.filesystem_selection import FilesystemMode, FilesystemSelection
from app.agents.shared.llm_config import AgentConfig from app.agents.shared.llm_config import AgentConfig
from app.agents.shared.prompt_caching import apply_litellm_prompt_caching from app.agents.shared.prompt_caching import apply_litellm_prompt_caching
from app.agents.new_chat.tools.invalid_tool import INVALID_TOOL_NAME, invalid_tool from app.agents.shared.tools.invalid_tool import INVALID_TOOL_NAME, invalid_tool
from app.agents.new_chat.tools.registry import build_tools_async from app.agents.shared.tools.registry import build_tools_async
from app.db import ChatVisibility from app.db import ChatVisibility
from app.services.connector_service import ConnectorService from app.services.connector_service import ConnectorService
from app.services.user_tool_allowlist import ( from app.services.user_tool_allowlist import (

View file

@ -6,7 +6,7 @@ import logging
from app.agents.shared.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.shared.middleware import ActionLogMiddleware from app.agents.shared.middleware import ActionLogMiddleware
from app.agents.new_chat.tools.registry import BUILTIN_TOOLS from app.agents.shared.tools.registry import BUILTIN_TOOLS
from ..shared.flags import enabled from ..shared.flags import enabled

View file

@ -1,13 +1,13 @@
from app.agents.new_chat.tools.google_calendar.create_event import ( from app.agents.shared.tools.google_calendar.create_event import (
create_create_calendar_event_tool, create_create_calendar_event_tool,
) )
from app.agents.new_chat.tools.google_calendar.delete_event import ( from app.agents.shared.tools.google_calendar.delete_event import (
create_delete_calendar_event_tool, create_delete_calendar_event_tool,
) )
from app.agents.new_chat.tools.google_calendar.search_events import ( from app.agents.shared.tools.google_calendar.search_events import (
create_search_calendar_events_tool, create_search_calendar_events_tool,
) )
from app.agents.new_chat.tools.google_calendar.update_event import ( from app.agents.shared.tools.google_calendar.update_event import (
create_update_calendar_event_tool, create_update_calendar_event_tool,
) )

View file

@ -5,7 +5,7 @@ from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from app.agents.new_chat.tools.gmail.search_emails import _build_credentials from app.agents.shared.tools.gmail.search_emails import _build_credentials
from app.db import SearchSourceConnector, SearchSourceConnectorType from app.db import SearchSourceConnector, SearchSourceConnectorType
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -1,10 +1,10 @@
from app.agents.new_chat.tools.discord.list_channels import ( from app.agents.shared.tools.discord.list_channels import (
create_list_discord_channels_tool, create_list_discord_channels_tool,
) )
from app.agents.new_chat.tools.discord.read_messages import ( from app.agents.shared.tools.discord.read_messages import (
create_read_discord_messages_tool, create_read_discord_messages_tool,
) )
from app.agents.new_chat.tools.discord.send_message import ( from app.agents.shared.tools.discord.send_message import (
create_send_discord_message_tool, create_send_discord_message_tool,
) )

View file

@ -1,7 +1,7 @@
from app.agents.new_chat.tools.dropbox.create_file import ( from app.agents.shared.tools.dropbox.create_file import (
create_create_dropbox_file_tool, create_create_dropbox_file_tool,
) )
from app.agents.new_chat.tools.dropbox.trash_file import ( from app.agents.shared.tools.dropbox.trash_file import (
create_delete_dropbox_file_tool, create_delete_dropbox_file_tool,
) )

View file

@ -1,19 +1,19 @@
from app.agents.new_chat.tools.gmail.create_draft import ( from app.agents.shared.tools.gmail.create_draft import (
create_create_gmail_draft_tool, create_create_gmail_draft_tool,
) )
from app.agents.new_chat.tools.gmail.read_email import ( from app.agents.shared.tools.gmail.read_email import (
create_read_gmail_email_tool, create_read_gmail_email_tool,
) )
from app.agents.new_chat.tools.gmail.search_emails import ( from app.agents.shared.tools.gmail.search_emails import (
create_search_gmail_tool, create_search_gmail_tool,
) )
from app.agents.new_chat.tools.gmail.send_email import ( from app.agents.shared.tools.gmail.send_email import (
create_send_gmail_email_tool, create_send_gmail_email_tool,
) )
from app.agents.new_chat.tools.gmail.trash_email import ( from app.agents.shared.tools.gmail.trash_email import (
create_trash_gmail_email_tool, create_trash_gmail_email_tool,
) )
from app.agents.new_chat.tools.gmail.update_draft import ( from app.agents.shared.tools.gmail.update_draft import (
create_update_gmail_draft_tool, create_update_gmail_draft_tool,
) )

View file

@ -61,7 +61,7 @@ def create_read_gmail_email_tool(
"message": "Composio connected account ID not found for this Gmail connector.", "message": "Composio connected account ID not found for this Gmail connector.",
} }
from app.agents.new_chat.tools.gmail.search_emails import ( from app.agents.shared.tools.gmail.search_emails import (
_format_gmail_summary, _format_gmail_summary,
) )
from app.services.composio_service import ComposioService from app.services.composio_service import ComposioService
@ -97,7 +97,7 @@ def create_read_gmail_email_tool(
"content": content, "content": content,
} }
from app.agents.new_chat.tools.gmail.search_emails import ( from app.agents.shared.tools.gmail.search_emails import (
_build_credentials, _build_credentials,
) )

View file

@ -69,7 +69,7 @@ def create_search_gmail_tool(
"message": "Composio connected account ID not found for this Gmail connector.", "message": "Composio connected account ID not found for this Gmail connector.",
} }
from app.agents.new_chat.tools.gmail.search_emails import ( from app.agents.shared.tools.gmail.search_emails import (
_format_gmail_summary, _format_gmail_summary,
) )
from app.services.composio_service import ComposioService from app.services.composio_service import ComposioService
@ -98,7 +98,7 @@ def create_search_gmail_tool(
} }
return {"status": "success", "emails": emails, "total": len(emails)} return {"status": "success", "emails": emails, "total": len(emails)}
from app.agents.new_chat.tools.gmail.search_emails import ( from app.agents.shared.tools.gmail.search_emails import (
_build_credentials, _build_credentials,
) )

View file

@ -1,7 +1,7 @@
from app.agents.new_chat.tools.google_drive.create_file import ( from app.agents.shared.tools.google_drive.create_file import (
create_create_google_drive_file_tool, create_create_google_drive_file_tool,
) )
from app.agents.new_chat.tools.google_drive.trash_file import ( from app.agents.shared.tools.google_drive.trash_file import (
create_delete_google_drive_file_tool, create_delete_google_drive_file_tool,
) )

View file

@ -1,10 +1,10 @@
from app.agents.new_chat.tools.luma.create_event import ( from app.agents.shared.tools.luma.create_event import (
create_create_luma_event_tool, create_create_luma_event_tool,
) )
from app.agents.new_chat.tools.luma.list_events import ( from app.agents.shared.tools.luma.list_events import (
create_list_luma_events_tool, create_list_luma_events_tool,
) )
from app.agents.new_chat.tools.luma.read_event import ( from app.agents.shared.tools.luma.read_event import (
create_read_luma_event_tool, create_read_luma_event_tool,
) )

View file

@ -1,7 +1,7 @@
from app.agents.new_chat.tools.onedrive.create_file import ( from app.agents.shared.tools.onedrive.create_file import (
create_create_onedrive_file_tool, create_create_onedrive_file_tool,
) )
from app.agents.new_chat.tools.onedrive.trash_file import ( from app.agents.shared.tools.onedrive.trash_file import (
create_delete_onedrive_file_tool, create_delete_onedrive_file_tool,
) )

View file

@ -1,10 +1,10 @@
from app.agents.new_chat.tools.teams.list_channels import ( from app.agents.shared.tools.teams.list_channels import (
create_list_teams_channels_tool, create_list_teams_channels_tool,
) )
from app.agents.new_chat.tools.teams.read_messages import ( from app.agents.shared.tools.teams.read_messages import (
create_read_teams_messages_tool, create_read_teams_messages_tool,
) )
from app.agents.new_chat.tools.teams.send_message import ( from app.agents.shared.tools.teams.send_message import (
create_send_teams_message_tool, create_send_teams_message_tool,
) )

View file

@ -21,7 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.multi_agent_chat.constants import ( from app.agents.multi_agent_chat.constants import (
CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS, CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS,
) )
from app.agents.new_chat.tools.mcp_tool import load_mcp_tools from app.agents.shared.tools.mcp_tool import load_mcp_tools
from app.db import SearchSourceConnector from app.db import SearchSourceConnector
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -32,7 +32,7 @@ from app.agents.shared.middleware import (
RetryAfterMiddleware, RetryAfterMiddleware,
create_surfsense_compaction_middleware, create_surfsense_compaction_middleware,
) )
from app.agents.new_chat.tools.web_search import create_web_search_tool from app.agents.shared.tools.web_search import create_web_search_tool
# Cap how much of an uploaded document we inline into the system prompt. The # Cap how much of an uploaded document we inline into the system prompt. The
# upload endpoint allows files up to several MB, but the doc is re-sent on # upload endpoint allows files up to several MB, but the doc is re-sent on

View file

@ -1,46 +1,35 @@
""" """Backward-compatible shim package.
Tools module for SurfSense deep agent.
This module contains all the tools available to the SurfSense agent. The agent tools now live in the shared kernel at ``app.agents.shared.tools``.
To add a new tool, see the documentation in registry.py. This package re-exports the public surface (and keeps ``invalid_tool`` /
``registry`` submodule shims) so the frozen single-agent stack
Available tools: (``new_chat.__init__`` and ``chat_deepagent``) keeps working until that stack is
- generate_podcast: Generate audio podcasts from content retired. All live code imports from ``app.agents.shared.tools`` directly.
- generate_video_presentation: Generate video presentations with slides and narration
- generate_image: Generate images from text descriptions using AI models
- scrape_webpage: Extract content from webpages
- update_memory: Update the user's / team's memory document
""" """
# Registry exports from app.agents.shared.tools import (
# Tool factory exports (for direct use)
from .generate_image import create_generate_image_tool
from .knowledge_base import (
CONNECTOR_DESCRIPTIONS,
format_documents_for_context,
search_knowledge_base_async,
)
from .podcast import create_generate_podcast_tool
from .registry import (
BUILTIN_TOOLS, BUILTIN_TOOLS,
CONNECTOR_DESCRIPTIONS,
ToolDefinition, ToolDefinition,
build_tools, build_tools,
create_generate_image_tool,
create_generate_podcast_tool,
create_generate_video_presentation_tool,
create_scrape_webpage_tool,
create_update_memory_tool,
create_update_team_memory_tool,
format_documents_for_context,
get_all_tool_names, get_all_tool_names,
get_default_enabled_tools, get_default_enabled_tools,
get_tool_by_name, get_tool_by_name,
search_knowledge_base_async,
) )
from .scrape_webpage import create_scrape_webpage_tool
from .update_memory import create_update_memory_tool, create_update_team_memory_tool
from .video_presentation import create_generate_video_presentation_tool
__all__ = [ __all__ = [
# Registry
"BUILTIN_TOOLS", "BUILTIN_TOOLS",
# Knowledge base utilities
"CONNECTOR_DESCRIPTIONS", "CONNECTOR_DESCRIPTIONS",
"ToolDefinition", "ToolDefinition",
"build_tools", "build_tools",
# Tool factories
"create_generate_image_tool", "create_generate_image_tool",
"create_generate_podcast_tool", "create_generate_podcast_tool",
"create_generate_video_presentation_tool", "create_generate_video_presentation_tool",

View file

@ -1,51 +1,15 @@
""" """Backward-compatible shim.
The ``invalid`` fallback tool.
When the model emits a tool call whose name doesn't match any registered Moved to ``app.agents.shared.tools.invalid_tool``. Re-exported here for the
tool, :class:`ToolCallNameRepairMiddleware` rewrites the call to ``invalid`` frozen single-agent stack (``chat_deepagent``) until that stack is retired.
with the original name and a parser/validation error string. This tool's
execution then returns that error to the model so it can self-correct.
Ported from OpenCode's ``packages/opencode/src/tool/invalid.ts`` —
LangChain has no equivalent fallback path; the default behavior on an
unknown tool name is a hard ``ToolNotFoundError`` which kills the turn.
Critically, the :class:`ToolDefinition` for this tool is **excluded** from
the system-prompt tool list and from ``LLMToolSelectorMiddleware`` selection
(see ``ToolDefinition.always_include`` filtering in the registry) the
model never advertises ``invalid`` as a callable. It only ever shows up
in the tool registry so LangGraph can dispatch the rewritten call.
""" """
from __future__ import annotations from app.agents.shared.tools.invalid_tool import (
INVALID_TOOL_DESCRIPTION,
from langchain_core.tools import tool INVALID_TOOL_NAME,
invalid_tool,
INVALID_TOOL_NAME = "invalid"
INVALID_TOOL_DESCRIPTION = "Do not use"
def _format_invalid_message(tool: str | None, error: str | None) -> str:
"""Return the user-visible error string. Mirrors ``invalid.ts``."""
name = tool or "<unknown>"
detail = error or "(no error message provided)"
return (
f"The arguments provided to the tool `{name}` are invalid: {detail}\n"
f"Read the tool's docstring carefully and try again with valid arguments."
) )
@tool(name_or_callable=INVALID_TOOL_NAME, description=INVALID_TOOL_DESCRIPTION)
def invalid_tool(tool: str | None = None, error: str | None = None) -> str:
"""Return a human-readable explanation of a tool-call validation failure.
Activated only when :class:`ToolCallNameRepairMiddleware` rewrites a
failed tool call to ``invalid`` with the original tool name and the
error message produced during validation.
"""
return _format_invalid_message(tool, error)
__all__ = [ __all__ = [
"INVALID_TOOL_DESCRIPTION", "INVALID_TOOL_DESCRIPTION",
"INVALID_TOOL_NAME", "INVALID_TOOL_NAME",

View file

@ -1,962 +1,19 @@
"""Tools registry for SurfSense deep agent. """Backward-compatible shim.
This module provides a registry pattern for managing tools in the SurfSense agent. Moved to ``app.agents.shared.tools.registry``. Re-exported here for the frozen
It makes it easy for OSS contributors to add new tools by: single-agent stack (``chat_deepagent``) until that stack is retired.
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 app.agents.shared.tools.registry import (
from collections.abc import Callable BUILTIN_TOOLS,
from dataclasses import dataclass, field ToolDefinition,
from typing import Any build_tools_async,
get_connector_gated_tools,
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 .mcp_tool import load_mcp_tools
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 .scrape_webpage import create_scrape_webpage_tool
from .teams import (
create_list_teams_channels_tool,
create_read_teams_messages_tool,
create_send_teams_message_tool,
)
from .update_memory import create_update_memory_tool, create_update_team_memory_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( __all__ = [
search_space_id=deps["search_space_id"], "BUILTIN_TOOLS",
user_id=deps["user_id"], "ToolDefinition",
llm=deps["llm"], "build_tools_async",
) "get_connector_gated_tools",
# =============================================================================
# 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=lambda deps: create_scrape_webpage_tool(
firecrawl_api_key=deps.get("firecrawl_api_key"),
),
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=lambda deps: (
create_update_team_memory_tool(
search_space_id=deps["search_space_id"],
db_session=deps["db_session"],
llm=deps.get("llm"),
)
if deps["thread_visibility"] == ChatVisibility.SEARCH_SPACE
else create_update_memory_tool(
user_id=deps["user_id"],
db_session=deps["db_session"],
llm=deps.get("llm"),
)
),
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 (see chat_deepagent.py)
# =========================================================================
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 (see chat_deepagent.py)
# =========================================================================
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 (see chat_deepagent.py)
# =========================================================================
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 (see chat_deepagent.py)
# =========================================================================
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 (see chat_deepagent.py)
# =========================================================================
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_tool_by_name(name: str) -> ToolDefinition | None:
"""Get a tool definition by its name."""
for tool_def in BUILTIN_TOOLS:
if tool_def.name == name:
return tool_def
return None
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
def get_all_tool_names() -> list[str]:
"""Get names of all registered tools."""
return [tool_def.name for tool_def in BUILTIN_TOOLS]
def get_default_enabled_tools() -> list[str]:
"""Get names of tools that are enabled by default (excludes hidden tools)."""
return [tool_def.name for tool_def in BUILTIN_TOOLS if tool_def.enabled_by_default]
def build_tools(
dependencies: dict[str, Any],
enabled_tools: list[str] | None = None,
disabled_tools: list[str] | None = None,
additional_tools: list[BaseTool] | None = None,
) -> list[BaseTool]:
"""Build the list of tools for the agent.
Args:
dependencies: Dict containing all possible dependencies:
- search_space_id: The search space ID
- db_session: Database session
- connector_service: Connector service instance
- firecrawl_api_key: Optional Firecrawl API key
enabled_tools: Explicit list of tool names to enable. If None, uses defaults.
disabled_tools: List of tool names to disable (applied after enabled_tools).
additional_tools: Extra tools to add (e.g., custom tools not in registry).
Returns:
List of configured tool instances ready for the agent.
Example:
# Use all default tools
tools = build_tools(deps)
# Use only specific tools
tools = build_tools(deps, enabled_tools=["generate_report"])
# Use defaults but disable podcast
tools = build_tools(deps, disabled_tools=["generate_podcast"])
# Add custom tools
tools = build_tools(deps, additional_tools=[my_custom_tool])
"""
# Determine which tools to enable
if enabled_tools is not None:
tool_names_to_use = set(enabled_tools)
else:
tool_names_to_use = set(get_default_enabled_tools())
# Apply disabled list
if disabled_tools:
tool_names_to_use -= set(disabled_tools)
# Build the tools (skip hidden/WIP tools unconditionally)
tools: list[BaseTool] = []
for tool_def in BUILTIN_TOOLS:
if tool_def.hidden or tool_def.name not in tool_names_to_use:
continue
# Check that all required dependencies are provided
missing_deps = [dep for dep in tool_def.requires if dep not in dependencies]
if missing_deps:
msg = f"Tool '{tool_def.name}' requires dependencies: {missing_deps}"
raise ValueError(
msg,
)
# Create the tool
tool = tool_def.factory(dependencies)
# Propagate the registry-level metadata so middleware (e.g.
# ``DedupHITLToolCallsMiddleware``) and the action-log/revert
# pipeline can pick the resolvers up via ``tool.metadata`` without
# re-importing :data:`BUILTIN_TOOLS`.
if tool_def.dedup_key is not None or tool_def.reverse is not None:
existing_meta = getattr(tool, "metadata", None) or {}
merged_meta = dict(existing_meta)
if tool_def.dedup_key is not None:
merged_meta.setdefault("dedup_key", tool_def.dedup_key)
if tool_def.reverse is not None:
merged_meta.setdefault("reverse", tool_def.reverse)
try:
tool.metadata = merged_meta
except Exception:
logger.debug(
"Tool %s rejected metadata mutation; relying on registry lookup",
tool_def.name,
)
tools.append(tool)
# Add any additional custom tools
if additional_tools:
tools.extend(additional_tools)
return tools
async def build_tools_async(
dependencies: dict[str, Any],
enabled_tools: list[str] | None = None,
disabled_tools: list[str] | None = None,
additional_tools: list[BaseTool] | None = None,
include_mcp_tools: bool = True,
) -> list[BaseTool]:
"""Async version of build_tools that also loads MCP tools from database.
Design Note:
This function exists because MCP tools require database queries to load
user configs, while built-in tools are created synchronously from static
code.
Alternative: We could make build_tools() itself async and always query
the database, but that would force async everywhere even when only using
built-in tools. The current design keeps the simple case (static tools
only) synchronous while supporting dynamic database-loaded tools through
this async wrapper.
Phase 1.3: built-in tool construction (CPU; runs in a thread pool to
avoid event-loop stalls) and MCP tool loading (HTTP/DB I/O; runs on
the event loop) are kicked off concurrently. Cold-path savings are
bounded by the slower of the two typically MCP at ~200ms-1.7s
so the parallelization recovers the ~50-200ms previously spent
serially on built-in construction.
Args:
dependencies: Dict containing all possible dependencies
enabled_tools: Explicit list of tool names to enable. If None, uses defaults.
disabled_tools: List of tool names to disable (applied after enabled_tools).
additional_tools: Extra tools to add (e.g., custom tools not in registry).
include_mcp_tools: Whether to load user's MCP tools from database.
Returns:
List of configured tool instances ready for the agent, including MCP tools.
"""
import asyncio
import time
_perf_log = logging.getLogger("surfsense.perf")
_perf_log.setLevel(logging.DEBUG)
can_load_mcp = (
include_mcp_tools
and "db_session" in dependencies
and "search_space_id" in dependencies
)
# Built-in tool construction is synchronous + CPU-only. Off-loop it so
# MCP's HTTP/DB I/O can fire concurrently. ``build_tools`` is pure
# function over its inputs — safe to thread-shift.
_t0 = time.perf_counter()
builtin_task = asyncio.create_task(
asyncio.to_thread(
build_tools, dependencies, enabled_tools, disabled_tools, additional_tools
)
)
mcp_task: asyncio.Task | None = None
if can_load_mcp:
mcp_task = asyncio.create_task(
load_mcp_tools(
dependencies["db_session"],
dependencies["search_space_id"],
)
)
# Surface failures from each task independently so a flaky MCP
# endpoint never poisons built-in tool registration. ``return_exceptions``
# gives us per-task exceptions instead of dropping the second result
# when the first raises.
if mcp_task is not None:
builtin_result, mcp_result = await asyncio.gather(
builtin_task, mcp_task, return_exceptions=True
)
else:
builtin_result = await builtin_task
mcp_result = None
if isinstance(builtin_result, BaseException):
raise builtin_result # built-in registration failure is non-recoverable
tools: list[BaseTool] = builtin_result
_perf_log.info(
"[build_tools_async] Built-in tools in %.3fs (%d tools, parallel)",
time.perf_counter() - _t0,
len(tools),
)
if mcp_task is not None:
if isinstance(mcp_result, BaseException):
# ``return_exceptions=True`` captures the exception out-of-band,
# so ``sys.exc_info()`` is empty here. Pass the captured
# exception via ``exc_info=`` to get a real traceback.
logging.error(
"Failed to load MCP tools: %s", mcp_result, exc_info=mcp_result
)
else:
mcp_tools = mcp_result or []
_perf_log.info(
"[build_tools_async] MCP tools loaded in %.3fs (%d tools, parallel)",
time.perf_counter() - _t0,
len(mcp_tools),
)
tools.extend(mcp_tools)
logging.info(
"Registered %d MCP tools: %s",
len(mcp_tools),
[t.name for t in mcp_tools],
)
logging.info(
"Total tools for agent: %d%s",
len(tools),
[t.name for t in tools],
)
return tools

View file

@ -3,7 +3,7 @@
Wraps every tool call via :meth:`AgentMiddleware.awrap_tool_call` and writes 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 a row to :class:`~app.db.AgentActionLog` after the tool returns. Tools opt
into reversibility by declaring a ``reverse`` callable on their into reversibility by declaring a ``reverse`` callable on their
:class:`~app.agents.new_chat.tools.registry.ToolDefinition`; the rendered :class:`~app.agents.shared.tools.registry.ToolDefinition`; the rendered
descriptor is persisted in ``reverse_descriptor`` for use by descriptor is persisted in ``reverse_descriptor`` for use by
``/api/threads/{thread_id}/revert/{action_id}``. ``/api/threads/{thread_id}/revert/{action_id}``.
@ -42,7 +42,7 @@ if TYPE_CHECKING: # pragma: no cover - type-only
# Type-only import: keeping it lazy avoids a module-load cycle through the # Type-only import: keeping it lazy avoids a module-load cycle through the
# frozen single-agent package (new_chat.__init__ -> chat_deepagent -> # frozen single-agent package (new_chat.__init__ -> chat_deepagent ->
# middleware shim). Resolves to app.agents.shared.tools once tools migrate. # middleware shim). Resolves to app.agents.shared.tools once tools migrate.
from app.agents.new_chat.tools.registry import ToolDefinition from app.agents.shared.tools.registry import ToolDefinition
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -121,7 +121,7 @@ class ToolCallNameRepairMiddleware(
# Local import avoids a module-load cycle through the frozen single-agent # Local import avoids a module-load cycle through the frozen single-agent
# package (new_chat.__init__ -> chat_deepagent -> middleware shim). # package (new_chat.__init__ -> chat_deepagent -> middleware shim).
# Resolves to app.agents.shared.tools once tools migrate. # Resolves to app.agents.shared.tools once tools migrate.
from app.agents.new_chat.tools.invalid_tool import INVALID_TOOL_NAME from app.agents.shared.tools.invalid_tool import INVALID_TOOL_NAME
if INVALID_TOOL_NAME in registered: if INVALID_TOOL_NAME in registered:
original_args = call.get("args") or {} original_args = call.get("args") or {}

View file

@ -0,0 +1,55 @@
"""
Tools module for SurfSense deep agent.
This module contains all the tools available to the SurfSense agent.
To add a new tool, see the documentation in registry.py.
Available tools:
- generate_podcast: Generate audio podcasts from content
- generate_video_presentation: Generate video presentations with slides and narration
- generate_image: Generate images from text descriptions using AI models
- scrape_webpage: Extract content from webpages
- update_memory: Update the user's / team's memory document
"""
# Registry exports
# Tool factory exports (for direct use)
from .generate_image import create_generate_image_tool
from .knowledge_base import (
CONNECTOR_DESCRIPTIONS,
format_documents_for_context,
search_knowledge_base_async,
)
from .podcast import create_generate_podcast_tool
from .registry import (
BUILTIN_TOOLS,
ToolDefinition,
build_tools,
get_all_tool_names,
get_default_enabled_tools,
get_tool_by_name,
)
from .scrape_webpage import create_scrape_webpage_tool
from .update_memory import create_update_memory_tool, create_update_team_memory_tool
from .video_presentation import create_generate_video_presentation_tool
__all__ = [
# Registry
"BUILTIN_TOOLS",
# Knowledge base utilities
"CONNECTOR_DESCRIPTIONS",
"ToolDefinition",
"build_tools",
# Tool factories
"create_generate_image_tool",
"create_generate_podcast_tool",
"create_generate_video_presentation_tool",
"create_scrape_webpage_tool",
"create_update_memory_tool",
"create_update_team_memory_tool",
"format_documents_for_context",
"get_all_tool_names",
"get_default_enabled_tools",
"get_tool_by_name",
"search_knowledge_base_async",
]

View file

@ -5,7 +5,7 @@ from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
from app.agents.new_chat.tools.hitl import request_approval from app.agents.shared.tools.hitl import request_approval
from app.connectors.confluence_history import ConfluenceHistoryConnector from app.connectors.confluence_history import ConfluenceHistoryConnector
from app.db import async_session_maker from app.db import async_session_maker
from app.services.confluence import ConfluenceToolMetadataService from app.services.confluence import ConfluenceToolMetadataService

View file

@ -5,7 +5,7 @@ from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
from app.agents.new_chat.tools.hitl import request_approval from app.agents.shared.tools.hitl import request_approval
from app.connectors.confluence_history import ConfluenceHistoryConnector from app.connectors.confluence_history import ConfluenceHistoryConnector
from app.db import async_session_maker from app.db import async_session_maker
from app.services.confluence import ConfluenceToolMetadataService from app.services.confluence import ConfluenceToolMetadataService

View file

@ -5,7 +5,7 @@ from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
from app.agents.new_chat.tools.hitl import request_approval from app.agents.shared.tools.hitl import request_approval
from app.connectors.confluence_history import ConfluenceHistoryConnector from app.connectors.confluence_history import ConfluenceHistoryConnector
from app.db import async_session_maker from app.db import async_session_maker
from app.services.confluence import ConfluenceToolMetadataService from app.services.confluence import ConfluenceToolMetadataService

View file

@ -1,10 +1,10 @@
from app.agents.new_chat.tools.discord.list_channels import ( from app.agents.shared.tools.discord.list_channels import (
create_list_discord_channels_tool, create_list_discord_channels_tool,
) )
from app.agents.new_chat.tools.discord.read_messages import ( from app.agents.shared.tools.discord.read_messages import (
create_read_discord_messages_tool, create_read_discord_messages_tool,
) )
from app.agents.new_chat.tools.discord.send_message import ( from app.agents.shared.tools.discord.send_message import (
create_send_discord_message_tool, create_send_discord_message_tool,
) )

View file

@ -5,7 +5,7 @@ import httpx
from langchain_core.tools import tool from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval from app.agents.shared.tools.hitl import request_approval
from app.db import async_session_maker from app.db import async_session_maker
from ._auth import DISCORD_API, get_bot_token, get_discord_connector from ._auth import DISCORD_API, get_bot_token, get_discord_connector

View file

@ -1,7 +1,7 @@
from app.agents.new_chat.tools.dropbox.create_file import ( from app.agents.shared.tools.dropbox.create_file import (
create_create_dropbox_file_tool, create_create_dropbox_file_tool,
) )
from app.agents.new_chat.tools.dropbox.trash_file import ( from app.agents.shared.tools.dropbox.trash_file import (
create_delete_dropbox_file_tool, create_delete_dropbox_file_tool,
) )

View file

@ -8,7 +8,7 @@ from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from app.agents.new_chat.tools.hitl import request_approval from app.agents.shared.tools.hitl import request_approval
from app.connectors.dropbox.client import DropboxClient from app.connectors.dropbox.client import DropboxClient
from app.db import SearchSourceConnector, SearchSourceConnectorType, async_session_maker from app.db import SearchSourceConnector, SearchSourceConnectorType, async_session_maker

View file

@ -6,7 +6,7 @@ from sqlalchemy import String, and_, cast, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from app.agents.new_chat.tools.hitl import request_approval from app.agents.shared.tools.hitl import request_approval
from app.connectors.dropbox.client import DropboxClient from app.connectors.dropbox.client import DropboxClient
from app.db import ( from app.db import (
Document, Document,

View file

@ -1,19 +1,19 @@
from app.agents.new_chat.tools.gmail.create_draft import ( from app.agents.shared.tools.gmail.create_draft import (
create_create_gmail_draft_tool, create_create_gmail_draft_tool,
) )
from app.agents.new_chat.tools.gmail.read_email import ( from app.agents.shared.tools.gmail.read_email import (
create_read_gmail_email_tool, create_read_gmail_email_tool,
) )
from app.agents.new_chat.tools.gmail.search_emails import ( from app.agents.shared.tools.gmail.search_emails import (
create_search_gmail_tool, create_search_gmail_tool,
) )
from app.agents.new_chat.tools.gmail.send_email import ( from app.agents.shared.tools.gmail.send_email import (
create_send_gmail_email_tool, create_send_gmail_email_tool,
) )
from app.agents.new_chat.tools.gmail.trash_email import ( from app.agents.shared.tools.gmail.trash_email import (
create_trash_gmail_email_tool, create_trash_gmail_email_tool,
) )
from app.agents.new_chat.tools.gmail.update_draft import ( from app.agents.shared.tools.gmail.update_draft import (
create_update_gmail_draft_tool, create_update_gmail_draft_tool,
) )

View file

@ -8,7 +8,7 @@ from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval from app.agents.shared.tools.hitl import request_approval
from app.db import async_session_maker from app.db import async_session_maker
from app.services.gmail import GmailToolMetadataService from app.services.gmail import GmailToolMetadataService
@ -241,7 +241,7 @@ def create_create_gmail_draft_tool(
try: try:
if is_composio_gmail: if is_composio_gmail:
from app.agents.new_chat.tools.gmail.composio_helpers import ( from app.agents.shared.tools.gmail.composio_helpers import (
execute_composio_gmail_tool, execute_composio_gmail_tool,
split_recipients, split_recipients,
) )

View file

@ -79,7 +79,7 @@ def create_read_gmail_email_tool(
"message": "Composio connected account ID not found.", "message": "Composio connected account ID not found.",
} }
from app.agents.new_chat.tools.gmail.search_emails import ( from app.agents.shared.tools.gmail.search_emails import (
_format_gmail_summary, _format_gmail_summary,
) )
from app.services.composio_service import ComposioService from app.services.composio_service import ComposioService
@ -116,7 +116,7 @@ def create_read_gmail_email_tool(
"content": content, "content": content,
} }
from app.agents.new_chat.tools.gmail.search_emails import ( from app.agents.shared.tools.gmail.search_emails import (
_build_credentials, _build_credentials,
) )

View file

@ -8,7 +8,7 @@ from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval from app.agents.shared.tools.hitl import request_approval
from app.db import async_session_maker from app.db import async_session_maker
from app.services.gmail import GmailToolMetadataService from app.services.gmail import GmailToolMetadataService
@ -242,7 +242,7 @@ def create_send_gmail_email_tool(
try: try:
if is_composio_gmail: if is_composio_gmail:
from app.agents.new_chat.tools.gmail.composio_helpers import ( from app.agents.shared.tools.gmail.composio_helpers import (
execute_composio_gmail_tool, execute_composio_gmail_tool,
split_recipients, split_recipients,
) )

View file

@ -6,7 +6,7 @@ from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval from app.agents.shared.tools.hitl import request_approval
from app.db import async_session_maker from app.db import async_session_maker
from app.services.gmail import GmailToolMetadataService from app.services.gmail import GmailToolMetadataService
@ -233,7 +233,7 @@ def create_trash_gmail_email_tool(
try: try:
if is_composio_gmail: if is_composio_gmail:
from app.agents.new_chat.tools.gmail.composio_helpers import ( from app.agents.shared.tools.gmail.composio_helpers import (
execute_composio_gmail_tool, execute_composio_gmail_tool,
) )

View file

@ -8,7 +8,7 @@ from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval from app.agents.shared.tools.hitl import request_approval
from app.db import async_session_maker from app.db import async_session_maker
from app.services.gmail import GmailToolMetadataService from app.services.gmail import GmailToolMetadataService
@ -297,7 +297,7 @@ def create_update_gmail_draft_tool(
try: try:
if is_composio_gmail: if is_composio_gmail:
from app.agents.new_chat.tools.gmail.composio_helpers import ( from app.agents.shared.tools.gmail.composio_helpers import (
execute_composio_gmail_tool, execute_composio_gmail_tool,
split_recipients, split_recipients,
) )
@ -466,7 +466,7 @@ async def _find_draft_id_by_message(gmail_service: Any, message_id: str) -> str
async def _find_composio_draft_id_by_message( async def _find_composio_draft_id_by_message(
connector: Any, user_id: str, message_id: str connector: Any, user_id: str, message_id: str
) -> str | None: ) -> str | None:
from app.agents.new_chat.tools.gmail.composio_helpers import ( from app.agents.shared.tools.gmail.composio_helpers import (
execute_composio_gmail_tool, execute_composio_gmail_tool,
) )

View file

@ -1,13 +1,13 @@
from app.agents.new_chat.tools.google_calendar.create_event import ( from app.agents.shared.tools.google_calendar.create_event import (
create_create_calendar_event_tool, create_create_calendar_event_tool,
) )
from app.agents.new_chat.tools.google_calendar.delete_event import ( from app.agents.shared.tools.google_calendar.delete_event import (
create_delete_calendar_event_tool, create_delete_calendar_event_tool,
) )
from app.agents.new_chat.tools.google_calendar.search_events import ( from app.agents.shared.tools.google_calendar.search_events import (
create_search_calendar_events_tool, create_search_calendar_events_tool,
) )
from app.agents.new_chat.tools.google_calendar.update_event import ( from app.agents.shared.tools.google_calendar.update_event import (
create_update_calendar_event_tool, create_update_calendar_event_tool,
) )

View file

@ -8,7 +8,7 @@ from googleapiclient.discovery import build
from langchain_core.tools import tool from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval from app.agents.shared.tools.hitl import request_approval
from app.db import async_session_maker from app.db import async_session_maker
from app.services.google_calendar import GoogleCalendarToolMetadataService from app.services.google_calendar import GoogleCalendarToolMetadataService

View file

@ -8,7 +8,7 @@ from googleapiclient.discovery import build
from langchain_core.tools import tool from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval from app.agents.shared.tools.hitl import request_approval
from app.db import async_session_maker from app.db import async_session_maker
from app.services.google_calendar import GoogleCalendarToolMetadataService from app.services.google_calendar import GoogleCalendarToolMetadataService

View file

@ -5,7 +5,7 @@ from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from app.agents.new_chat.tools.gmail.search_emails import _build_credentials from app.agents.shared.tools.gmail.search_emails import _build_credentials
from app.db import SearchSourceConnector, SearchSourceConnectorType, async_session_maker from app.db import SearchSourceConnector, SearchSourceConnectorType, async_session_maker
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -8,7 +8,7 @@ from googleapiclient.discovery import build
from langchain_core.tools import tool from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval from app.agents.shared.tools.hitl import request_approval
from app.db import async_session_maker from app.db import async_session_maker
from app.services.google_calendar import GoogleCalendarToolMetadataService from app.services.google_calendar import GoogleCalendarToolMetadataService

View file

@ -1,7 +1,7 @@
from app.agents.new_chat.tools.google_drive.create_file import ( from app.agents.shared.tools.google_drive.create_file import (
create_create_google_drive_file_tool, create_create_google_drive_file_tool,
) )
from app.agents.new_chat.tools.google_drive.trash_file import ( from app.agents.shared.tools.google_drive.trash_file import (
create_delete_google_drive_file_tool, create_delete_google_drive_file_tool,
) )

View file

@ -5,7 +5,7 @@ from googleapiclient.errors import HttpError
from langchain_core.tools import tool from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval from app.agents.shared.tools.hitl import request_approval
from app.connectors.google_drive.client import GoogleDriveClient from app.connectors.google_drive.client import GoogleDriveClient
from app.connectors.google_drive.file_types import GOOGLE_DOC, GOOGLE_SHEET from app.connectors.google_drive.file_types import GOOGLE_DOC, GOOGLE_SHEET
from app.db import async_session_maker from app.db import async_session_maker

View file

@ -5,7 +5,7 @@ from googleapiclient.errors import HttpError
from langchain_core.tools import tool from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval from app.agents.shared.tools.hitl import request_approval
from app.connectors.google_drive.client import GoogleDriveClient from app.connectors.google_drive.client import GoogleDriveClient
from app.db import async_session_maker from app.db import async_session_maker
from app.services.google_drive import GoogleDriveToolMetadataService from app.services.google_drive import GoogleDriveToolMetadataService

View file

@ -6,7 +6,7 @@ shared by every sensitive tool (native connectors and MCP tools alike).
Usage inside a tool:: Usage inside a tool::
from app.agents.new_chat.tools.hitl import request_approval from app.agents.shared.tools.hitl import request_approval
result = request_approval( result = request_approval(
action_type="gmail_email_send", action_type="gmail_email_send",

View file

@ -0,0 +1,53 @@
"""
The ``invalid`` fallback tool.
When the model emits a tool call whose name doesn't match any registered
tool, :class:`ToolCallNameRepairMiddleware` rewrites the call to ``invalid``
with the original name and a parser/validation error string. This tool's
execution then returns that error to the model so it can self-correct.
Ported from OpenCode's ``packages/opencode/src/tool/invalid.ts`` —
LangChain has no equivalent fallback path; the default behavior on an
unknown tool name is a hard ``ToolNotFoundError`` which kills the turn.
Critically, the :class:`ToolDefinition` for this tool is **excluded** from
the system-prompt tool list and from ``LLMToolSelectorMiddleware`` selection
(see ``ToolDefinition.always_include`` filtering in the registry) the
model never advertises ``invalid`` as a callable. It only ever shows up
in the tool registry so LangGraph can dispatch the rewritten call.
"""
from __future__ import annotations
from langchain_core.tools import tool
INVALID_TOOL_NAME = "invalid"
INVALID_TOOL_DESCRIPTION = "Do not use"
def _format_invalid_message(tool: str | None, error: str | None) -> str:
"""Return the user-visible error string. Mirrors ``invalid.ts``."""
name = tool or "<unknown>"
detail = error or "(no error message provided)"
return (
f"The arguments provided to the tool `{name}` are invalid: {detail}\n"
f"Read the tool's docstring carefully and try again with valid arguments."
)
@tool(name_or_callable=INVALID_TOOL_NAME, description=INVALID_TOOL_DESCRIPTION)
def invalid_tool(tool: str | None = None, error: str | None = None) -> str:
"""Return a human-readable explanation of a tool-call validation failure.
Activated only when :class:`ToolCallNameRepairMiddleware` rewrites a
failed tool call to ``invalid`` with the original tool name and the
error message produced during validation.
"""
return _format_invalid_message(tool, error)
__all__ = [
"INVALID_TOOL_DESCRIPTION",
"INVALID_TOOL_NAME",
"invalid_tool",
]

View file

@ -1,10 +1,10 @@
from app.agents.new_chat.tools.luma.create_event import ( from app.agents.shared.tools.luma.create_event import (
create_create_luma_event_tool, create_create_luma_event_tool,
) )
from app.agents.new_chat.tools.luma.list_events import ( from app.agents.shared.tools.luma.list_events import (
create_list_luma_events_tool, create_list_luma_events_tool,
) )
from app.agents.new_chat.tools.luma.read_event import ( from app.agents.shared.tools.luma.read_event import (
create_read_luma_event_tool, create_read_luma_event_tool,
) )

View file

@ -5,7 +5,7 @@ import httpx
from langchain_core.tools import tool from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval from app.agents.shared.tools.hitl import request_approval
from app.db import async_session_maker from app.db import async_session_maker
from ._auth import LUMA_API, get_api_key, get_luma_connector, luma_headers from ._auth import LUMA_API, get_api_key, get_luma_connector, luma_headers

View file

@ -34,9 +34,9 @@ from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.shared.middleware.dedup_tool_calls import dedup_key_full_args from app.agents.shared.middleware.dedup_tool_calls import dedup_key_full_args
from app.agents.new_chat.tools.hitl import request_approval from app.agents.shared.tools.hitl import request_approval
from app.agents.new_chat.tools.mcp_client import MCPClient from app.agents.shared.tools.mcp_client import MCPClient
from app.agents.new_chat.tools.mcp_tools_cache import ( from app.agents.shared.tools.mcp_tools_cache import (
CachedMCPTools, CachedMCPTools,
read_cached_tools, read_cached_tools,
write_cached_tools, write_cached_tools,

View file

@ -112,7 +112,7 @@ def refresh_mcp_tools_cache_for_connector(
when an event loop is available. Neither path raises. when an event loop is available. Neither path raises.
""" """
try: try:
from app.agents.new_chat.tools.mcp_tool import invalidate_mcp_tools_cache from app.agents.shared.tools.mcp_tool import invalidate_mcp_tools_cache
invalidate_mcp_tools_cache(search_space_id) invalidate_mcp_tools_cache(search_space_id)
except Exception: except Exception:
@ -133,7 +133,7 @@ def refresh_mcp_tools_cache_for_connector(
async def _run_connector_prefetch(connector_id: int) -> None: async def _run_connector_prefetch(connector_id: int) -> None:
from app.agents.new_chat.tools.mcp_tool import discover_single_mcp_connector from app.agents.shared.tools.mcp_tool import discover_single_mcp_connector
try: try:
await discover_single_mcp_connector(connector_id) await discover_single_mcp_connector(connector_id)

View file

@ -4,7 +4,7 @@ from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval from app.agents.shared.tools.hitl import request_approval
from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector
from app.db import async_session_maker from app.db import async_session_maker
from app.services.notion import NotionToolMetadataService from app.services.notion import NotionToolMetadataService

View file

@ -4,7 +4,7 @@ from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval from app.agents.shared.tools.hitl import request_approval
from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector
from app.db import async_session_maker from app.db import async_session_maker
from app.services.notion.tool_metadata_service import NotionToolMetadataService from app.services.notion.tool_metadata_service import NotionToolMetadataService

View file

@ -4,7 +4,7 @@ from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval from app.agents.shared.tools.hitl import request_approval
from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector
from app.db import async_session_maker from app.db import async_session_maker
from app.services.notion import NotionToolMetadataService from app.services.notion import NotionToolMetadataService

View file

@ -1,7 +1,7 @@
from app.agents.new_chat.tools.onedrive.create_file import ( from app.agents.shared.tools.onedrive.create_file import (
create_create_onedrive_file_tool, create_create_onedrive_file_tool,
) )
from app.agents.new_chat.tools.onedrive.trash_file import ( from app.agents.shared.tools.onedrive.trash_file import (
create_delete_onedrive_file_tool, create_delete_onedrive_file_tool,
) )

View file

@ -8,7 +8,7 @@ from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from app.agents.new_chat.tools.hitl import request_approval from app.agents.shared.tools.hitl import request_approval
from app.connectors.onedrive.client import OneDriveClient from app.connectors.onedrive.client import OneDriveClient
from app.db import SearchSourceConnector, SearchSourceConnectorType, async_session_maker from app.db import SearchSourceConnector, SearchSourceConnectorType, async_session_maker

View file

@ -6,7 +6,7 @@ from sqlalchemy import String, and_, cast, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from app.agents.new_chat.tools.hitl import request_approval from app.agents.shared.tools.hitl import request_approval
from app.connectors.onedrive.client import OneDriveClient from app.connectors.onedrive.client import OneDriveClient
from app.db import ( from app.db import (
Document, Document,

View file

@ -0,0 +1,962 @@
"""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 .mcp_tool import load_mcp_tools
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 .scrape_webpage import create_scrape_webpage_tool
from .teams import (
create_list_teams_channels_tool,
create_read_teams_messages_tool,
create_send_teams_message_tool,
)
from .update_memory import create_update_memory_tool, create_update_team_memory_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"],
)
# =============================================================================
# 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=lambda deps: create_scrape_webpage_tool(
firecrawl_api_key=deps.get("firecrawl_api_key"),
),
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=lambda deps: (
create_update_team_memory_tool(
search_space_id=deps["search_space_id"],
db_session=deps["db_session"],
llm=deps.get("llm"),
)
if deps["thread_visibility"] == ChatVisibility.SEARCH_SPACE
else create_update_memory_tool(
user_id=deps["user_id"],
db_session=deps["db_session"],
llm=deps.get("llm"),
)
),
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 (see chat_deepagent.py)
# =========================================================================
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 (see chat_deepagent.py)
# =========================================================================
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 (see chat_deepagent.py)
# =========================================================================
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 (see chat_deepagent.py)
# =========================================================================
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 (see chat_deepagent.py)
# =========================================================================
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_tool_by_name(name: str) -> ToolDefinition | None:
"""Get a tool definition by its name."""
for tool_def in BUILTIN_TOOLS:
if tool_def.name == name:
return tool_def
return None
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
def get_all_tool_names() -> list[str]:
"""Get names of all registered tools."""
return [tool_def.name for tool_def in BUILTIN_TOOLS]
def get_default_enabled_tools() -> list[str]:
"""Get names of tools that are enabled by default (excludes hidden tools)."""
return [tool_def.name for tool_def in BUILTIN_TOOLS if tool_def.enabled_by_default]
def build_tools(
dependencies: dict[str, Any],
enabled_tools: list[str] | None = None,
disabled_tools: list[str] | None = None,
additional_tools: list[BaseTool] | None = None,
) -> list[BaseTool]:
"""Build the list of tools for the agent.
Args:
dependencies: Dict containing all possible dependencies:
- search_space_id: The search space ID
- db_session: Database session
- connector_service: Connector service instance
- firecrawl_api_key: Optional Firecrawl API key
enabled_tools: Explicit list of tool names to enable. If None, uses defaults.
disabled_tools: List of tool names to disable (applied after enabled_tools).
additional_tools: Extra tools to add (e.g., custom tools not in registry).
Returns:
List of configured tool instances ready for the agent.
Example:
# Use all default tools
tools = build_tools(deps)
# Use only specific tools
tools = build_tools(deps, enabled_tools=["generate_report"])
# Use defaults but disable podcast
tools = build_tools(deps, disabled_tools=["generate_podcast"])
# Add custom tools
tools = build_tools(deps, additional_tools=[my_custom_tool])
"""
# Determine which tools to enable
if enabled_tools is not None:
tool_names_to_use = set(enabled_tools)
else:
tool_names_to_use = set(get_default_enabled_tools())
# Apply disabled list
if disabled_tools:
tool_names_to_use -= set(disabled_tools)
# Build the tools (skip hidden/WIP tools unconditionally)
tools: list[BaseTool] = []
for tool_def in BUILTIN_TOOLS:
if tool_def.hidden or tool_def.name not in tool_names_to_use:
continue
# Check that all required dependencies are provided
missing_deps = [dep for dep in tool_def.requires if dep not in dependencies]
if missing_deps:
msg = f"Tool '{tool_def.name}' requires dependencies: {missing_deps}"
raise ValueError(
msg,
)
# Create the tool
tool = tool_def.factory(dependencies)
# Propagate the registry-level metadata so middleware (e.g.
# ``DedupHITLToolCallsMiddleware``) and the action-log/revert
# pipeline can pick the resolvers up via ``tool.metadata`` without
# re-importing :data:`BUILTIN_TOOLS`.
if tool_def.dedup_key is not None or tool_def.reverse is not None:
existing_meta = getattr(tool, "metadata", None) or {}
merged_meta = dict(existing_meta)
if tool_def.dedup_key is not None:
merged_meta.setdefault("dedup_key", tool_def.dedup_key)
if tool_def.reverse is not None:
merged_meta.setdefault("reverse", tool_def.reverse)
try:
tool.metadata = merged_meta
except Exception:
logger.debug(
"Tool %s rejected metadata mutation; relying on registry lookup",
tool_def.name,
)
tools.append(tool)
# Add any additional custom tools
if additional_tools:
tools.extend(additional_tools)
return tools
async def build_tools_async(
dependencies: dict[str, Any],
enabled_tools: list[str] | None = None,
disabled_tools: list[str] | None = None,
additional_tools: list[BaseTool] | None = None,
include_mcp_tools: bool = True,
) -> list[BaseTool]:
"""Async version of build_tools that also loads MCP tools from database.
Design Note:
This function exists because MCP tools require database queries to load
user configs, while built-in tools are created synchronously from static
code.
Alternative: We could make build_tools() itself async and always query
the database, but that would force async everywhere even when only using
built-in tools. The current design keeps the simple case (static tools
only) synchronous while supporting dynamic database-loaded tools through
this async wrapper.
Phase 1.3: built-in tool construction (CPU; runs in a thread pool to
avoid event-loop stalls) and MCP tool loading (HTTP/DB I/O; runs on
the event loop) are kicked off concurrently. Cold-path savings are
bounded by the slower of the two typically MCP at ~200ms-1.7s
so the parallelization recovers the ~50-200ms previously spent
serially on built-in construction.
Args:
dependencies: Dict containing all possible dependencies
enabled_tools: Explicit list of tool names to enable. If None, uses defaults.
disabled_tools: List of tool names to disable (applied after enabled_tools).
additional_tools: Extra tools to add (e.g., custom tools not in registry).
include_mcp_tools: Whether to load user's MCP tools from database.
Returns:
List of configured tool instances ready for the agent, including MCP tools.
"""
import asyncio
import time
_perf_log = logging.getLogger("surfsense.perf")
_perf_log.setLevel(logging.DEBUG)
can_load_mcp = (
include_mcp_tools
and "db_session" in dependencies
and "search_space_id" in dependencies
)
# Built-in tool construction is synchronous + CPU-only. Off-loop it so
# MCP's HTTP/DB I/O can fire concurrently. ``build_tools`` is pure
# function over its inputs — safe to thread-shift.
_t0 = time.perf_counter()
builtin_task = asyncio.create_task(
asyncio.to_thread(
build_tools, dependencies, enabled_tools, disabled_tools, additional_tools
)
)
mcp_task: asyncio.Task | None = None
if can_load_mcp:
mcp_task = asyncio.create_task(
load_mcp_tools(
dependencies["db_session"],
dependencies["search_space_id"],
)
)
# Surface failures from each task independently so a flaky MCP
# endpoint never poisons built-in tool registration. ``return_exceptions``
# gives us per-task exceptions instead of dropping the second result
# when the first raises.
if mcp_task is not None:
builtin_result, mcp_result = await asyncio.gather(
builtin_task, mcp_task, return_exceptions=True
)
else:
builtin_result = await builtin_task
mcp_result = None
if isinstance(builtin_result, BaseException):
raise builtin_result # built-in registration failure is non-recoverable
tools: list[BaseTool] = builtin_result
_perf_log.info(
"[build_tools_async] Built-in tools in %.3fs (%d tools, parallel)",
time.perf_counter() - _t0,
len(tools),
)
if mcp_task is not None:
if isinstance(mcp_result, BaseException):
# ``return_exceptions=True`` captures the exception out-of-band,
# so ``sys.exc_info()`` is empty here. Pass the captured
# exception via ``exc_info=`` to get a real traceback.
logging.error(
"Failed to load MCP tools: %s", mcp_result, exc_info=mcp_result
)
else:
mcp_tools = mcp_result or []
_perf_log.info(
"[build_tools_async] MCP tools loaded in %.3fs (%d tools, parallel)",
time.perf_counter() - _t0,
len(mcp_tools),
)
tools.extend(mcp_tools)
logging.info(
"Registered %d MCP tools: %s",
len(mcp_tools),
[t.name for t in mcp_tools],
)
logging.info(
"Total tools for agent: %d%s",
len(tools),
[t.name for t in tools],
)
return tools

View file

@ -1,10 +1,10 @@
from app.agents.new_chat.tools.teams.list_channels import ( from app.agents.shared.tools.teams.list_channels import (
create_list_teams_channels_tool, create_list_teams_channels_tool,
) )
from app.agents.new_chat.tools.teams.read_messages import ( from app.agents.shared.tools.teams.read_messages import (
create_read_teams_messages_tool, create_read_teams_messages_tool,
) )
from app.agents.new_chat.tools.teams.send_message import ( from app.agents.shared.tools.teams.send_message import (
create_send_teams_message_tool, create_send_teams_message_tool,
) )

View file

@ -5,7 +5,7 @@ import httpx
from langchain_core.tools import tool from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval from app.agents.shared.tools.hitl import request_approval
from app.db import async_session_maker from app.db import async_session_maker
from ._auth import GRAPH_API, get_access_token, get_teams_connector from ._auth import GRAPH_API, get_access_token, get_teams_connector

View file

@ -665,7 +665,7 @@ def _refresh_mcp_cache(connector_id: int, space_id: int) -> None:
isolated from the OAuth response flow. isolated from the OAuth response flow.
""" """
try: try:
from app.agents.new_chat.tools.mcp_tools_cache import ( from app.agents.shared.tools.mcp_tools_cache import (
refresh_mcp_tools_cache_for_connector, refresh_mcp_tools_cache_for_connector,
) )

View file

@ -1668,7 +1668,7 @@ async def list_agent_tools(
Hidden (WIP) tools are excluded from the response. Hidden (WIP) tools are excluded from the response.
""" """
from app.agents.new_chat.tools.registry import BUILTIN_TOOLS from app.agents.shared.tools.registry import BUILTIN_TOOLS
return [ return [
AgentToolInfo( AgentToolInfo(

View file

@ -675,7 +675,7 @@ async def delete_search_source_connector(
await session.commit() await session.commit()
if is_mcp: if is_mcp:
from app.agents.new_chat.tools.mcp_tool import invalidate_mcp_tools_cache from app.agents.shared.tools.mcp_tool import invalidate_mcp_tools_cache
invalidate_mcp_tools_cache(search_space_id) invalidate_mcp_tools_cache(search_space_id)
@ -2687,7 +2687,7 @@ async def create_mcp_connector(
f"for user {user.id} in search space {search_space_id}" f"for user {user.id} in search space {search_space_id}"
) )
from app.agents.new_chat.tools.mcp_tools_cache import ( from app.agents.shared.tools.mcp_tools_cache import (
refresh_mcp_tools_cache_for_connector, refresh_mcp_tools_cache_for_connector,
) )
@ -2867,7 +2867,7 @@ async def update_mcp_connector(
logger.info(f"Updated MCP connector {connector_id}") logger.info(f"Updated MCP connector {connector_id}")
from app.agents.new_chat.tools.mcp_tools_cache import ( from app.agents.shared.tools.mcp_tools_cache import (
refresh_mcp_tools_cache_for_connector, refresh_mcp_tools_cache_for_connector,
) )
@ -2927,7 +2927,7 @@ async def delete_mcp_connector(
await session.delete(connector) await session.delete(connector)
await session.commit() await session.commit()
from app.agents.new_chat.tools.mcp_tool import invalidate_mcp_tools_cache from app.agents.shared.tools.mcp_tool import invalidate_mcp_tools_cache
invalidate_mcp_tools_cache(search_space_id) invalidate_mcp_tools_cache(search_space_id)
@ -2966,7 +2966,7 @@ async def test_mcp_server_connection(
Connection status and list of available tools Connection status and list of available tools
""" """
try: try:
from app.agents.new_chat.tools.mcp_client import ( from app.agents.shared.tools.mcp_client import (
test_mcp_connection, test_mcp_connection,
test_mcp_http_connection, test_mcp_http_connection,
) )
@ -3157,7 +3157,7 @@ async def trust_mcp_tool(
connectors (``LINEAR_CONNECTOR``, ``JIRA_CONNECTOR``, ...) the connectors (``LINEAR_CONNECTOR``, ``JIRA_CONNECTOR``, ...) the
storage primitive is the same JSON list under ``config.trusted_tools``. storage primitive is the same JSON list under ``config.trusted_tools``.
""" """
from app.agents.new_chat.tools.mcp_tool import invalidate_mcp_tools_cache from app.agents.shared.tools.mcp_tool import invalidate_mcp_tools_cache
from app.services.user_tool_allowlist import add_user_trust from app.services.user_tool_allowlist import add_user_trust
try: try:
@ -3197,7 +3197,7 @@ async def untrust_mcp_tool(
The tool will require HITL approval again on subsequent calls. The tool will require HITL approval again on subsequent calls.
""" """
from app.agents.new_chat.tools.mcp_tool import invalidate_mcp_tools_cache from app.agents.shared.tools.mcp_tool import invalidate_mcp_tools_cache
from app.services.user_tool_allowlist import remove_user_trust from app.services.user_tool_allowlist import remove_user_trust
try: try:

View file

@ -56,7 +56,7 @@ logger = logging.getLogger(__name__)
# class-body init time. ``app.agents.shared.llm_config`` re-exports # class-body init time. ``app.agents.shared.llm_config`` re-exports
# this constant under the historical ``PROVIDER_MAP`` name; placing the # this constant under the historical ``PROVIDER_MAP`` name; placing the
# map there directly would re-introduce the # map there directly would re-introduce the
# ``app.config -> ... -> app.agents.new_chat.tools.generate_image -> # ``app.config -> ... -> app.agents.shared.tools.generate_image ->
# app.config`` cycle that prompted the move. # app.config`` cycle that prompted the move.
_PROVIDER_PREFIX_MAP: dict[str, str] = { _PROVIDER_PREFIX_MAP: dict[str, str] = {
"OPENAI": "openai", "OPENAI": "openai",

View file

@ -137,10 +137,10 @@ def install(active_patches: list[Any]) -> None:
"""Patch production MCP streamable-HTTP boundaries exactly once.""" """Patch production MCP streamable-HTTP boundaries exactly once."""
targets = [ targets = [
( (
"app.agents.new_chat.tools.mcp_tool.streamablehttp_client", "app.agents.shared.tools.mcp_tool.streamablehttp_client",
_fake_streamablehttp_client, _fake_streamablehttp_client,
), ),
("app.agents.new_chat.tools.mcp_tool.ClientSession", _FakeClientSession), ("app.agents.shared.tools.mcp_tool.ClientSession", _FakeClientSession),
] ]
for target, replacement in targets: for target, replacement in targets:
p = patch(target, replacement) p = patch(target, replacement)

View file

@ -429,9 +429,9 @@ def install(active_patches: list[Any]) -> None:
("app.connectors.google_drive.client.build", _fake_build), ("app.connectors.google_drive.client.build", _fake_build),
("app.connectors.google_gmail_connector.build", _fake_build), ("app.connectors.google_gmail_connector.build", _fake_build),
("app.connectors.google_calendar_connector.build", _fake_build), ("app.connectors.google_calendar_connector.build", _fake_build),
("app.agents.new_chat.tools.google_calendar.create_event.build", _fake_build), ("app.agents.shared.tools.google_calendar.create_event.build", _fake_build),
("app.agents.new_chat.tools.google_calendar.update_event.build", _fake_build), ("app.agents.shared.tools.google_calendar.update_event.build", _fake_build),
("app.agents.new_chat.tools.google_calendar.delete_event.build", _fake_build), ("app.agents.shared.tools.google_calendar.delete_event.build", _fake_build),
("googleapiclient.http.MediaIoBaseDownload", _FakeMediaIoBaseDownload), ("googleapiclient.http.MediaIoBaseDownload", _FakeMediaIoBaseDownload),
( (
"app.connectors.google_drive.client._build_thread_http", "app.connectors.google_drive.client._build_thread_http",

View file

@ -239,7 +239,7 @@ def patched_shielded_session(async_engine, monkeypatch):
yield session yield session
monkeypatch.setattr( monkeypatch.setattr(
"app.agents.new_chat.tools.knowledge_base.shielded_async_session", "app.agents.shared.tools.knowledge_base.shielded_async_session",
_test_shielded, _test_shielded,
) )

View file

@ -17,7 +17,7 @@ async def test_browse_recent_documents_with_list_type_returns_both(
committed_google_data, patched_shielded_session committed_google_data, patched_shielded_session
): ):
"""_browse_recent_documents returns docs of all types when given a list.""" """_browse_recent_documents returns docs of all types when given a list."""
from app.agents.new_chat.tools.knowledge_base import _browse_recent_documents from app.agents.shared.tools.knowledge_base import _browse_recent_documents
space_id = committed_google_data["search_space_id"] space_id = committed_google_data["search_space_id"]

View file

@ -12,7 +12,7 @@ from langchain_core.tools import tool
from app.agents.shared.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.shared.middleware.action_log import ActionLogMiddleware from app.agents.shared.middleware.action_log import ActionLogMiddleware
from app.agents.new_chat.tools.registry import ToolDefinition from app.agents.shared.tools.registry import ToolDefinition
@dataclass @dataclass

View file

@ -93,7 +93,7 @@ def test_no_agent_tools_means_no_dedup() -> None:
Coverage for the previously hardcoded native HITL tools now lives on Coverage for the previously hardcoded native HITL tools now lives on
each :class:`ToolDefinition.dedup_key` in each :class:`ToolDefinition.dedup_key` in
:mod:`app.agents.new_chat.tools.registry`, which is wired through to :mod:`app.agents.shared.tools.registry`, which is wired through to
``tool.metadata`` by :func:`build_tools`. ``tool.metadata`` by :func:`build_tools`.
""" """
mw = DedupHITLToolCallsMiddleware(agent_tools=None) mw = DedupHITLToolCallsMiddleware(agent_tools=None)
@ -116,7 +116,7 @@ def test_registry_propagates_dedup_key_to_tool_metadata() -> None:
the constructed tool's ``metadata`` so :class:`DedupHITLToolCallsMiddleware` the constructed tool's ``metadata`` so :class:`DedupHITLToolCallsMiddleware`
can pick it up at agent build time. can pick it up at agent build time.
""" """
from app.agents.new_chat.tools.registry import ( from app.agents.shared.tools.registry import (
BUILTIN_TOOLS, BUILTIN_TOOLS,
wrap_dedup_key_by_arg_name, wrap_dedup_key_by_arg_name,
) )

View file

@ -17,7 +17,7 @@ caused two production-painful behaviors:
read-only tool calls, raising ``RejectedError("ls")``. read-only tool calls, raising ``RejectedError("ls")``.
* Mutating connector tools got *double* prompted once via the * Mutating connector tools got *double* prompted once via the
middleware ``ask`` and again via the per-tool ``interrupt()`` in middleware ``ask`` and again via the per-tool ``interrupt()`` in
``app.agents.new_chat.tools.hitl``. ``app.agents.shared.tools.hitl``.
These tests pin the layering so a refactor that drops the default These tests pin the layering so a refactor that drops the default
ruleset fails loud. ruleset fails loud.

View file

@ -10,7 +10,7 @@ from __future__ import annotations
import pytest import pytest
from app.agents.new_chat.tools.hitl import ( from app.agents.shared.tools.hitl import (
DEFAULT_AUTO_APPROVED_TOOLS, DEFAULT_AUTO_APPROVED_TOOLS,
HITLResult, HITLResult,
request_approval, request_approval,

View file

@ -8,7 +8,7 @@ from langchain_core.messages import AIMessage
from app.agents.shared.middleware.tool_call_repair import ( from app.agents.shared.middleware.tool_call_repair import (
ToolCallNameRepairMiddleware, ToolCallNameRepairMiddleware,
) )
from app.agents.new_chat.tools.invalid_tool import INVALID_TOOL_NAME from app.agents.shared.tools.invalid_tool import INVALID_TOOL_NAME
pytestmark = pytest.mark.unit pytestmark = pytest.mark.unit

View file

@ -7,7 +7,7 @@ from types import SimpleNamespace
import pytest import pytest
from app.agents.new_chat.tools.mcp_tools_cache import ( from app.agents.shared.tools.mcp_tools_cache import (
CachedMCPToolDef, CachedMCPToolDef,
CachedMCPTools, CachedMCPTools,
read_cached_tools, read_cached_tools,

View file

@ -7,7 +7,7 @@ from unittest.mock import AsyncMock
import pypdf import pypdf
import pytest import pytest
from app.agents.new_chat.tools import resume as resume_tool from app.agents.shared.tools import resume as resume_tool
pytestmark = pytest.mark.unit pytestmark = pytest.mark.unit

View file

@ -90,7 +90,7 @@ async def test_global_openrouter_image_gen_sets_api_base_when_config_empty():
async def test_generate_image_tool_global_sets_api_base_when_config_empty(): async def test_generate_image_tool_global_sets_api_base_when_config_empty():
"""Same defense at the agent tool entry point — both surfaces share """Same defense at the agent tool entry point — both surfaces share
the same OpenRouter config payloads.""" the same OpenRouter config payloads."""
from app.agents.new_chat.tools import generate_image as gi_module from app.agents.shared.tools import generate_image as gi_module
cfg = { cfg = {
"id": -20_001, "id": -20_001,