diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/domain_routing_spec.py b/surfsense_backend/app/agents/multi_agent_chat/routing/domain_routing_spec.py index fedd19cfd..f61d5b151 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/routing/domain_routing_spec.py +++ b/surfsense_backend/app/agents/multi_agent_chat/routing/domain_routing_spec.py @@ -9,7 +9,12 @@ from typing import Any @dataclass(frozen=True) class DomainRoutingSpec: - """One ``@tool`` the supervisor calls to delegate to a compiled domain graph.""" + """One supervisor-facing routing ``@tool`` bound to a compiled domain graph. + + ``curated_context`` is optional for **any** route: when set, the routing tool prepends its return + value into the child task via :func:`~app.agents.multi_agent_chat.core.delegation.compose_child_task`. + :func:`build_supervisor_routing_tools` does not pass it (all routes treated the same); use when building specs manually. + """ tool_name: str description: str diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/route_connector_gate.py b/surfsense_backend/app/agents/multi_agent_chat/routing/route_connector_gate.py new file mode 100644 index 000000000..adf63d931 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/routing/route_connector_gate.py @@ -0,0 +1,56 @@ +"""Gate supervisor routing tools by connected searchable connector types (aligned with ``new_chat`` KB). + +When ``available_connectors`` is ``None``, all routes are emitted (caller did not pass an inventory). + +When provided, a connector route is emitted only if at least one required searchable type is present. +MCP tools are filtered upstream in :func:`~app.agents.multi_agent_chat.core.mcp_partition.partition_mcp_tools_by_expert_route` +so merges only include tools for connected accounts. +""" + +from __future__ import annotations + +# Route tool_name → searchable connector / doc-type strings (same family as +# ``chat_deepagent._CONNECTOR_TYPE_TO_SEARCHABLE`` values in ``available_connectors``). +_ROUTE_REQUIRES_ANY: dict[str, frozenset[str]] = { + "calendar": frozenset( + {"GOOGLE_CALENDAR_CONNECTOR", "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR"} + ), + "confluence": frozenset({"CONFLUENCE_CONNECTOR"}), + "discord": frozenset({"DISCORD_CONNECTOR"}), + "dropbox": frozenset({"DROPBOX_FILE"}), + "gmail": frozenset({"GOOGLE_GMAIL_CONNECTOR", "COMPOSIO_GMAIL_CONNECTOR"}), + "google_drive": frozenset( + {"GOOGLE_DRIVE_FILE", "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"} + ), + "luma": frozenset({"LUMA_CONNECTOR"}), + "notion": frozenset({"NOTION_CONNECTOR"}), + "onedrive": frozenset({"ONEDRIVE_FILE"}), + "teams": frozenset({"TEAMS_CONNECTOR"}), + # MCP-only supervisor routes (see ``core.mcp_partition.MCP_ONLY_ROUTE_KEYS_IN_ORDER``). + "linear": frozenset({"LINEAR_CONNECTOR"}), + "slack": frozenset({"SLACK_CONNECTOR"}), + "jira": frozenset({"JIRA_CONNECTOR"}), + "clickup": frozenset({"CLICKUP_CONNECTOR"}), + "airtable": frozenset({"AIRTABLE_CONNECTOR"}), + "generic_mcp": frozenset({"MCP_CONNECTOR"}), +} + + +def include_connector_route( + route_key: str, + available_connectors: list[str] | None, +) -> bool: + """Return whether to register this connector route on the supervisor. + + If ``available_connectors`` is omitted, preserve legacy behaviour (emit the route). + + Otherwise require at least one matching entry in ``available_connectors`` for connector-backed routes. + Builtin routes (research, memory, …) have no entry in ``_ROUTE_REQUIRES_ANY`` and are always included. + """ + if available_connectors is None: + return True + required = _ROUTE_REQUIRES_ANY.get(route_key) + if required is None: + return True + avail = set(available_connectors) + return bool(required & avail) diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py b/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py index 8ebeed469..e97dec0b7 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py +++ b/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Callable from typing import Any from langchain_core.language_models import BaseChatModel @@ -64,6 +63,7 @@ from app.agents.multi_agent_chat.expert_agent.mcp_bridge import build_mcp_route_ from app.agents.multi_agent_chat.core.mcp_partition import MCP_ONLY_ROUTE_KEYS_IN_ORDER from app.agents.multi_agent_chat.routing.domain_routing_spec import DomainRoutingSpec from app.agents.multi_agent_chat.routing.from_domain_agents import routing_tools_from_specs +from app.agents.multi_agent_chat.routing.route_connector_gate import include_connector_route _MCP_ONLY_ROUTE_DESCRIPTIONS: dict[str, str] = { "linear": ( @@ -96,143 +96,89 @@ def build_supervisor_routing_tools( llm: BaseChatModel, *, registry_dependencies: dict[str, Any] | None = None, - gmail_curated_context: Callable[[str], str | None] | None = None, - calendar_curated_context: Callable[[str], str | None] | None = None, include_deliverables: bool = True, mcp_tools_by_route: dict[str, list[BaseTool]] | None = None, + available_connectors: list[str] | None = None, ) -> list[BaseTool]: - """``expert_agent.builtins`` (research, memory, deliverables) plus ``expert_agent.connectors`` → routing tools. + """Build supervisor routing tools: builtins first, then connector experts (same pattern for all). + + Requires ``registry_dependencies`` to produce any routing tools; otherwise returns an empty list. Pass ``registry_dependencies`` from :func:`app.agents.multi_agent_chat.core.registry.build_registry_dependencies` - to enable **all** registry-backed routes (Gmail, Calendar, chat, doc stores, Luma, …) and builtins - (**research**, **memory**, **deliverables** when ``include_deliverables``). Use a real chat ``thread_id`` - in deps when deliverables need thread-scoped registry factories. + for builtins (**research**, **memory**, **deliverables** when ``include_deliverables``) and every + registry-backed connector route. - ``mcp_tools_by_route`` maps supervisor route keys (e.g. ``gmail``, ``linear``) to MCP tools loaded - elsewhere; those tools are merged into the matching expert subgraph only — the supervisor sees - routing tools, not raw MCP tools. + ``mcp_tools_by_route`` maps route keys to MCP tools merged into the matching expert subgraph. + + When ``available_connectors`` is set (searchable connector strings, same shape as ``new_chat``), + a vendor route is registered only if the connector is available **or** MCP tools are present for + that route. """ - mcp = mcp_tools_by_route or {} - if registry_dependencies is not None: - gmail_native = build_gmail_tools(registry_dependencies) - calendar_native = build_calendar_tools(registry_dependencies) - else: - gmail_native = [] - calendar_native = [] + if registry_dependencies is None: + return routing_tools_from_specs([]) - gmail_domain_agent = build_gmail_domain_agent(llm, gmail_native + mcp.get("gmail", [])) - calendar_domain_agent = build_calendar_domain_agent( - llm, - calendar_native + mcp.get("calendar", []), + mcp = mcp_tools_by_route or {} + specs: list[DomainRoutingSpec] = [] + + research_tools = build_research_tools(registry_dependencies) + research_agent = build_research_domain_agent(llm, research_tools) + specs.append( + DomainRoutingSpec( + tool_name="research", + description=( + "Route web search, page scraping, and SurfSense documentation help to the " + "research sub-agent. Pass a clear natural-language task." + ), + domain_agent=research_agent, + ), ) - specs: list[DomainRoutingSpec] = [ + memory_tools = build_memory_tools(registry_dependencies) + memory_agent = build_memory_domain_agent(llm, memory_tools) + specs.append( DomainRoutingSpec( - tool_name="gmail", + tool_name="memory", description=( - "Route Gmail-related work to the Gmail sub-agent. " - "Pass a clear natural-language task." + "Route saving long-term facts and preferences (personal or team memory) to the " + "memory sub-agent. Pass a clear natural-language task." ), - domain_agent=gmail_domain_agent, - curated_context=gmail_curated_context, + domain_agent=memory_agent, ), - DomainRoutingSpec( - tool_name="calendar", - description=( - "Route Google Calendar work to the Calendar sub-agent. " - "Pass a clear natural-language task." - ), - domain_agent=calendar_domain_agent, - curated_context=calendar_curated_context, - ), - ] + ) - if registry_dependencies is not None: - research_tools = build_research_tools(registry_dependencies) - research_agent = build_research_domain_agent(llm, research_tools) + if include_deliverables: + deliverables_tools = build_deliverables_tools(registry_dependencies) + deliverables_agent = build_deliverables_domain_agent(llm, deliverables_tools) specs.append( DomainRoutingSpec( - tool_name="research", + tool_name="deliverables", description=( - "Route web search, page scraping, and SurfSense documentation help to the " - "research sub-agent. Pass a clear natural-language task." + "Route structured outputs (reports, podcasts, video presentations, resumes, " + "images) to the deliverables sub-agent. Pass a clear natural-language task." ), - domain_agent=research_agent, + domain_agent=deliverables_agent, ), ) - memory_tools = build_memory_tools(registry_dependencies) - memory_agent = build_memory_domain_agent(llm, memory_tools) - specs.append( - DomainRoutingSpec( - tool_name="memory", - description=( - "Route saving long-term facts and preferences (personal or team memory) to the " - "memory sub-agent. Pass a clear natural-language task." - ), - domain_agent=memory_agent, - ), - ) - - if include_deliverables: - deliverables_tools = build_deliverables_tools(registry_dependencies) - deliverables_agent = build_deliverables_domain_agent(llm, deliverables_tools) - specs.append( - DomainRoutingSpec( - tool_name="deliverables", - description=( - "Route structured outputs (reports, podcasts, video presentations, resumes, " - "images) to the deliverables sub-agent. Pass a clear natural-language task." - ), - domain_agent=deliverables_agent, - ), - ) - - discord_tools = build_discord_tools(registry_dependencies) - discord_agent = build_discord_domain_agent( + # Connector experts (registry-backed + optional MCP merge): alphabetical by route key. + if include_connector_route("calendar", available_connectors): + calendar_agent = build_calendar_domain_agent( llm, - discord_tools + mcp.get("discord", []), + build_calendar_tools(registry_dependencies) + mcp.get("calendar", []), ) specs.append( DomainRoutingSpec( - tool_name="discord", + tool_name="calendar", description=( - "Route Discord work (channels, messages) to the Discord sub-agent. " + "Route Google Calendar work to the Calendar sub-agent. " "Pass a clear natural-language task." ), - domain_agent=discord_agent, - ), - ) - - teams_tools = build_teams_tools(registry_dependencies) - teams_agent = build_teams_domain_agent( - llm, - teams_tools + mcp.get("teams", []), - ) - specs.append( - DomainRoutingSpec( - tool_name="teams", - description=( - "Route Microsoft Teams work (channels, messages) to the Teams sub-agent. " - "Pass a clear natural-language task." - ), - domain_agent=teams_agent, - ), - ) - - notion_tools = build_notion_tools(registry_dependencies) - notion_agent = build_notion_domain_agent(llm, notion_tools) - specs.append( - DomainRoutingSpec( - tool_name="notion", - description=( - "Route Notion page work to the Notion sub-agent. Pass a clear natural-language task." - ), - domain_agent=notion_agent, + domain_agent=calendar_agent, ), ) + if include_connector_route("confluence", available_connectors): confluence_tools = build_confluence_tools(registry_dependencies) confluence_agent = build_confluence_domain_agent(llm, confluence_tools) specs.append( @@ -246,6 +192,50 @@ def build_supervisor_routing_tools( ), ) + if include_connector_route("discord", available_connectors): + discord_tools = build_discord_tools(registry_dependencies) + discord_agent = build_discord_domain_agent(llm, discord_tools + mcp.get("discord", [])) + specs.append( + DomainRoutingSpec( + tool_name="discord", + description=( + "Route Discord work (channels, messages) to the Discord sub-agent. " + "Pass a clear natural-language task." + ), + domain_agent=discord_agent, + ), + ) + + if include_connector_route("dropbox", available_connectors): + dropbox_tools = build_dropbox_tools(registry_dependencies) + dropbox_agent = build_dropbox_domain_agent(llm, dropbox_tools) + specs.append( + DomainRoutingSpec( + tool_name="dropbox", + description=( + "Route Dropbox file work to the Dropbox sub-agent. Pass a clear natural-language task." + ), + domain_agent=dropbox_agent, + ), + ) + + if include_connector_route("gmail", available_connectors): + gmail_agent = build_gmail_domain_agent( + llm, + build_gmail_tools(registry_dependencies) + mcp.get("gmail", []), + ) + specs.append( + DomainRoutingSpec( + tool_name="gmail", + description=( + "Route Gmail-related work to the Gmail sub-agent. " + "Pass a clear natural-language task." + ), + domain_agent=gmail_agent, + ), + ) + + if include_connector_route("google_drive", available_connectors): google_drive_tools = build_google_drive_tools(registry_dependencies) google_drive_agent = build_google_drive_domain_agent(llm, google_drive_tools) specs.append( @@ -259,31 +249,7 @@ def build_supervisor_routing_tools( ), ) - dropbox_tools = build_dropbox_tools(registry_dependencies) - dropbox_agent = build_dropbox_domain_agent(llm, dropbox_tools) - specs.append( - DomainRoutingSpec( - tool_name="dropbox", - description=( - "Route Dropbox file work to the Dropbox sub-agent. Pass a clear natural-language task." - ), - domain_agent=dropbox_agent, - ), - ) - - onedrive_tools = build_onedrive_tools(registry_dependencies) - onedrive_agent = build_onedrive_domain_agent(llm, onedrive_tools) - specs.append( - DomainRoutingSpec( - tool_name="onedrive", - description=( - "Route Microsoft OneDrive file work to the OneDrive sub-agent. " - "Pass a clear natural-language task." - ), - domain_agent=onedrive_agent, - ), - ) - + if include_connector_route("luma", available_connectors): luma_tools = build_luma_tools(registry_dependencies) luma_agent = build_luma_domain_agent(llm, luma_tools + mcp.get("luma", [])) specs.append( @@ -297,20 +263,63 @@ def build_supervisor_routing_tools( ), ) - for route_key in MCP_ONLY_ROUTE_KEYS_IN_ORDER: - only_mcp = mcp.get(route_key) or [] - if not only_mcp: - continue - desc = _MCP_ONLY_ROUTE_DESCRIPTIONS.get( - route_key, - f"Route {route_key} MCP work to the {route_key} sub-agent. Pass a clear natural-language task.", - ) - specs.append( - DomainRoutingSpec( - tool_name=route_key, - description=desc, - domain_agent=build_mcp_route_domain_agent(llm, route_key, only_mcp), + if include_connector_route("notion", available_connectors): + notion_tools = build_notion_tools(registry_dependencies) + notion_agent = build_notion_domain_agent(llm, notion_tools) + specs.append( + DomainRoutingSpec( + tool_name="notion", + description=( + "Route Notion page work to the Notion sub-agent. Pass a clear natural-language task." ), - ) + domain_agent=notion_agent, + ), + ) + + if include_connector_route("onedrive", available_connectors): + onedrive_tools = build_onedrive_tools(registry_dependencies) + onedrive_agent = build_onedrive_domain_agent(llm, onedrive_tools) + specs.append( + DomainRoutingSpec( + tool_name="onedrive", + description=( + "Route Microsoft OneDrive file work to the OneDrive sub-agent. " + "Pass a clear natural-language task." + ), + domain_agent=onedrive_agent, + ), + ) + + if include_connector_route("teams", available_connectors): + teams_tools = build_teams_tools(registry_dependencies) + teams_agent = build_teams_domain_agent(llm, teams_tools + mcp.get("teams", [])) + specs.append( + DomainRoutingSpec( + tool_name="teams", + description=( + "Route Microsoft Teams work (channels, messages) to the Teams sub-agent. " + "Pass a clear natural-language task." + ), + domain_agent=teams_agent, + ), + ) + + for route_key in MCP_ONLY_ROUTE_KEYS_IN_ORDER: + only_mcp = mcp.get(route_key) or [] + if not only_mcp: + continue + if not include_connector_route(route_key, available_connectors): + continue + desc = _MCP_ONLY_ROUTE_DESCRIPTIONS.get( + route_key, + f"Route {route_key} MCP work to the {route_key} sub-agent. Pass a clear natural-language task.", + ) + specs.append( + DomainRoutingSpec( + tool_name=route_key, + description=desc, + domain_agent=build_mcp_route_domain_agent(llm, route_key, only_mcp), + ), + ) return routing_tools_from_specs(specs)