From 5497f472b24d64285715105d75dd2f8a8933a432 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 02:36:16 +0200 Subject: [PATCH] Wire supervisor routing specs and registry-backed expert routes. --- .../multi_agent_chat/routing/__init__.py | 8 +- .../routing/domain_routing_spec.py | 17 + .../routing/from_domain_agents.py | 48 +-- .../routing/supervisor_routing.py | 315 +++++++++++++++++- 4 files changed, 340 insertions(+), 48 deletions(-) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/routing/domain_routing_spec.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/routing/__init__.py index 783d1fad2..c369aeea5 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/routing/__init__.py +++ b/surfsense_backend/app/agents/multi_agent_chat/routing/__init__.py @@ -1,11 +1,13 @@ """Supervisor routing: domain-agent wrappers and composed routing tool lists.""" -from app.agents.multi_agent_chat.routing.from_domain_agents import routing_tools_from_domain_agents +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.supervisor_routing import build_supervisor_routing_tools -from app.agents.multi_agent_chat.shared.invoke_output import extract_last_assistant_text +from app.agents.multi_agent_chat.core.invocation import extract_last_assistant_text __all__ = [ + "DomainRoutingSpec", "build_supervisor_routing_tools", "extract_last_assistant_text", - "routing_tools_from_domain_agents", + "routing_tools_from_specs", ] 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 new file mode 100644 index 000000000..fedd19cfd --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/routing/domain_routing_spec.py @@ -0,0 +1,17 @@ +"""Declarative description of one supervisor routing tool → domain agent.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class DomainRoutingSpec: + """One ``@tool`` the supervisor calls to delegate to a compiled domain graph.""" + + tool_name: str + description: str + domain_agent: Any + curated_context: Callable[[str], str | None] | None = None diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/from_domain_agents.py b/surfsense_backend/app/agents/multi_agent_chat/routing/from_domain_agents.py index 92ca14150..48d643d4d 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/routing/from_domain_agents.py +++ b/surfsense_backend/app/agents/multi_agent_chat/routing/from_domain_agents.py @@ -2,44 +2,28 @@ from __future__ import annotations -from typing import Any +from collections.abc import Sequence from langchain_core.tools import BaseTool, tool -from app.agents.multi_agent_chat.shared.invoke_output import extract_last_assistant_text +from app.agents.multi_agent_chat.routing.domain_routing_spec import DomainRoutingSpec +from app.agents.multi_agent_chat.core.delegation import compose_child_task +from app.agents.multi_agent_chat.core.invocation import extract_last_assistant_text -def routing_tools_from_domain_agents( - *, - gmail_domain_agent: Any, - calendar_domain_agent: Any, -) -> list[BaseTool]: - """Build ``gmail`` / ``calendar`` tools that invoke the given graphs (factory, not import-time exports).""" - - @tool( - "gmail", - description=( - "Route Gmail-related work to the Gmail sub-agent. " - "Pass a clear natural-language task." - ), - ) - def call_gmail_agent(task: str) -> str: - result = gmail_domain_agent.invoke( - {"messages": [{"role": "user", "content": task}]} +def _routing_tool_for_spec(spec: DomainRoutingSpec) -> BaseTool: + @tool(spec.tool_name, description=spec.description) + def _route(task: str) -> str: + curated = spec.curated_context(task) if spec.curated_context else None + content = compose_child_task(task, curated_context=curated) + result = spec.domain_agent.invoke( + {"messages": [{"role": "user", "content": content}]}, ) return extract_last_assistant_text(result) - @tool( - "calendar", - description=( - "Route Google Calendar work to the Calendar sub-agent. " - "Pass a clear natural-language task." - ), - ) - def call_calendar_agent(task: str) -> str: - result = calendar_domain_agent.invoke( - {"messages": [{"role": "user", "content": task}]} - ) - return extract_last_assistant_text(result) + return _route - return [call_gmail_agent, call_calendar_agent] + +def routing_tools_from_specs(specs: Sequence[DomainRoutingSpec]) -> list[BaseTool]: + """Build one supervisor-facing routing ``@tool`` per :class:`DomainRoutingSpec`.""" + return [_routing_tool_for_spec(spec) for spec in specs] 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 a69528b8e..8ebeed469 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 @@ -1,27 +1,316 @@ -"""Compose domain agents + connector tool lists into supervisor ``gmail`` / ``calendar`` routing tools.""" +"""Compose domain agents + tool lists into supervisor routing tools (one ``@tool`` per category).""" from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Callable +from typing import Any from langchain_core.language_models import BaseChatModel from langchain_core.tools import BaseTool -from app.agents.multi_agent_chat.calendar import build_calendar_domain_agent -from app.agents.multi_agent_chat.gmail import build_gmail_domain_agent -from app.agents.multi_agent_chat.routing.from_domain_agents import routing_tools_from_domain_agents +from app.agents.multi_agent_chat.expert_agent.builtins.deliverables import ( + build_deliverables_tools, + build_deliverables_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.builtins.memory import ( + build_memory_tools, + build_memory_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.builtins.research import ( + build_research_tools, + build_research_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.calendar import ( + build_calendar_tools, + build_calendar_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.confluence import ( + build_confluence_tools, + build_confluence_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.discord import ( + build_discord_tools, + build_discord_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.dropbox import ( + build_dropbox_tools, + build_dropbox_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.gmail import ( + build_gmail_tools, + build_gmail_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.google_drive import ( + build_google_drive_tools, + build_google_drive_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.luma import ( + build_luma_tools, + build_luma_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.notion import ( + build_notion_tools, + build_notion_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.onedrive import ( + build_onedrive_tools, + build_onedrive_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.teams import ( + build_teams_tools, + build_teams_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.mcp_bridge import build_mcp_route_domain_agent +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 + +_MCP_ONLY_ROUTE_DESCRIPTIONS: dict[str, str] = { + "linear": ( + "Route Linear work (issues, projects, cycles, documents) via MCP to the Linear sub-agent. " + "Pass a clear natural-language task." + ), + "slack": ( + "Route Slack search and channel/thread reads via MCP to the Slack sub-agent. " + "Pass a clear natural-language task." + ), + "jira": ( + "Route Jira issues and projects via MCP to the Jira sub-agent. " + "Pass a clear natural-language task." + ), + "clickup": ( + "Route ClickUp tasks via MCP to the ClickUp sub-agent. Pass a clear natural-language task." + ), + "airtable": ( + "Route Airtable bases and records via MCP to the Airtable sub-agent. " + "Pass a clear natural-language task." + ), + "generic_mcp": ( + "Route user-defined MCP (stdio) server tools to the custom MCP sub-agent. " + "Pass a clear natural-language task." + ), +} def build_supervisor_routing_tools( llm: BaseChatModel, *, - gmail_tools: Sequence[BaseTool] | None = None, - calendar_tools: Sequence[BaseTool] | None = None, + 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, ) -> list[BaseTool]: - """Domain agents (with their connector tools) → ``gmail`` / ``calendar`` routing tools.""" - gmail_domain_agent = build_gmail_domain_agent(llm, list(gmail_tools or [])) - calendar_domain_agent = build_calendar_domain_agent(llm, list(calendar_tools or [])) - return routing_tools_from_domain_agents( - gmail_domain_agent=gmail_domain_agent, - calendar_domain_agent=calendar_domain_agent, + """``expert_agent.builtins`` (research, memory, deliverables) plus ``expert_agent.connectors`` → routing tools. + + 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. + + ``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 = 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 = [] + + 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", []), ) + + specs: list[DomainRoutingSpec] = [ + DomainRoutingSpec( + tool_name="gmail", + description=( + "Route Gmail-related work to the Gmail sub-agent. " + "Pass a clear natural-language task." + ), + domain_agent=gmail_domain_agent, + curated_context=gmail_curated_context, + ), + 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) + 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, + ), + ) + + 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( + 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, + ), + ) + + 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, + ), + ) + + confluence_tools = build_confluence_tools(registry_dependencies) + confluence_agent = build_confluence_domain_agent(llm, confluence_tools) + specs.append( + DomainRoutingSpec( + tool_name="confluence", + description=( + "Route Confluence page work to the Confluence sub-agent. " + "Pass a clear natural-language task." + ), + domain_agent=confluence_agent, + ), + ) + + google_drive_tools = build_google_drive_tools(registry_dependencies) + google_drive_agent = build_google_drive_domain_agent(llm, google_drive_tools) + specs.append( + DomainRoutingSpec( + tool_name="google_drive", + description=( + "Route Google Drive file work to the Google Drive sub-agent. " + "Pass a clear natural-language task." + ), + domain_agent=google_drive_agent, + ), + ) + + 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, + ), + ) + + luma_tools = build_luma_tools(registry_dependencies) + luma_agent = build_luma_domain_agent(llm, luma_tools + mcp.get("luma", [])) + specs.append( + DomainRoutingSpec( + tool_name="luma", + description=( + "Route Luma calendar events (list, read, create) to the Luma sub-agent. " + "Pass a clear natural-language task." + ), + domain_agent=luma_agent, + ), + ) + + 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), + ), + ) + + return routing_tools_from_specs(specs)