refactor(agents): split tool registry into pure-data catalog, decouple connectors

Replace the connector-coupled BUILTIN_TOOLS registry with a pure-data
catalog so shared/tools no longer imports any connector module, making the
connector packages independently deletable.

- add shared/tools/catalog.py (ToolMetadata + TOOL_CATALOG, 41 tools, no imports)
- point GET /agent/tools (the only live consumer) at the catalog
- relocate ToolDefinition into action_log middleware (its sole consumer);
  drop the inert tool_definitions wiring (no tool defines reverse)
- delete shared/tools/registry.py: connector imports, dead factories,
  dead get_connector_gated_tools, and BUILTIN_TOOLS
- drop stale dedup-propagation test (path removed in C1) + refresh docstrings

import-all guardrail + agents unit suite green (987 passed).
This commit is contained in:
CREDO23 2026-06-04 19:43:50 +02:00
parent c3238d8840
commit 003924062d
13 changed files with 154 additions and 826 deletions

View file

@ -6,7 +6,6 @@ import logging
from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.shared.middleware import ActionLogMiddleware
from app.agents.shared.tools.registry import BUILTIN_TOOLS
from app.agents.multi_agent_chat.shared.middleware.flags import enabled
@ -21,12 +20,13 @@ def build_action_log_mw(
if not enabled(flags, "enable_action_log") or thread_id is None:
return None
try:
tool_defs_by_name = {td.name: td for td in BUILTIN_TOOLS}
# No built-in tool declares a ``reverse`` callable yet, so the action
# log runs without a tool_definitions map. Reversibility is opt-in per
# tool via ``ToolDefinition.reverse`` and can be wired here when used.
return ActionLogMiddleware(
thread_id=thread_id,
search_space_id=search_space_id,
user_id=user_id,
tool_definitions=tool_defs_by_name,
)
except Exception: # pragma: no cover - defensive
logging.warning(

View file

@ -5,15 +5,13 @@ connector integrations, MCP, and deliverables are delegated to ``task``
subagents (see :mod:`app.agents.multi_agent_chat.main_agent.tools.index`).
This module is the *building* counterpart to that name list: it owns the
factories for those few tools and nothing else. It is deliberately decoupled
from :mod:`app.agents.shared.tools.registry` (the app-wide ``BUILTIN_TOOLS``
metadata catalog, which imports every connector) so the main agent's tool
factories for those few tools and nothing else, so the main agent's tool
surface stays self-contained and connector-free.
The ``BUILTIN_TOOLS`` catalog still exists and is still used elsewhere for
tool *metadata* the ``/agent/tools`` listing endpoint and the action-log
revert/dedup resolvers (which must cover subagent-executed connector tools).
This registry only governs what the main agent actually builds and binds.
Tool *display* metadata for the whole app (the ``/agent/tools`` listing
endpoint) lives separately in :mod:`app.agents.shared.tools.catalog`, a
pure-data module that imports no connectors. This registry only governs what
the main agent actually builds and binds.
"""
from __future__ import annotations
@ -71,8 +69,7 @@ def _build_update_memory_tool(deps: dict[str, Any]) -> BaseTool:
)
# Ordered to match the historical binding order produced by the shared
# ``build_tools`` (which iterated ``BUILTIN_TOOLS`` in declaration order):
# Ordered to match the historical main-agent binding order:
# scrape_webpage, web_search, create_automation, update_memory.
# Each entry is ``(factory, required_dependency_names)``.
_MAIN_AGENT_TOOL_FACTORIES: dict[

View file

@ -113,12 +113,11 @@ def tools_signature(
MCP tools loaded for the user changes, gating rules flip, etc.).
* The available connectors / document types for the search space
change (new connector added, last connector removed, new document
type indexed). Because :func:`get_connector_gated_tools` derives
``modified_disabled_tools`` from ``available_connectors``, the
tool surface is technically already covered but we hash the
connector list separately so an empty-list "no tools changed"
situation still rotates the key when, say, the user re-adds a
connector that gates a tool we were already not exposing.
type indexed). Connector gating derives disabled tools from
``available_connectors``, so the tool surface is technically already
covered but we hash the connector list separately so an empty-list
"no tools changed" situation still rotates the key when, say, the user
re-adds a connector that gates a tool we were already not exposing.
Stays stable across:

View file

@ -1,6 +1,9 @@
"""Middleware components for the SurfSense new chat agent."""
from app.agents.shared.middleware.action_log import ActionLogMiddleware
from app.agents.shared.middleware.action_log import (
ActionLogMiddleware,
ToolDefinition,
)
from app.agents.shared.middleware.anonymous_document import (
AnonymousDocumentMiddleware,
)
@ -76,6 +79,7 @@ __all__ = [
"SpillingContextEditingMiddleware",
"SurfSenseCompactionMiddleware",
"ToolCallNameRepairMiddleware",
"ToolDefinition",
"build_skills_backend_factory",
"commit_staged_filesystem_state",
"create_surfsense_compaction_middleware",

View file

@ -3,8 +3,8 @@
Wraps every tool call via :meth:`AgentMiddleware.awrap_tool_call` and writes
a row to :class:`~app.db.AgentActionLog` after the tool returns. Tools opt
into reversibility by declaring a ``reverse`` callable on their
:class:`~app.agents.shared.tools.registry.ToolDefinition`; the rendered
descriptor is persisted in ``reverse_descriptor`` for use by
:class:`ToolDefinition`; the rendered descriptor is persisted in
``reverse_descriptor`` for use by
``/api/threads/{thread_id}/revert/{action_id}``.
Design points:
@ -27,6 +27,7 @@ from __future__ import annotations
import json
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from langchain.agents.middleware import AgentMiddleware
@ -39,15 +40,35 @@ if TYPE_CHECKING: # pragma: no cover - type-only
from langchain.agents.middleware.types import ToolCallRequest
from langgraph.types import Command
# Type-only import: ToolDefinition is only referenced in annotations, and a
# runtime import would close a module-load cycle (tools.registry imports
# shared.middleware.dedup_tool_calls).
from app.agents.shared.tools.registry import ToolDefinition
logger = logging.getLogger(__name__)
@dataclass
class ToolDefinition:
"""Reversibility descriptor consumed by :class:`ActionLogMiddleware`.
Only ``name`` and ``reverse`` are read by the middleware; the remaining
fields let callers and tests describe a tool declaratively. A tool is
marked reversible in the action log when ``reverse`` is set and renders a
descriptor without raising.
Attributes:
name: Unique identifier for the tool.
description: Human-readable description of what the tool does.
factory: Optional callable that builds the tool (unused by the
middleware; retained for declarative call sites/tests).
reverse: Optional callable that, given the tool's ``(args, result)``,
returns a ``ReverseDescriptor`` describing the inverse invocation.
"""
name: str
description: str = ""
factory: Callable[[dict[str, Any]], Any] | None = None
reverse: Callable[[dict[str, Any], Any], dict[str, Any]] | None = None
# Cap for the persisted ``args`` JSON to avoid bloating the action log with
# accidentally-huge inputs. Values are truncated and a flag is set in the
# stored payload so consumers can detect truncation.

View file

@ -9,12 +9,12 @@ the duplicate call is stripped from the AIMessage that gets checkpointed.
That means it is also safe across LangGraph ``interrupt()`` boundaries:
the removed call will never appear on graph resume.
Dedup-key resolution order:
Dedup-key resolution order (read from each tool's own ``metadata``):
1. :class:`ToolDefinition.dedup_key` callable provided by the registry
entry. This is the canonical mechanism.
2. ``tool.metadata["hitl_dedup_key"]`` string with a primary arg name;
used by MCP / Composio tools whose schemas the registry doesn't see.
1. ``tool.metadata["dedup_key"]`` callable mapping the args dict to a
stable signature string. This is the canonical mechanism.
2. ``tool.metadata["hitl_dedup_key"]`` string naming a primary arg;
used by MCP / Composio tools that only expose a single key field.
A tool with no resolver from either path simply opts out of dedup.
"""
@ -39,17 +39,10 @@ DedupResolver = Callable[[dict[str, Any]], str]
def wrap_dedup_key_by_arg_name(arg_name: str) -> DedupResolver:
"""Adapt a string-arg name into a :data:`DedupResolver`.
Convenience helper used by registry entries that just want to dedupe
on a single arg's lowercased value (the most common case for native
HITL tools like ``send_gmail_email`` keyed on ``subject``).
Example::
ToolDefinition(
name="send_gmail_email",
...,
dedup_key=wrap_dedup_key_by_arg_name("subject"),
)
Convenience helper for tools that just want to dedupe on a single arg's
lowercased value (the most common case for HITL tools like
``send_gmail_email`` keyed on ``subject``). Set the result on the tool's
``metadata["dedup_key"]``.
"""
def _resolver(args: dict[str, Any]) -> str:
@ -84,9 +77,8 @@ class DedupHITLToolCallsMiddleware(AgentMiddleware): # type: ignore[type-arg]
The dedup-resolver map is built from two sources, in priority order:
1. ``tool.metadata["dedup_key"]`` callable provided by the registry's
``ToolDefinition.dedup_key``. Receives the args dict and returns
a string signature. This is the canonical mechanism.
1. ``tool.metadata["dedup_key"]`` callable that receives the args dict
and returns a string signature. This is the canonical mechanism.
2. ``tool.metadata["hitl_dedup_key"]`` string with a primary arg
name; primarily used by MCP / Composio tools.
"""

View file

@ -118,9 +118,8 @@ class ToolCallNameRepairMiddleware(
return call
# Stage 2 — invalid fallback
# Local import avoids a module-load cycle: tools.registry imports
# shared.middleware (dedup_tool_calls), so importing tools at module
# scope here would close the loop.
# Local import keeps the middleware module import-light and avoids any
# tools <-> middleware import-order coupling at module scope.
from app.agents.shared.tools.invalid_tool import INVALID_TOOL_NAME
if INVALID_TOOL_NAME in registered:

View file

@ -18,19 +18,16 @@ from .knowledge_base import (
format_documents_for_context,
search_knowledge_base_async,
)
from .catalog import TOOL_CATALOG, ToolMetadata
from .podcast import create_generate_podcast_tool
from .registry import (
BUILTIN_TOOLS,
ToolDefinition,
)
from .video_presentation import create_generate_video_presentation_tool
__all__ = [
# Registry
"BUILTIN_TOOLS",
# Tool catalog (display metadata)
"TOOL_CATALOG",
# Knowledge base utilities
"CONNECTOR_DESCRIPTIONS",
"ToolDefinition",
"ToolMetadata",
# Tool factories
"create_generate_image_tool",
"create_generate_podcast_tool",

View file

@ -0,0 +1,84 @@
"""Pure-data catalog of built-in agent tools.
This module advertises *what* tools exist and their display metadata. It is
intentionally free of any tool implementation imports (no connectors, no
factories) so it can be consumed without pulling the whole tool dependency
graph and so connector packages stay independently deletable.
The single live consumer is the ``GET /agent/tools`` endpoint, which renders
the tool picker in the web UI. Tool *construction* lives elsewhere:
* main-agent tools -> ``app.agents.multi_agent_chat.main_agent.tools.registry``
* subagent / connector tools -> ``app.agents.multi_agent_chat.subagents.*``
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class ToolMetadata:
"""Display metadata for a single built-in tool.
Attributes:
name: Unique identifier for the tool.
description: Human-readable description of what the tool does.
enabled_by_default: Whether the tool is on when no explicit config
is provided.
hidden: WIP tools that should be excluded from public listings.
"""
name: str
description: str
enabled_by_default: bool = True
hidden: bool = False
# Catalog of all built-in tools. Contributors: add new tools here so they show
# up in the UI tool picker. This list carries metadata only — wire the actual
# implementation in the relevant builder/registry module.
TOOL_CATALOG: list[ToolMetadata] = [
ToolMetadata(name="generate_podcast", description="Generate an audio podcast from provided content"),
ToolMetadata(name="generate_video_presentation", description="Generate a video presentation with slides and narration from provided content"),
ToolMetadata(name="generate_report", description="Generate a structured report from provided content and export it"),
ToolMetadata(name="generate_resume", description="Generate a professional resume as a Typst document"),
ToolMetadata(name="generate_image", description="Generate images from text descriptions using AI image models"),
ToolMetadata(name="scrape_webpage", description="Scrape and extract the main content from a webpage"),
ToolMetadata(name="web_search", description="Search the web for real-time information using configured search engines"),
ToolMetadata(name="get_connected_accounts", description="Discover connected accounts for a service and their metadata"),
ToolMetadata(name="create_automation", description="Draft an automation from an NL intent; user approves the card; tool saves"),
ToolMetadata(name="update_memory", description="Save important long-term facts, preferences, and instructions to the (personal or team) memory"),
ToolMetadata(name="create_notion_page", description="Create a new page in the user's Notion workspace"),
ToolMetadata(name="update_notion_page", description="Append new content to an existing Notion page"),
ToolMetadata(name="delete_notion_page", description="Delete an existing Notion page"),
ToolMetadata(name="create_google_drive_file", description="Create a new Google Doc or Google Sheet in Google Drive"),
ToolMetadata(name="delete_google_drive_file", description="Move an indexed Google Drive file to trash"),
ToolMetadata(name="create_dropbox_file", description="Create a new file in Dropbox"),
ToolMetadata(name="delete_dropbox_file", description="Delete a file from Dropbox"),
ToolMetadata(name="create_onedrive_file", description="Create a new file in Microsoft OneDrive"),
ToolMetadata(name="delete_onedrive_file", description="Move a OneDrive file to the recycle bin"),
ToolMetadata(name="search_calendar_events", description="Search Google Calendar events within a date range"),
ToolMetadata(name="create_calendar_event", description="Create a new event on Google Calendar"),
ToolMetadata(name="update_calendar_event", description="Update an existing indexed Google Calendar event"),
ToolMetadata(name="delete_calendar_event", description="Delete an existing indexed Google Calendar event"),
ToolMetadata(name="search_gmail", description="Search emails in Gmail using Gmail search syntax"),
ToolMetadata(name="read_gmail_email", description="Read the full content of a specific Gmail email"),
ToolMetadata(name="create_gmail_draft", description="Create a draft email in Gmail"),
ToolMetadata(name="send_gmail_email", description="Send an email via Gmail"),
ToolMetadata(name="trash_gmail_email", description="Move an indexed email to trash in Gmail"),
ToolMetadata(name="update_gmail_draft", description="Update an existing Gmail draft"),
ToolMetadata(name="create_confluence_page", description="Create a new page in the user's Confluence space"),
ToolMetadata(name="update_confluence_page", description="Update an existing indexed Confluence page"),
ToolMetadata(name="delete_confluence_page", description="Delete an existing indexed Confluence page"),
ToolMetadata(name="list_discord_channels", description="List text channels in the connected Discord server"),
ToolMetadata(name="read_discord_messages", description="Read recent messages from a Discord text channel"),
ToolMetadata(name="send_discord_message", description="Send a message to a Discord text channel"),
ToolMetadata(name="list_teams_channels", description="List Microsoft Teams and their channels"),
ToolMetadata(name="read_teams_messages", description="Read recent messages from a Microsoft Teams channel"),
ToolMetadata(name="send_teams_message", description="Send a message to a Microsoft Teams channel"),
ToolMetadata(name="list_luma_events", description="List upcoming and recent Luma events"),
ToolMetadata(name="read_luma_event", description="Read detailed information about a specific Luma event"),
ToolMetadata(name="create_luma_event", description="Create a new event on Luma"),
]

View file

@ -1,743 +0,0 @@
"""Tools registry for SurfSense deep agent.
This module provides a registry pattern for managing tools in the SurfSense agent.
It makes it easy for OSS contributors to add new tools by:
1. Creating a tool factory function in a new file in this directory
2. Registering the tool in the BUILTIN_TOOLS list below
Example of adding a new tool:
------------------------------
1. Create your tool file (e.g., `tools/my_tool.py`):
from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession
def create_my_tool(search_space_id: int, db_session: AsyncSession):
@tool
async def my_tool(param: str) -> dict:
'''My tool description.'''
# Your implementation
return {"result": "success"}
return my_tool
2. Import and register in this file:
from .my_tool import create_my_tool
# Add to BUILTIN_TOOLS list:
ToolDefinition(
name="my_tool",
description="Description of what your tool does",
factory=lambda deps: create_my_tool(
search_space_id=deps["search_space_id"],
db_session=deps["db_session"],
),
requires=["search_space_id", "db_session"],
),
"""
import logging
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from langchain_core.tools import BaseTool
from app.agents.shared.middleware.dedup_tool_calls import (
wrap_dedup_key_by_arg_name,
)
from app.db import ChatVisibility
from .confluence import (
create_create_confluence_page_tool,
create_delete_confluence_page_tool,
create_update_confluence_page_tool,
)
from .connected_accounts import create_get_connected_accounts_tool
from .discord import (
create_list_discord_channels_tool,
create_read_discord_messages_tool,
create_send_discord_message_tool,
)
from .dropbox import (
create_create_dropbox_file_tool,
create_delete_dropbox_file_tool,
)
from .generate_image import create_generate_image_tool
from .gmail import (
create_create_gmail_draft_tool,
create_read_gmail_email_tool,
create_search_gmail_tool,
create_send_gmail_email_tool,
create_trash_gmail_email_tool,
create_update_gmail_draft_tool,
)
from .google_calendar import (
create_create_calendar_event_tool,
create_delete_calendar_event_tool,
create_search_calendar_events_tool,
create_update_calendar_event_tool,
)
from .google_drive import (
create_create_google_drive_file_tool,
create_delete_google_drive_file_tool,
)
from .luma import (
create_create_luma_event_tool,
create_list_luma_events_tool,
create_read_luma_event_tool,
)
from .notion import (
create_create_notion_page_tool,
create_delete_notion_page_tool,
create_update_notion_page_tool,
)
from .onedrive import (
create_create_onedrive_file_tool,
create_delete_onedrive_file_tool,
)
from .podcast import create_generate_podcast_tool
from .report import create_generate_report_tool
from .resume import create_generate_resume_tool
from .teams import (
create_list_teams_channels_tool,
create_read_teams_messages_tool,
create_send_teams_message_tool,
)
from .video_presentation import create_generate_video_presentation_tool
from .web_search import create_web_search_tool
logger = logging.getLogger(__name__)
# =============================================================================
# Tool Definition
# =============================================================================
@dataclass
class ToolDefinition:
"""Definition of a tool that can be added to the agent.
Attributes:
name: Unique identifier for the tool
description: Human-readable description of what the tool does
factory: Callable that creates the tool. Receives a dict of dependencies.
requires: List of dependency names this tool needs (e.g., "search_space_id", "db_session")
enabled_by_default: Whether the tool is enabled when no explicit config is provided
required_connector: Searchable type string (e.g. ``"LINEAR_CONNECTOR"``)
that must be in ``available_connectors`` for the tool to be enabled.
dedup_key: Optional callable that maps a tool's ``args`` dict to a
string signature used by :class:`DedupHITLToolCallsMiddleware`
to drop duplicate calls within a single LLM response.
reverse: Optional callable that, given the tool's ``(args, result)``,
returns a ``ReverseDescriptor`` describing the inverse tool
invocation. Consumed by the snapshot/revert pipeline.
"""
name: str
description: str
factory: Callable[[dict[str, Any]], BaseTool]
requires: list[str] = field(default_factory=list)
enabled_by_default: bool = True
hidden: bool = False
required_connector: str | None = None
dedup_key: Callable[[dict[str, Any]], str] | None = None
reverse: Callable[[dict[str, Any], Any], dict[str, Any]] | None = None
# =============================================================================
# Deferred-import factories
# =============================================================================
# Used for tools whose impls live under ``multi_agent_chat``. Importing those
# at module-load time would cycle (``multi_agent_chat`` middleware imports
# this registry). The import inside the factory runs only when
# ``build_tools`` is called, by which point ``multi_agent_chat`` is fully
# initialised.
def _build_create_automation_tool(deps: dict[str, Any]) -> BaseTool:
from app.agents.multi_agent_chat.main_agent.tools.automation import (
create_create_automation_tool,
)
return create_create_automation_tool(
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
llm=deps["llm"],
)
def _build_scrape_webpage_tool(deps: dict[str, Any]) -> BaseTool:
# ``scrape_webpage`` is owned by the main agent (its sole live consumer);
# deferred import keeps this catalog free of a ``multi_agent_chat`` cycle.
from app.agents.multi_agent_chat.main_agent.tools.scrape_webpage import (
create_scrape_webpage_tool,
)
return create_scrape_webpage_tool(firecrawl_api_key=deps.get("firecrawl_api_key"))
def _build_update_memory_tool(deps: dict[str, Any]) -> BaseTool:
# ``update_memory`` is owned by the main agent; deferred import (see above).
from app.agents.multi_agent_chat.main_agent.tools.update_memory import (
create_update_memory_tool,
create_update_team_memory_tool,
)
if deps["thread_visibility"] == ChatVisibility.SEARCH_SPACE:
return create_update_team_memory_tool(
search_space_id=deps["search_space_id"],
db_session=deps["db_session"],
llm=deps.get("llm"),
)
return create_update_memory_tool(
user_id=deps["user_id"],
db_session=deps["db_session"],
llm=deps.get("llm"),
)
# =============================================================================
# Built-in Tools Registry
# =============================================================================
# Registry of all built-in tools
# Contributors: Add your new tools here!
BUILTIN_TOOLS: list[ToolDefinition] = [
# Podcast generation tool
ToolDefinition(
name="generate_podcast",
description="Generate an audio podcast from provided content",
factory=lambda deps: create_generate_podcast_tool(
search_space_id=deps["search_space_id"],
db_session=deps["db_session"],
thread_id=deps["thread_id"],
),
requires=["search_space_id", "db_session", "thread_id"],
),
# Video presentation generation tool
ToolDefinition(
name="generate_video_presentation",
description="Generate a video presentation with slides and narration from provided content",
factory=lambda deps: create_generate_video_presentation_tool(
search_space_id=deps["search_space_id"],
db_session=deps["db_session"],
thread_id=deps["thread_id"],
),
requires=["search_space_id", "db_session", "thread_id"],
),
# Report generation tool (inline, short-lived sessions for DB ops)
# Supports internal KB search via source_strategy so the agent does not
# need a separate search step before generating.
ToolDefinition(
name="generate_report",
description="Generate a structured report from provided content and export it",
factory=lambda deps: create_generate_report_tool(
search_space_id=deps["search_space_id"],
thread_id=deps["thread_id"],
connector_service=deps.get("connector_service"),
available_connectors=deps.get("available_connectors"),
available_document_types=deps.get("available_document_types"),
),
requires=["search_space_id", "thread_id"],
# connector_service, available_connectors, and available_document_types
# are optional — when missing, source_strategy="kb_search" degrades
# gracefully to "provided"
),
# Resume generation tool (Typst-based, uses rendercv package)
ToolDefinition(
name="generate_resume",
description="Generate a professional resume as a Typst document",
factory=lambda deps: create_generate_resume_tool(
search_space_id=deps["search_space_id"],
thread_id=deps["thread_id"],
),
requires=["search_space_id", "thread_id"],
),
# Generate image tool - creates images using AI models (DALL-E, GPT Image, etc.)
ToolDefinition(
name="generate_image",
description="Generate images from text descriptions using AI image models",
factory=lambda deps: create_generate_image_tool(
search_space_id=deps["search_space_id"],
db_session=deps["db_session"],
),
requires=["search_space_id", "db_session"],
),
# Web scraping tool - extracts content from webpages
ToolDefinition(
name="scrape_webpage",
description="Scrape and extract the main content from a webpage",
factory=_build_scrape_webpage_tool,
requires=[], # firecrawl_api_key is optional
),
# Web search tool — real-time web search via SearXNG + user-configured engines
ToolDefinition(
name="web_search",
description="Search the web for real-time information using configured search engines",
factory=lambda deps: create_web_search_tool(
search_space_id=deps.get("search_space_id"),
available_connectors=deps.get("available_connectors"),
),
requires=[],
),
# =========================================================================
# SERVICE ACCOUNT DISCOVERY
# Generic tool for the LLM to discover connected accounts and resolve
# service-specific identifiers (e.g. Jira cloudId, Slack team, etc.)
# =========================================================================
ToolDefinition(
name="get_connected_accounts",
description="Discover connected accounts for a service and their metadata",
factory=lambda deps: create_get_connected_accounts_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
),
# =========================================================================
# AUTOMATION AUTHORING - single HITL tool. The tool takes an NL ``intent``
# from the main agent, drafts the full AutomationCreate JSON via a focused
# sub-LLM, surfaces it on an approval card, and persists on approval. The
# factory defers its import because the impl lives under ``multi_agent_chat``
# and that package transitively pulls this registry via middleware;
# deferring to ``build_tools`` call-time breaks the cycle without a
# parallel registry.
# =========================================================================
ToolDefinition(
name="create_automation",
description="Draft an automation from an NL intent; user approves the card; tool saves",
factory=_build_create_automation_tool,
requires=["search_space_id", "user_id", "llm"],
),
# =========================================================================
# MEMORY TOOL - single update_memory, private or team by thread_visibility
# =========================================================================
ToolDefinition(
name="update_memory",
description="Save important long-term facts, preferences, and instructions to the (personal or team) memory",
factory=_build_update_memory_tool,
requires=[
"user_id",
"search_space_id",
"db_session",
"thread_visibility",
"llm",
],
),
# =========================================================================
# NOTION TOOLS - create, update, delete pages
# Auto-disabled when no Notion connector is configured # =========================================================================
ToolDefinition(
name="create_notion_page",
description="Create a new page in the user's Notion workspace",
factory=lambda deps: create_create_notion_page_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="NOTION_CONNECTOR",
dedup_key=wrap_dedup_key_by_arg_name("title"),
),
ToolDefinition(
name="update_notion_page",
description="Append new content to an existing Notion page",
factory=lambda deps: create_update_notion_page_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="NOTION_CONNECTOR",
dedup_key=wrap_dedup_key_by_arg_name("page_title"),
),
ToolDefinition(
name="delete_notion_page",
description="Delete an existing Notion page",
factory=lambda deps: create_delete_notion_page_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="NOTION_CONNECTOR",
dedup_key=wrap_dedup_key_by_arg_name("page_title"),
),
# =========================================================================
# GOOGLE DRIVE TOOLS - create files, delete files
# Auto-disabled when no Google Drive connector is configured # =========================================================================
ToolDefinition(
name="create_google_drive_file",
description="Create a new Google Doc or Google Sheet in Google Drive",
factory=lambda deps: create_create_google_drive_file_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="GOOGLE_DRIVE_FILE",
dedup_key=wrap_dedup_key_by_arg_name("file_name"),
),
ToolDefinition(
name="delete_google_drive_file",
description="Move an indexed Google Drive file to trash",
factory=lambda deps: create_delete_google_drive_file_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="GOOGLE_DRIVE_FILE",
dedup_key=wrap_dedup_key_by_arg_name("file_name"),
),
# =========================================================================
# DROPBOX TOOLS - create and trash files
# Auto-disabled when no Dropbox connector is configured # =========================================================================
ToolDefinition(
name="create_dropbox_file",
description="Create a new file in Dropbox",
factory=lambda deps: create_create_dropbox_file_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="DROPBOX_FILE",
dedup_key=wrap_dedup_key_by_arg_name("file_name"),
),
ToolDefinition(
name="delete_dropbox_file",
description="Delete a file from Dropbox",
factory=lambda deps: create_delete_dropbox_file_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="DROPBOX_FILE",
dedup_key=wrap_dedup_key_by_arg_name("file_name"),
),
# =========================================================================
# ONEDRIVE TOOLS - create and trash files
# Auto-disabled when no OneDrive connector is configured # =========================================================================
ToolDefinition(
name="create_onedrive_file",
description="Create a new file in Microsoft OneDrive",
factory=lambda deps: create_create_onedrive_file_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="ONEDRIVE_FILE",
dedup_key=wrap_dedup_key_by_arg_name("file_name"),
),
ToolDefinition(
name="delete_onedrive_file",
description="Move a OneDrive file to the recycle bin",
factory=lambda deps: create_delete_onedrive_file_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="ONEDRIVE_FILE",
dedup_key=wrap_dedup_key_by_arg_name("file_name"),
),
# =========================================================================
# GOOGLE CALENDAR TOOLS - search, create, update, delete events
# Auto-disabled when no Google Calendar connector is configured
# =========================================================================
ToolDefinition(
name="search_calendar_events",
description="Search Google Calendar events within a date range",
factory=lambda deps: create_search_calendar_events_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="GOOGLE_CALENDAR_CONNECTOR",
),
ToolDefinition(
name="create_calendar_event",
description="Create a new event on Google Calendar",
factory=lambda deps: create_create_calendar_event_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="GOOGLE_CALENDAR_CONNECTOR",
dedup_key=wrap_dedup_key_by_arg_name("title"),
),
ToolDefinition(
name="update_calendar_event",
description="Update an existing indexed Google Calendar event",
factory=lambda deps: create_update_calendar_event_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="GOOGLE_CALENDAR_CONNECTOR",
dedup_key=wrap_dedup_key_by_arg_name("event_title_or_id"),
),
ToolDefinition(
name="delete_calendar_event",
description="Delete an existing indexed Google Calendar event",
factory=lambda deps: create_delete_calendar_event_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="GOOGLE_CALENDAR_CONNECTOR",
dedup_key=wrap_dedup_key_by_arg_name("event_title_or_id"),
),
# =========================================================================
# GMAIL TOOLS - search, read, create drafts, update drafts, send, trash
# Auto-disabled when no Gmail connector is configured
# =========================================================================
ToolDefinition(
name="search_gmail",
description="Search emails in Gmail using Gmail search syntax",
factory=lambda deps: create_search_gmail_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="GOOGLE_GMAIL_CONNECTOR",
),
ToolDefinition(
name="read_gmail_email",
description="Read the full content of a specific Gmail email",
factory=lambda deps: create_read_gmail_email_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="GOOGLE_GMAIL_CONNECTOR",
),
ToolDefinition(
name="create_gmail_draft",
description="Create a draft email in Gmail",
factory=lambda deps: create_create_gmail_draft_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="GOOGLE_GMAIL_CONNECTOR",
dedup_key=wrap_dedup_key_by_arg_name("subject"),
),
ToolDefinition(
name="send_gmail_email",
description="Send an email via Gmail",
factory=lambda deps: create_send_gmail_email_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="GOOGLE_GMAIL_CONNECTOR",
dedup_key=wrap_dedup_key_by_arg_name("subject"),
),
ToolDefinition(
name="trash_gmail_email",
description="Move an indexed email to trash in Gmail",
factory=lambda deps: create_trash_gmail_email_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="GOOGLE_GMAIL_CONNECTOR",
dedup_key=wrap_dedup_key_by_arg_name("email_subject_or_id"),
),
ToolDefinition(
name="update_gmail_draft",
description="Update an existing Gmail draft",
factory=lambda deps: create_update_gmail_draft_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="GOOGLE_GMAIL_CONNECTOR",
dedup_key=wrap_dedup_key_by_arg_name("draft_subject_or_id"),
),
# =========================================================================
# CONFLUENCE TOOLS - create, update, delete pages
# Auto-disabled when no Confluence connector is configured # =========================================================================
ToolDefinition(
name="create_confluence_page",
description="Create a new page in the user's Confluence space",
factory=lambda deps: create_create_confluence_page_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="CONFLUENCE_CONNECTOR",
dedup_key=wrap_dedup_key_by_arg_name("title"),
),
ToolDefinition(
name="update_confluence_page",
description="Update an existing indexed Confluence page",
factory=lambda deps: create_update_confluence_page_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="CONFLUENCE_CONNECTOR",
dedup_key=wrap_dedup_key_by_arg_name("page_title_or_id"),
),
ToolDefinition(
name="delete_confluence_page",
description="Delete an existing indexed Confluence page",
factory=lambda deps: create_delete_confluence_page_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="CONFLUENCE_CONNECTOR",
dedup_key=wrap_dedup_key_by_arg_name("page_title_or_id"),
),
# =========================================================================
# DISCORD TOOLS - list channels, read messages, send messages
# Auto-disabled when no Discord connector is configured
# =========================================================================
ToolDefinition(
name="list_discord_channels",
description="List text channels in the connected Discord server",
factory=lambda deps: create_list_discord_channels_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="DISCORD_CONNECTOR",
),
ToolDefinition(
name="read_discord_messages",
description="Read recent messages from a Discord text channel",
factory=lambda deps: create_read_discord_messages_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="DISCORD_CONNECTOR",
),
ToolDefinition(
name="send_discord_message",
description="Send a message to a Discord text channel",
factory=lambda deps: create_send_discord_message_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="DISCORD_CONNECTOR",
),
# =========================================================================
# TEAMS TOOLS - list channels, read messages, send messages
# Auto-disabled when no Teams connector is configured
# =========================================================================
ToolDefinition(
name="list_teams_channels",
description="List Microsoft Teams and their channels",
factory=lambda deps: create_list_teams_channels_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="TEAMS_CONNECTOR",
),
ToolDefinition(
name="read_teams_messages",
description="Read recent messages from a Microsoft Teams channel",
factory=lambda deps: create_read_teams_messages_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="TEAMS_CONNECTOR",
),
ToolDefinition(
name="send_teams_message",
description="Send a message to a Microsoft Teams channel",
factory=lambda deps: create_send_teams_message_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="TEAMS_CONNECTOR",
),
# =========================================================================
# LUMA TOOLS - list events, read event details, create events
# Auto-disabled when no Luma connector is configured
# =========================================================================
ToolDefinition(
name="list_luma_events",
description="List upcoming and recent Luma events",
factory=lambda deps: create_list_luma_events_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="LUMA_CONNECTOR",
),
ToolDefinition(
name="read_luma_event",
description="Read detailed information about a specific Luma event",
factory=lambda deps: create_read_luma_event_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="LUMA_CONNECTOR",
),
ToolDefinition(
name="create_luma_event",
description="Create a new event on Luma",
factory=lambda deps: create_create_luma_event_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
required_connector="LUMA_CONNECTOR",
),
]
# =============================================================================
# Registry Functions
# =============================================================================
def get_connector_gated_tools(
available_connectors: list[str] | None,
) -> list[str]:
"""Return tool names to disable"""
available = set() if available_connectors is None else set(available_connectors)
disabled: list[str] = []
for tool_def in BUILTIN_TOOLS:
if tool_def.required_connector and tool_def.required_connector not in available:
disabled.append(tool_def.name)
return disabled

View file

@ -1668,7 +1668,7 @@ async def list_agent_tools(
Hidden (WIP) tools are excluded from the response.
"""
from app.agents.shared.tools.registry import BUILTIN_TOOLS
from app.agents.shared.tools.catalog import TOOL_CATALOG
return [
AgentToolInfo(
@ -1676,7 +1676,7 @@ async def list_agent_tools(
description=t.description,
enabled_by_default=t.enabled_by_default,
)
for t in BUILTIN_TOOLS
for t in TOOL_CATALOG
if not t.hidden
]

View file

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

View file

@ -91,10 +91,9 @@ def test_no_agent_tools_means_no_dedup() -> None:
"""After the cleanup tier removed the legacy ``_NATIVE_HITL_TOOL_DEDUP_KEYS``
map, dedup is purely declarative no resolvers means no dedup runs.
Coverage for the previously hardcoded native HITL tools now lives on
each :class:`ToolDefinition.dedup_key` in
:mod:`app.agents.shared.tools.registry`, which is wired through to
``tool.metadata`` by :func:`build_tools`.
Dedup is purely declarative: tools opt in by carrying a ``dedup_key``
(callable) or ``hitl_dedup_key`` (arg name) in their ``metadata``. With no
agent tools, there are no resolvers and dedup is a no-op.
"""
mw = DedupHITLToolCallsMiddleware(agent_tools=None)
state = {
@ -109,27 +108,6 @@ def test_no_agent_tools_means_no_dedup() -> None:
assert out is None
def test_registry_propagates_dedup_key_to_tool_metadata() -> None:
"""Smoke-check the wiring path that replaced the legacy native map.
``ToolDefinition.dedup_key`` set in the registry must be copied onto
the constructed tool's ``metadata`` so :class:`DedupHITLToolCallsMiddleware`
can pick it up at agent build time.
"""
from app.agents.shared.tools.registry import (
BUILTIN_TOOLS,
wrap_dedup_key_by_arg_name,
)
notion_tool_defs = [t for t in BUILTIN_TOOLS if t.name == "create_notion_page"]
assert notion_tool_defs, "registry should still expose create_notion_page"
tool_def = notion_tool_defs[0]
assert tool_def.dedup_key is not None
# Same wrapping helper used in the registry — sanity check identity
sample = wrap_dedup_key_by_arg_name("title")({"title": "Plan"})
assert sample == "plan"
def test_full_args_dedup_keeps_distinct_calls_sharing_a_field() -> None:
"""Regression: MCP tools (e.g. ``createJiraIssue``) used to dedup on
the schema's first required field, which is often the workspace /