diff --git a/VERSION b/VERSION index 818944f5b..df5db66fe 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.22 +0.0.23 diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example index 86c1b326f..ba89059c8 100644 --- a/surfsense_backend/.env.example +++ b/surfsense_backend/.env.example @@ -282,6 +282,9 @@ LANGSMITH_PROJECT=surfsense # ============================================================================= # OPTIONAL: New-chat agent feature flags # ============================================================================= +# Multi-agent orchestrator switch for authenticated chat streaming. +# MULTI_AGENT_CHAT_ENABLED=false + # Master kill-switch — when true, every flag below is forced OFF. # SURFSENSE_DISABLE_NEW_AGENT_STACK=false diff --git a/surfsense_backend/app/agents/multi_agent_chat/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/__init__.py new file mode 100644 index 000000000..6c7d79eb8 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/__init__.py @@ -0,0 +1,7 @@ +"""Deepagents-backed routes: ``subagents/``; main-agent graph under ``main_agent/`` (SRP subpackages).""" + +from __future__ import annotations + +from .main_agent import create_multi_agent_chat_deep_agent + +__all__ = ["create_multi_agent_chat_deep_agent"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/constants.py b/surfsense_backend/app/agents/multi_agent_chat/constants.py new file mode 100644 index 000000000..972677502 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/constants.py @@ -0,0 +1,43 @@ +"""Connector-type to subagent name; subagent name to availability tokens for build_subagents.""" + +from __future__ import annotations + +CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS: dict[str, str] = { + "GOOGLE_GMAIL_CONNECTOR": "gmail", + "COMPOSIO_GMAIL_CONNECTOR": "gmail", + "GOOGLE_CALENDAR_CONNECTOR": "calendar", + "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR": "calendar", + "DISCORD_CONNECTOR": "discord", + "TEAMS_CONNECTOR": "teams", + "LUMA_CONNECTOR": "luma", + "LINEAR_CONNECTOR": "linear", + "JIRA_CONNECTOR": "jira", + "CLICKUP_CONNECTOR": "clickup", + "SLACK_CONNECTOR": "slack", + "AIRTABLE_CONNECTOR": "airtable", + "NOTION_CONNECTOR": "notion", + "CONFLUENCE_CONNECTOR": "confluence", + "GOOGLE_DRIVE_CONNECTOR": "google_drive", + "COMPOSIO_GOOGLE_DRIVE_CONNECTOR": "google_drive", + "DROPBOX_CONNECTOR": "dropbox", + "ONEDRIVE_CONNECTOR": "onedrive", +} + +SUBAGENT_TO_REQUIRED_CONNECTOR_MAP: dict[str, frozenset[str]] = { + "deliverables": frozenset(), + "airtable": frozenset({"AIRTABLE_CONNECTOR"}), + "calendar": frozenset({"GOOGLE_CALENDAR_CONNECTOR"}), + "clickup": frozenset({"CLICKUP_CONNECTOR"}), + "confluence": frozenset({"CONFLUENCE_CONNECTOR"}), + "discord": frozenset({"DISCORD_CONNECTOR"}), + "dropbox": frozenset({"DROPBOX_FILE"}), + "gmail": frozenset({"GOOGLE_GMAIL_CONNECTOR"}), + "google_drive": frozenset({"GOOGLE_DRIVE_FILE"}), + "jira": frozenset({"JIRA_CONNECTOR"}), + "linear": frozenset({"LINEAR_CONNECTOR"}), + "luma": frozenset({"LUMA_CONNECTOR"}), + "notion": frozenset({"NOTION_CONNECTOR"}), + "onedrive": frozenset({"ONEDRIVE_FILE"}), + "slack": frozenset({"SLACK_CONNECTOR"}), + "teams": frozenset({"TEAMS_CONNECTOR"}), +} diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/__init__.py new file mode 100644 index 000000000..f74ca0cd0 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/__init__.py @@ -0,0 +1,7 @@ +"""Main-agent deep agent: ``runtime/`` (factory), ``graph/`` (compile), ``system_prompt/``, etc.""" + +from __future__ import annotations + +from .runtime import create_multi_agent_chat_deep_agent + +__all__ = ["create_multi_agent_chat_deep_agent"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/context_prune/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/context_prune/__init__.py new file mode 100644 index 000000000..550ba54c5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/context_prune/__init__.py @@ -0,0 +1,7 @@ +"""Tool-name pruning for context editing (exclude lists without dropping protected tools).""" + +from __future__ import annotations + +from .prune_tool_names import PRUNE_PROTECTED_TOOL_NAMES, safe_exclude_tools + +__all__ = ["PRUNE_PROTECTED_TOOL_NAMES", "safe_exclude_tools"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/context_prune/prune_tool_names.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/context_prune/prune_tool_names.py new file mode 100644 index 000000000..c8bf6d6e0 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/context_prune/prune_tool_names.py @@ -0,0 +1,26 @@ +"""Tool names excluded from context-editing prune when bound.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from langchain_core.tools import BaseTool + +PRUNE_PROTECTED_TOOL_NAMES: frozenset[str] = frozenset( + { + "generate_report", + "generate_resume", + "generate_podcast", + "generate_video_presentation", + "generate_image", + "read_email", + "search_emails", + "invalid", + }, +) + + +def safe_exclude_tools(tools: Sequence[BaseTool]) -> tuple[str, ...]: + """Names from ``PRUNE_PROTECTED_TOOL_NAMES`` that appear in ``tools``.""" + enabled = {t.name for t in tools} + return tuple(n for n in PRUNE_PROTECTED_TOOL_NAMES if n in enabled) diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/__init__.py new file mode 100644 index 000000000..e12108484 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/__init__.py @@ -0,0 +1,7 @@ +"""Sync compile of the main-agent LangGraph graph (middleware + ``create_agent``).""" + +from __future__ import annotations + +from .compile_graph_sync import build_compiled_agent_graph_sync + +__all__ = ["build_compiled_agent_graph_sync"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/compile_graph_sync.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/compile_graph_sync.py new file mode 100644 index 000000000..4ed94bf7b --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/compile_graph_sync.py @@ -0,0 +1,86 @@ +"""Synchronous graph compile (middleware + ``create_agent``).""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from deepagents import __version__ as deepagents_version +from langchain.agents import create_agent +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool +from langgraph.types import Checkpointer + +from app.agents.multi_agent_chat.middleware import ( + build_main_agent_deepagent_middleware, +) +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) +from app.agents.new_chat.context import SurfSenseContextSchema +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.db import ChatVisibility + + +def build_compiled_agent_graph_sync( + *, + llm: BaseChatModel, + tools: Sequence[BaseTool], + final_system_prompt: str, + backend_resolver: Any, + filesystem_mode: FilesystemMode, + search_space_id: int, + user_id: str | None, + thread_id: int | None, + visibility: ChatVisibility, + anon_session_id: str | None, + available_connectors: list[str] | None, + available_document_types: list[str] | None, + mentioned_document_ids: list[int] | None, + max_input_tokens: int | None, + flags: AgentFeatureFlags, + checkpointer: Checkpointer, + subagent_dependencies: dict[str, Any], + mcp_tools_by_agent: dict[str, ToolsPermissions] | None = None, + disabled_tools: list[str] | None = None, +): + """Sync compile: middleware + ``create_agent`` (run via ``asyncio.to_thread``).""" + main_agent_middleware = build_main_agent_deepagent_middleware( + llm=llm, + tools=tools, + backend_resolver=backend_resolver, + filesystem_mode=filesystem_mode, + search_space_id=search_space_id, + user_id=user_id, + thread_id=thread_id, + visibility=visibility, + anon_session_id=anon_session_id, + available_connectors=available_connectors, + available_document_types=available_document_types, + mentioned_document_ids=mentioned_document_ids, + max_input_tokens=max_input_tokens, + flags=flags, + subagent_dependencies=subagent_dependencies, + checkpointer=checkpointer, + mcp_tools_by_agent=mcp_tools_by_agent, + disabled_tools=disabled_tools, + ) + + agent = create_agent( + llm, + system_prompt=final_system_prompt, + tools=list(tools), + middleware=main_agent_middleware, + context_schema=SurfSenseContextSchema, + checkpointer=checkpointer, + ) + return agent.with_config( + { + "recursion_limit": 10_000, + "metadata": { + "ls_integration": "deepagents", + "versions": {"deepagents": deepagents_version}, + }, + } + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/__init__.py new file mode 100644 index 000000000..593e8da20 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/__init__.py @@ -0,0 +1,7 @@ +"""Async factory: wiring tools, prompts, MCP buckets, then graph compile.""" + +from __future__ import annotations + +from .factory import create_multi_agent_chat_deep_agent + +__all__ = ["create_multi_agent_chat_deep_agent"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/agent_cache.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/agent_cache.py new file mode 100644 index 000000000..42f984b79 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/agent_cache.py @@ -0,0 +1,117 @@ +"""Compiled agent graph caching for the multi-agent path.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Sequence +from typing import Any + +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool +from langgraph.types import Checkpointer + +from app.agents.multi_agent_chat.subagents.shared.permissions import ToolsPermissions +from app.agents.new_chat.agent_cache import ( + flags_signature, + get_cache, + stable_hash, + system_prompt_hash, + tools_signature, +) +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.db import ChatVisibility + +from ..graph.compile_graph_sync import build_compiled_agent_graph_sync + + +def mcp_signature(mcp_tools_by_agent: dict[str, ToolsPermissions]) -> str: + """Hash the per-agent MCP tool surface so a change rotates the cache key.""" + rows = [] + for agent_name in sorted(mcp_tools_by_agent.keys()): + perms = mcp_tools_by_agent[agent_name] + allow_names = sorted(item.get("name", "") for item in perms.get("allow", [])) + ask_names = sorted(item.get("name", "") for item in perms.get("ask", [])) + rows.append((agent_name, allow_names, ask_names)) + return stable_hash(rows) + + +async def build_agent_with_cache( + *, + llm: BaseChatModel, + tools: Sequence[BaseTool], + final_system_prompt: str, + backend_resolver: Any, + filesystem_mode: FilesystemMode, + search_space_id: int, + user_id: str | None, + thread_id: int | None, + visibility: ChatVisibility, + anon_session_id: str | None, + available_connectors: list[str], + available_document_types: list[str], + mentioned_document_ids: list[int] | None, + max_input_tokens: int | None, + flags: AgentFeatureFlags, + checkpointer: Checkpointer, + subagent_dependencies: dict[str, Any], + mcp_tools_by_agent: dict[str, ToolsPermissions], + disabled_tools: list[str] | None, + config_id: str | None, +) -> Any: + """Compile the multi-agent graph, serving from cache when key components are stable.""" + + async def _build() -> Any: + return await asyncio.to_thread( + build_compiled_agent_graph_sync, + llm=llm, + tools=tools, + final_system_prompt=final_system_prompt, + backend_resolver=backend_resolver, + filesystem_mode=filesystem_mode, + search_space_id=search_space_id, + user_id=user_id, + thread_id=thread_id, + visibility=visibility, + anon_session_id=anon_session_id, + available_connectors=available_connectors, + available_document_types=available_document_types, + mentioned_document_ids=mentioned_document_ids, + max_input_tokens=max_input_tokens, + flags=flags, + checkpointer=checkpointer, + subagent_dependencies=subagent_dependencies, + mcp_tools_by_agent=mcp_tools_by_agent, + disabled_tools=disabled_tools, + ) + + if not (flags.enable_agent_cache and not flags.disable_new_agent_stack): + return await _build() + + # Every per-request value any middleware closes over at __init__ must be in + # the key, otherwise a hit will leak state across threads. Bump the schema + # version when the component list changes shape. + cache_key = stable_hash( + "multi-agent-v1", + config_id, + thread_id, + user_id, + search_space_id, + visibility, + filesystem_mode, + anon_session_id, + tools_signature( + tools, + available_connectors=available_connectors, + available_document_types=available_document_types, + ), + mcp_signature(mcp_tools_by_agent), + flags_signature(flags), + system_prompt_hash(final_system_prompt), + max_input_tokens, + sorted(disabled_tools) if disabled_tools else None, + ) + return await get_cache().get_or_build(cache_key, builder=_build) + + +__all__ = ["build_agent_with_cache", "mcp_signature"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/factory.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/factory.py new file mode 100644 index 000000000..cb6410acb --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/factory.py @@ -0,0 +1,257 @@ +"""Async factory: tools, system prompt, MCP buckets for subagents, then sync graph compile.""" + +from __future__ import annotations + +import logging +import time +from collections.abc import Sequence +from typing import Any + +from deepagents.graph import BASE_AGENT_PROMPT +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool +from langgraph.types import Checkpointer +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.multi_agent_chat.subagents import ( + get_subagents_to_exclude, + main_prompt_registry_subagent_lines, +) +from app.agents.multi_agent_chat.subagents.mcp_tools.index import ( + load_mcp_tools_by_connector, +) +from app.agents.new_chat.chat_deepagent import _map_connectors_to_searchable_types +from app.agents.new_chat.feature_flags import AgentFeatureFlags, get_flags +from app.agents.new_chat.filesystem_backends import build_backend_resolver +from app.agents.new_chat.filesystem_selection import FilesystemMode, FilesystemSelection +from app.agents.new_chat.llm_config import AgentConfig +from app.agents.new_chat.prompt_caching import apply_litellm_prompt_caching +from app.agents.new_chat.tools.invalid_tool import INVALID_TOOL_NAME, invalid_tool +from app.agents.new_chat.tools.registry import build_tools_async +from app.db import ChatVisibility +from app.services.connector_service import ConnectorService +from app.utils.perf import get_perf_logger + +from ..system_prompt import build_main_agent_system_prompt +from ..tools import ( + MAIN_AGENT_SURFSENSE_TOOL_NAMES, + MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED, +) +from .agent_cache import build_agent_with_cache + +_perf_log = get_perf_logger() + + +async def create_multi_agent_chat_deep_agent( + llm: BaseChatModel, + search_space_id: int, + db_session: AsyncSession, + connector_service: ConnectorService, + checkpointer: Checkpointer, + user_id: str | None = None, + thread_id: int | None = None, + agent_config: AgentConfig | None = None, + enabled_tools: list[str] | None = None, + disabled_tools: list[str] | None = None, + additional_tools: Sequence[BaseTool] | None = None, + firecrawl_api_key: str | None = None, + thread_visibility: ChatVisibility | None = None, + mentioned_document_ids: list[int] | None = None, + anon_session_id: str | None = None, + filesystem_selection: FilesystemSelection | None = None, +): + """Deep agent with SurfSense tools/middleware; registry route subagents behind ``task`` when enabled.""" + _t_agent_total = time.perf_counter() + + apply_litellm_prompt_caching(llm, agent_config=agent_config, thread_id=thread_id) + + filesystem_selection = filesystem_selection or FilesystemSelection() + backend_resolver = build_backend_resolver( + filesystem_selection, + search_space_id=search_space_id + if filesystem_selection.mode == FilesystemMode.CLOUD + else None, + ) + + available_connectors: list[str] | None = None + available_document_types: list[str] | None = None + + _t0 = time.perf_counter() + try: + connector_types = await connector_service.get_available_connectors( + search_space_id + ) + available_connectors = _map_connectors_to_searchable_types(connector_types) + + available_document_types = await connector_service.get_available_document_types( + search_space_id + ) + + except Exception as e: + logging.warning( + "Connector/doc-type discovery failed; excluding connector subagents this turn: %s", + e, + ) + + # Fail closed: a None list short-circuits ``get_subagents_to_exclude`` to "exclude + # nothing", which would silently advertise every connector specialist on a flaky + # discovery call. Empty list excludes connector-gated subagents while keeping builtins. + if available_connectors is None: + available_connectors = [] + if available_document_types is None: + available_document_types = [] + _perf_log.info( + "[create_agent] Connector/doc-type discovery in %.3fs", + time.perf_counter() - _t0, + ) + + visibility = thread_visibility or ChatVisibility.PRIVATE + + _model_profile = getattr(llm, "profile", None) + _max_input_tokens: int | None = ( + _model_profile.get("max_input_tokens") + if isinstance(_model_profile, dict) + else None + ) + + dependencies: dict[str, Any] = { + "search_space_id": search_space_id, + "db_session": db_session, + "connector_service": connector_service, + "firecrawl_api_key": firecrawl_api_key, + "user_id": user_id, + "thread_id": thread_id, + "thread_visibility": visibility, + "available_connectors": available_connectors, + "available_document_types": available_document_types, + "max_input_tokens": _max_input_tokens, + "llm": llm, + } + + _t0 = time.perf_counter() + try: + mcp_tools_by_agent = await load_mcp_tools_by_connector( + db_session, search_space_id + ) + except Exception as e: + # Degrade to builtins-only rather than aborting the turn: a transient + # DB or MCP-server hiccup should not deny the user a response. + logging.warning( + "MCP tool discovery failed; subagents will run without MCP tools this turn: %s", + e, + ) + mcp_tools_by_agent = {} + _perf_log.info( + "[create_agent] load_mcp_tools_by_connector in %.3fs (%d buckets)", + time.perf_counter() - _t0, + len(mcp_tools_by_agent), + ) + + modified_disabled_tools = list(disabled_tools) if disabled_tools else [] + + if "search_knowledge_base" not in modified_disabled_tools: + modified_disabled_tools.append("search_knowledge_base") + + if enabled_tools is not None: + main_agent_enabled_tools = [ + n for n in enabled_tools if n in MAIN_AGENT_SURFSENSE_TOOL_NAMES + ] + else: + main_agent_enabled_tools = list(MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED) + + _t0 = time.perf_counter() + tools = await build_tools_async( + dependencies=dependencies, + enabled_tools=main_agent_enabled_tools, + disabled_tools=modified_disabled_tools, + additional_tools=list(additional_tools) if additional_tools else None, + include_mcp_tools=False, + ) + + _flags: AgentFeatureFlags = get_flags() + if _flags.enable_tool_call_repair and INVALID_TOOL_NAME not in { + t.name for t in tools + }: + tools = [*list(tools), invalid_tool] + _perf_log.info( + "[create_agent] build_tools_async in %.3fs (%d tools)", + time.perf_counter() - _t0, + len(tools), + ) + + _t0 = time.perf_counter() + _enabled_tool_names = {t.name for t in tools} + _user_disabled_tool_names = set(disabled_tools) if disabled_tools else set() + + _model_name: str | None = None + prof = getattr(llm, "model_name", None) or getattr(llm, "model", None) + if isinstance(prof, str): + _model_name = prof + + _connector_exclude = get_subagents_to_exclude(available_connectors) + _registry_subagent_prompt_lines = main_prompt_registry_subagent_lines( + _connector_exclude + ) + + if agent_config is not None: + system_prompt = build_main_agent_system_prompt( + today=None, + thread_visibility=thread_visibility, + enabled_tool_names=_enabled_tool_names, + disabled_tool_names=_user_disabled_tool_names, + custom_system_instructions=agent_config.system_instructions, + use_default_system_instructions=agent_config.use_default_system_instructions, + citations_enabled=agent_config.citations_enabled, + model_name=_model_name or getattr(agent_config, "model_name", None), + registry_subagent_prompt_lines=_registry_subagent_prompt_lines, + ) + else: + system_prompt = build_main_agent_system_prompt( + thread_visibility=thread_visibility, + enabled_tool_names=_enabled_tool_names, + disabled_tool_names=_user_disabled_tool_names, + citations_enabled=True, + model_name=_model_name, + registry_subagent_prompt_lines=_registry_subagent_prompt_lines, + ) + _perf_log.info( + "[create_agent] System prompt built in %.3fs", time.perf_counter() - _t0 + ) + + final_system_prompt = system_prompt + "\n\n" + BASE_AGENT_PROMPT + + config_id = agent_config.config_id if agent_config is not None else None + + _t0 = time.perf_counter() + agent = await build_agent_with_cache( + llm=llm, + tools=tools, + final_system_prompt=final_system_prompt, + backend_resolver=backend_resolver, + filesystem_mode=filesystem_selection.mode, + search_space_id=search_space_id, + user_id=user_id, + thread_id=thread_id, + visibility=visibility, + anon_session_id=anon_session_id, + available_connectors=available_connectors, + available_document_types=available_document_types, + mentioned_document_ids=mentioned_document_ids, + max_input_tokens=_max_input_tokens, + flags=_flags, + checkpointer=checkpointer, + subagent_dependencies=dependencies, + mcp_tools_by_agent=mcp_tools_by_agent, + disabled_tools=disabled_tools, + config_id=config_id, + ) + _perf_log.info( + "[create_agent] Middleware stack + graph compiled in %.3fs", + time.perf_counter() - _t0, + ) + + _perf_log.info( + "[create_agent] Total agent creation in %.3fs", + time.perf_counter() - _t_agent_total, + ) + return agent diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/__init__.py new file mode 100644 index 000000000..d58aecdf4 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/__init__.py @@ -0,0 +1,7 @@ +"""Main-agent system prompt — not shared verbatim with single-agent ``new_chat``.""" + +from __future__ import annotations + +from .builder import build_main_agent_system_prompt + +__all__ = ["build_main_agent_system_prompt"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/__init__.py new file mode 100644 index 000000000..151280707 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/__init__.py @@ -0,0 +1,7 @@ +"""Assemble the main-agent system prompt from ``markdown/*.md`` fragments.""" + +from __future__ import annotations + +from .compose import build_main_agent_system_prompt + +__all__ = ["build_main_agent_system_prompt"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/compose.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/compose.py new file mode 100644 index 000000000..5f09b9cac --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/compose.py @@ -0,0 +1,53 @@ +"""Assemble the **main-agent** deep-agent system string only. + +Sections (order matters): core instructions → provider → citations → dynamic +```` → SurfSense ````. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from app.db import ChatVisibility + +from .sections.citations import build_citations_section +from .sections.provider import build_provider_section +from .sections.registry_subagents import build_registry_subagents_section +from .sections.system_instruction import build_default_system_instruction_xml +from .sections.tools import build_tools_section + + +def build_main_agent_system_prompt( + *, + today: datetime | None = None, + thread_visibility: ChatVisibility | None = None, + enabled_tool_names: set[str] | None = None, + disabled_tool_names: set[str] | None = None, + custom_system_instructions: str | None = None, + use_default_system_instructions: bool = True, + citations_enabled: bool = True, + model_name: str | None = None, + registry_subagent_prompt_lines: list[tuple[str, str]] | None = None, +) -> str: + resolved_today = (today or datetime.now(UTC)).astimezone(UTC).date().isoformat() + visibility = thread_visibility or ChatVisibility.PRIVATE + + if custom_system_instructions and custom_system_instructions.strip(): + system_block = custom_system_instructions.format(resolved_today=resolved_today) + elif use_default_system_instructions: + system_block = build_default_system_instruction_xml( + visibility=visibility, + resolved_today=resolved_today, + ) + else: + system_block = "" + + system_block += build_provider_section(model_name=model_name) + system_block += build_citations_section(citations_enabled=citations_enabled) + system_block += build_registry_subagents_section(registry_subagent_prompt_lines) + system_block += build_tools_section( + visibility=visibility, + enabled_tool_names=enabled_tool_names, + disabled_tool_names=disabled_tool_names, + ) + return system_block diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/load_md.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/load_md.py new file mode 100644 index 000000000..f29e7f9ef --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/load_md.py @@ -0,0 +1,16 @@ +"""Load main-agent-only markdown from ``system_prompt/markdown/`` (``importlib.resources``).""" + +from __future__ import annotations + +from importlib import resources + +_PROMPTS_PACKAGE = "app.agents.multi_agent_chat.main_agent.system_prompt.markdown" + + +def read_prompt_md(filename: str) -> str: + """Load ``markdown/{filename}`` (e.g. ``agent_private.md`` or ``tools/_preamble.md``).""" + ref = resources.files(_PROMPTS_PACKAGE).joinpath(filename) + if not ref.is_file(): + return "" + text = ref.read_text(encoding="utf-8") + return text[:-1] if text.endswith("\n") else text diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/provider_hints.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/provider_hints.py new file mode 100644 index 000000000..fa85af8d5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/provider_hints.py @@ -0,0 +1,50 @@ +"""Provider-specific style hints from ``markdown/providers/`` (main agent only).""" + +from __future__ import annotations + +import re + +from .load_md import read_prompt_md + +ProviderVariant = str + +_OPENAI_CODEX_RE = re.compile( + r"\b(gpt-codex|codex-mini|gpt-[\d.]+-codex)\b", re.IGNORECASE +) +_OPENAI_REASONING_RE = re.compile(r"\b(gpt-5|o\d|o-)", re.IGNORECASE) +_OPENAI_CLASSIC_RE = re.compile(r"\bgpt-4", re.IGNORECASE) +_ANTHROPIC_RE = re.compile(r"\bclaude\b", re.IGNORECASE) +_GOOGLE_RE = re.compile(r"\bgemini\b", re.IGNORECASE) +_KIMI_RE = re.compile(r"\b(kimi[-\d.]*|moonshot)\b", re.IGNORECASE) +_GROK_RE = re.compile(r"\bgrok\b", re.IGNORECASE) +_DEEPSEEK_RE = re.compile(r"\bdeepseek\b", re.IGNORECASE) + + +def detect_provider_variant(model_name: str | None) -> ProviderVariant: + if not model_name: + return "default" + name = model_name.strip() + if _OPENAI_CODEX_RE.search(name): + return "openai_codex" + if _OPENAI_REASONING_RE.search(name): + return "openai_reasoning" + if _OPENAI_CLASSIC_RE.search(name): + return "openai_classic" + if _ANTHROPIC_RE.search(name): + return "anthropic" + if _GOOGLE_RE.search(name): + return "google" + if _KIMI_RE.search(name): + return "kimi" + if _GROK_RE.search(name): + return "grok" + if _DEEPSEEK_RE.search(name): + return "deepseek" + return "default" + + +def build_provider_hint_block(provider_variant: ProviderVariant) -> str: + if not provider_variant or provider_variant == "default": + return "" + text = read_prompt_md(f"providers/{provider_variant}.md") + return f"\n{text}\n" if text else "" diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/__init__.py new file mode 100644 index 000000000..568b52baf --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/__init__.py @@ -0,0 +1 @@ +"""Rendered slices of the main-agent system prompt.""" diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/citations.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/citations.py new file mode 100644 index 000000000..db3909bbd --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/citations.py @@ -0,0 +1,11 @@ +"""Citation fragment for the main agent (chunk-tagged context only).""" + +from __future__ import annotations + +from ..load_md import read_prompt_md + + +def build_citations_section(*, citations_enabled: bool) -> str: + name = "citations_on.md" if citations_enabled else "citations_off.md" + fragment = read_prompt_md(name) + return f"\n{fragment}\n" if fragment else "" diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/provider.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/provider.py new file mode 100644 index 000000000..7de722080 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/provider.py @@ -0,0 +1,9 @@ +"""Provider-specific style hints.""" + +from __future__ import annotations + +from ..provider_hints import build_provider_hint_block, detect_provider_variant + + +def build_provider_section(*, model_name: str | None) -> str: + return build_provider_hint_block(detect_provider_variant(model_name)) diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/registry_subagents.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/registry_subagents.py new file mode 100644 index 000000000..90f4cc2d6 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/registry_subagents.py @@ -0,0 +1,27 @@ +"""Dynamic ```` block: **task** specialists actually built for this workspace.""" + +from __future__ import annotations + + +def build_registry_subagents_section( + registry_subagent_lines: list[tuple[str, str]] | None, +) -> str: + if registry_subagent_lines is None: + return "" + if not registry_subagent_lines: + return ( + "\n\n" + "No registry specialists are listed for **task** in this workspace.\n" + "\n" + ) + bullets = "\n".join( + f"- **{name}** — {desc}" for name, desc in registry_subagent_lines + ) + return ( + "\n\n" + "These specialists are registered for **task** (routes without a matching connector are omitted).\n" + f"{bullets}\n" + "The runtime may also offer a general-purpose **task** helper with your tools in a separate context.\n" + "Pick the specialist by **name**. Put full instructions in the task prompt; they do not see this thread.\n" + "\n" + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/system_instruction.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/system_instruction.py new file mode 100644 index 000000000..b14d87002 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/system_instruction.py @@ -0,0 +1,35 @@ +"""Default ```` block for the main agent only.""" + +from __future__ import annotations + +from app.db import ChatVisibility + +from ..load_md import read_prompt_md + +_PRIVATE_ORDER = ( + "agent_private.md", + "kb_only_policy_private.md", + "main_agent_tool_routing.md", + "parameter_resolution.md", + "memory_protocol_private.md", +) +_TEAM_ORDER = ( + "agent_team.md", + "kb_only_policy_team.md", + "main_agent_tool_routing.md", + "parameter_resolution.md", + "memory_protocol_team.md", +) + + +def build_default_system_instruction_xml( + *, + visibility: ChatVisibility, + resolved_today: str, +) -> str: + order = _TEAM_ORDER if visibility == ChatVisibility.SEARCH_SPACE else _PRIVATE_ORDER + parts = [read_prompt_md(name) for name in order] + body = "\n\n".join(p for p in parts if p) + return f"\n\n{body}\n\n\n".format( + resolved_today=resolved_today, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/tools.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/tools.py new file mode 100644 index 000000000..bc4d48ef5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/tools.py @@ -0,0 +1,20 @@ +"""Main-agent ```` block (memory + research builtins only; see ``main_agent.tools``).""" + +from __future__ import annotations + +from app.db import ChatVisibility + +from ..tool_instruction_block import build_tools_instruction_block + + +def build_tools_section( + *, + visibility: ChatVisibility, + enabled_tool_names: set[str] | None, + disabled_tool_names: set[str] | None, +) -> str: + return build_tools_instruction_block( + visibility=visibility, + enabled_tool_names=enabled_tool_names, + disabled_tool_names=disabled_tool_names, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/tool_instruction_block.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/tool_instruction_block.py new file mode 100644 index 000000000..d5b3fea4e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/tool_instruction_block.py @@ -0,0 +1,86 @@ +"""```` + ```` from ``system_prompt/markdown/{tools,examples}/``. + +Only documents tools the main agent actually binds — not full ``new_chat``. +""" + +from __future__ import annotations + +from app.db import ChatVisibility + +from ...tools import MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED +from .load_md import read_prompt_md + +_MEMORY_VARIANT_TOOLS: frozenset[str] = frozenset({"update_memory"}) + + +def _tool_fragment_path(tool_name: str, variant: str) -> str: + if tool_name in _MEMORY_VARIANT_TOOLS: + return f"tools/{tool_name}_{variant}.md" + return f"tools/{tool_name}.md" + + +def _example_fragment_path(tool_name: str, variant: str) -> str: + if tool_name in _MEMORY_VARIANT_TOOLS: + return f"examples/{tool_name}_{variant}.md" + return f"examples/{tool_name}.md" + + +def _format_tool_label(tool_name: str) -> str: + return tool_name.replace("_", " ").title() + + +def build_tools_instruction_block( + *, + visibility: ChatVisibility, + enabled_tool_names: set[str] | None, + disabled_tool_names: set[str] | None, +) -> str: + variant = "team" if visibility == ChatVisibility.SEARCH_SPACE else "private" + + parts: list[str] = [] + preamble = read_prompt_md("tools/_preamble.md") + if preamble: + parts.append(preamble + "\n") + + examples: list[str] = [] + + for tool_name in MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED: + if enabled_tool_names is not None and tool_name not in enabled_tool_names: + continue + + instruction = read_prompt_md(_tool_fragment_path(tool_name, variant)) + if instruction: + parts.append(instruction + "\n") + + example = read_prompt_md(_example_fragment_path(tool_name, variant)) + if example: + examples.append(example + "\n") + + known_disabled = ( + set(disabled_tool_names) & set(MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED) + if disabled_tool_names + else set() + ) + if known_disabled: + disabled_list = ", ".join( + _format_tool_label(n) + for n in MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED + if n in known_disabled + ) + parts.append( + "\n" + "DISABLED TOOLS (by user, main-agent scope):\n" + f"These SurfSense tools were disabled on the main agent for this session: {disabled_list}.\n" + "You do NOT have access to them and MUST NOT claim you can use them.\n" + "If the user still needs that capability, delegate with **task** if a subagent covers it,\n" + "otherwise explain it is disabled on the main agent for this session.\n" + ) + + parts.append("\n\n") + + if examples: + parts.append("") + parts.extend(examples) + parts.append("\n") + + return "".join(parts) diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/__init__.py new file mode 100644 index 000000000..b53f8165a --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/__init__.py @@ -0,0 +1 @@ +"""Markdown fragments for the **main-agent** system prompt only (`importlib.resources`).""" diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/agent_private.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/agent_private.md new file mode 100644 index 000000000..6bf575501 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/agent_private.md @@ -0,0 +1,9 @@ +You are SurfSense’s **main agent**: you answer using the user’s knowledge context, +lightweight research tools, and memory — and you **delegate** integrations and +specialized work via **task** (see `` in this prompt). + +Today's date (UTC): {resolved_today} + +When writing mathematical formulas or equations, ALWAYS use LaTeX notation. NEVER use backtick code spans or Unicode symbols for math. + +NEVER expose internal tool parameter names, backend IDs, or implementation details to the user. Always use natural, user-friendly language instead. diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/agent_team.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/agent_team.md new file mode 100644 index 000000000..fa95849c1 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/agent_team.md @@ -0,0 +1,11 @@ +You are SurfSense’s **main agent** for this team space: you answer using shared +knowledge context, lightweight research tools, and memory — and you **delegate** +integrations and specialized work via **task** (see `` in this prompt). + +In this team thread, each message is prefixed with **[DisplayName of the author]**. Use this to attribute and reference the author of anything in the discussion (who asked a question, made a suggestion, or contributed an idea) and to cite who said what in your answers. + +Today's date (UTC): {resolved_today} + +When writing mathematical formulas or equations, ALWAYS use LaTeX notation. NEVER use backtick code spans or Unicode symbols for math. + +NEVER expose internal tool parameter names, backend IDs, or implementation details to the user. Always use natural, user-friendly language instead. diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/citations_off.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/citations_off.md new file mode 100644 index 000000000..5af3ca1f3 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/citations_off.md @@ -0,0 +1,15 @@ + +IMPORTANT: Citations are DISABLED for this configuration. + +DO NOT include `[citation:…]` markers anywhere — even if tool descriptions or examples +mention them. Ignore citation-format reminders elsewhere in this prompt when they conflict +with this block. + +Instead: +1. Answer in plain prose; optional markdown links to public URLs when sources are URLs. +2. Do NOT expose raw chunk IDs, document IDs, or internal IDs to the user. +3. Present indexed or doc-search facts naturally without attribution markers. + +When answering from workspace or docs context: integrate facts cleanly without claiming +“this comes from chunk X”. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/citations_on.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/citations_on.md new file mode 100644 index 000000000..4e6d6ce6d --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/citations_on.md @@ -0,0 +1,15 @@ + +This block appears **before** `` so it wins over any tool-example wording below. + +Apply chunk citations **only** when the runtime injects `` / `` blocks +(e.g. from SurfSense docs search or priority documents). + +1. For each factual statement taken from those chunks, add `[citation:chunk_id]` using the **exact** `chunk_id` string from ``. +2. Multiple chunks → `[citation:id1], [citation:id2]` (comma-separated). +3. Never invent or normalize ids; if unsure, omit the citation. +4. Plain brackets only — no markdown links, no `([citation:…](url))`, no footnote numbering. + +Chunk ids may be numeric, prefixed (e.g. `doc-45`), or URLs when the source is web-shaped — copy verbatim. + +If no chunk-tagged documents appear in context this turn, do not fabricate citations. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/__init__.py @@ -0,0 +1 @@ + diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/scrape_webpage.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/scrape_webpage.md new file mode 100644 index 000000000..0f156bf24 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/scrape_webpage.md @@ -0,0 +1,13 @@ + +- User: "Check out https://dev.to/some-article" + - Call: `scrape_webpage(url="https://dev.to/some-article")` + - Respond with a structured analysis — key points, takeaways. +- User: "Read this article and summarize it for me: https://example.com/blog/ai-trends" + - Call: `scrape_webpage(url="https://example.com/blog/ai-trends")` + - Respond with a thorough summary using headings and bullet points. +- User: (after discussing https://example.com/stats) "Can you get the live data from that page?" + - Call: `scrape_webpage(url="https://example.com/stats")` + - IMPORTANT: Always attempt scraping first. Never refuse before trying the tool. +- User: "https://example.com/blog/weekend-recipes" + - Call: `scrape_webpage(url="https://example.com/blog/weekend-recipes")` + - When a user sends just a URL with no instructions, scrape it and provide a concise summary of the content. diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/search_surfsense_docs.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/search_surfsense_docs.md new file mode 100644 index 000000000..222709b38 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/search_surfsense_docs.md @@ -0,0 +1,9 @@ + +- User: "How do I install SurfSense?" + - Call: `search_surfsense_docs(query="installation setup")` +- User: "What connectors does SurfSense support?" + - Call: `search_surfsense_docs(query="available connectors integrations")` +- User: "How do I set up the Notion connector?" + - Call: `search_surfsense_docs(query="Notion connector setup configuration")` (how-to docs). Changing data inside Notion itself → **task**. +- User: "How do I use Docker to run SurfSense?" + - Call: `search_surfsense_docs(query="Docker installation setup")` diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/update_memory_private.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/update_memory_private.md new file mode 100644 index 000000000..f83fe40b4 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/update_memory_private.md @@ -0,0 +1,16 @@ + +- Alex, is empty. User: "I'm a space enthusiast, explain astrophage to me" + - The user casually shared a durable fact. Use their first name in the entry, short neutral heading: + update_memory(updated_memory="## Interests & background\n- (2025-03-15) [fact] Alex is a space enthusiast\n") +- User: "Remember that I prefer concise answers over detailed explanations" + - Durable preference. Merge with existing memory, add a new heading: + update_memory(updated_memory="## Interests & background\n- (2025-03-15) [fact] Alex is a space enthusiast\n\n## Response style\n- (2025-03-15) [pref] Alex prefers concise answers over detailed explanations\n") +- User: "I actually moved to Tokyo last month" + - Updated fact, date prefix reflects when recorded: + update_memory(updated_memory="## Interests & background\n...\n\n## Personal context\n- (2025-03-15) [fact] Alex lives in Tokyo (previously London)\n...") +- User: "I'm a freelance photographer working on a nature documentary" + - Durable background info under a fitting heading: + update_memory(updated_memory="...\n\n## Current focus\n- (2025-03-15) [fact] Alex is a freelance photographer\n- (2025-03-15) [fact] Alex is working on a nature documentary\n") +- User: "Always respond in bullet points" + - Standing instruction: + update_memory(updated_memory="...\n\n## Response style\n- (2025-03-15) [instr] Always respond to Alex in bullet points\n") diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/update_memory_team.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/update_memory_team.md new file mode 100644 index 000000000..1c74fdf6e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/update_memory_team.md @@ -0,0 +1,7 @@ + +- User: "Let's remember that we decided to do weekly standup meetings on Mondays" + - Durable team decision: + update_memory(updated_memory="- (2025-03-15) [fact] Weekly standup meetings on Mondays\n...") +- User: "Our office is in downtown Seattle, 5th floor" + - Durable team fact: + update_memory(updated_memory="- (2025-03-15) [fact] Office location: downtown Seattle, 5th floor\n...") diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/web_search.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/web_search.md new file mode 100644 index 000000000..4789a6ed9 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/web_search.md @@ -0,0 +1,8 @@ + +- User: "What's the current USD to INR exchange rate?" + - Call: `web_search(query="current USD to INR exchange rate")` + - Answer from returned snippets or scrape a top URL if needed; use markdown links to sources. +- User: "What's the latest news about AI?" + - Call: `web_search(query="latest AI news today")` +- User: "What's the weather in New York?" + - Call: `web_search(query="weather New York today")` diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/kb_only_policy_private.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/kb_only_policy_private.md new file mode 100644 index 000000000..75c3c0e5f --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/kb_only_policy_private.md @@ -0,0 +1,19 @@ + +CRITICAL RULE — KNOWLEDGE BASE FIRST, NEVER DEFAULT TO GENERAL KNOWLEDGE: +- Ground factual answers in what you actually receive this turn: injected workspace + documents (when present), **search_surfsense_docs**, **web_search**, **scrape_webpage**, + or substantive results summarized from a **task** subagent you invoked. +- Do NOT answer factual or informational questions from general knowledge unless the user + explicitly grants permission after you say you did not find enough in those sources. +- If indexed/docs search returns nothing relevant AND **web_search** / **scrape_webpage** + (and **task**, if already tried appropriately) still do not supply an answer, you MUST: + 1. Say you could not find enough in their workspace/docs/tools output. + 2. Ask: "Would you like me to answer from my general knowledge instead?" + 3. ONLY then answer from general knowledge after they clearly say yes. +- This policy does NOT apply to: + * Casual conversation, greetings, or meta-questions about SurfSense (e.g. "what can you do?") + * Formatting or analysis of content already in the chat + * Clear rewrite/edit instructions ("bullet-point this paragraph") + * Lightweight research with **web_search** / **scrape_webpage** + * Work that belongs on a specialist — use **task**; see `` + diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/kb_only_policy_team.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/kb_only_policy_team.md new file mode 100644 index 000000000..7c4aba1f8 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/kb_only_policy_team.md @@ -0,0 +1,19 @@ + +CRITICAL RULE — KNOWLEDGE BASE FIRST, NEVER DEFAULT TO GENERAL KNOWLEDGE: +- Ground factual answers in what you actually receive this turn: injected shared + workspace documents (when present), **search_surfsense_docs**, **web_search**, + **scrape_webpage**, or substantive results summarized from a **task** subagent you invoked. +- Do NOT answer factual questions from general knowledge unless a team member explicitly + grants permission after you say you did not find enough in those sources. +- If indexed/docs search returns nothing relevant AND **web_search** / **scrape_webpage** + (and **task**, if already tried appropriately) still do not supply an answer, you MUST: + 1. Say you could not find enough in shared docs/tools output. + 2. Ask: "Would you like me to answer from my general knowledge instead?" + 3. ONLY then answer from general knowledge after they clearly say yes. +- This policy does NOT apply to: + * Casual conversation, greetings, or meta-questions about SurfSense + * Formatting or analysis of content already in the chat + * Clear rewrite/edit instructions + * Lightweight research with **web_search** / **scrape_webpage** + * Work that belongs on a specialist — use **task**; see `` + diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/main_agent_tool_routing.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/main_agent_tool_routing.md new file mode 100644 index 000000000..e91075c35 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/main_agent_tool_routing.md @@ -0,0 +1,27 @@ + +Use **task** for anything beyond your direct SurfSense tools: calendar, mail, +chat, tickets, documents in third-party systems, connector-specific discovery, +deliverables (reports, podcasts, images, etc.), and other specialized routes. +The live list of specialists you may target with **task** for this workspace is in +`` (later in this prompt). + +Your **direct** SurfSense tools are only: **update_memory**, **web_search**, +**scrape_webpage**, and **search_surfsense_docs**. The runtime may also attach +deep-agent helpers (e.g. todos, filesystem, **task** itself). Use **task** whenever +the user needs capabilities **not** listed in the `` section (that section appears +later in this system prompt, after citation rules). + +Do not treat live third-party state as if it were already in the indexed knowledge +base; reach it via **task**. + +Never emit more than one **task** tool call in the same turn. Bundle related work +for the same specialist into a single **task** invocation (the subagent itself can +call its own tools in parallel inside that one run). Parallel **task** calls would +fan out into multiple concurrent subagent runs whose human-approval interrupts +cannot be coordinated; one **task** at a time is required. + + + \ No newline at end of file diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/memory_protocol_private.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/memory_protocol_private.md new file mode 100644 index 000000000..8f7da14f8 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/memory_protocol_private.md @@ -0,0 +1,6 @@ + +IMPORTANT — After understanding each user message, ALWAYS check: does this message +reveal durable facts about the user (role, interests, preferences, projects, +background, or standing instructions)? If yes, you MUST call update_memory +alongside your normal response — do not defer this to a later turn. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/memory_protocol_team.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/memory_protocol_team.md new file mode 100644 index 000000000..61d89cc5d --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/memory_protocol_team.md @@ -0,0 +1,6 @@ + +IMPORTANT — After understanding each user message, ALWAYS check: does this message +reveal durable facts about the team (decisions, conventions, architecture, processes, +or key facts)? If yes, you MUST call update_memory alongside your normal response — +do not defer this to a later turn. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/parameter_resolution.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/parameter_resolution.md new file mode 100644 index 000000000..350da6220 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/parameter_resolution.md @@ -0,0 +1,15 @@ + +You do **not** call connector-specific discovery tools yourself (accounts, channels, +Jira cloud IDs, Airtable bases, Slack channels, etc.). Those tools exist only on +**task** subagents. + +When the user needs work inside a connected product, delegate with **task** and a +clear goal. If several Slack channels, Jira projects, calendar calendars, etc. could +match and only the integration can list them, **you must not** ask the human for +internal IDs (UUIDs, cloud IDs, opaque keys). The **task** subagent uses connector +tools to list candidates and either picks the only sensible match or asks the user +to choose using **normal labels** (e.g. channel display name, project title), not raw IDs. + +If you already have plain-language choices from the user or from prior tool output, +you may pass them through to **task** without re-discovery. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/__init__.py @@ -0,0 +1 @@ + diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/anthropic.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/anthropic.md new file mode 100644 index 000000000..89154c443 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/anthropic.md @@ -0,0 +1,16 @@ + +You are running on an Anthropic Claude model (SurfSense **main agent**). + +Structured reasoning: +- For non-trivial work, `` / short `` before tool calls is fine. + +Professional objectivity: +- Accuracy over flattery; verify with **search_surfsense_docs**, **web_search**, **scrape_webpage**, or **task** when unsure — don’t invent connector access. + +Task management: +- For 3+ steps, use todo tooling; update statuses promptly. + +Tool calls: +- Parallelise independent calls; sequence only when outputs chain. +- Never pretend you can run connector-specific tools directly — route through **task** when needed. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/deepseek.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/deepseek.md new file mode 100644 index 000000000..4254e9ed5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/deepseek.md @@ -0,0 +1,18 @@ + +You are running on a DeepSeek model (SurfSense **main agent**). + +Reasoning hygiene (R1-aware): +- Keep internal scratch separate from the user-facing answer; don’t leak chain-of-thought into tool arguments. + +Output style: +- Concise; lead with the answer or the next action; avoid sycophantic openers. + +Attribution: +- When citations are **enabled** and facts come from chunk-tagged context, follow the citation block above. +- When citations are **disabled**, do not use `[citation:…]`. + +Tool calls: +- Parallelise independent calls. +- Prefer **search_surfsense_docs** for SurfSense docs/product questions before **web_search** when that fits the ask. +- Don’t invent paths, chunk ids, or URLs — only values from tools or the user. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/default.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/default.md new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/default.md @@ -0,0 +1 @@ + diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/google.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/google.md new file mode 100644 index 000000000..c72c1bc72 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/google.md @@ -0,0 +1,18 @@ + +You are running on a Google Gemini model (SurfSense **main agent**). + +Output style: +- Concise & direct. Fewer than ~3 lines of prose when the task allows (excluding tool output and code). +- No filler openers/closers — move straight to the answer or the tool call. +- GitHub-flavoured Markdown; monospace-friendly. + +Workflow (Understand → Plan → Act → Verify): +1. **Understand:** parse the ask; use **search_surfsense_docs** / injected workspace context before guessing. +2. **Plan:** for multi-step work, a short plan first. +3. **Act:** only with tools you actually have on this agent (see `` and ``). Connector work → **task**. +4. **Verify:** re-read or re-search only when it materially reduces risk. + +Discipline: +- Do not imply access to connectors, MCP tools, or deliverable generators except via **task**. +- Path arguments for filesystem tools must be exact strings from tool results — never invent paths. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/grok.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/grok.md new file mode 100644 index 000000000..3219e10d3 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/grok.md @@ -0,0 +1,16 @@ + +You are running on an xAI Grok model (SurfSense **main agent**). + +Maximum terseness: +- Fewer than 4 lines unless detail is requested; skip preamble/postamble. + +Tool discipline: +- Typically one investigative tool per turn unless several independent read-only queries are clearly needed; don’t repeat identical calls. + +Attribution: +- When citations are **enabled** (see citation block above) and you answer from chunk-tagged documents, use `[citation:chunk_id]` exactly as specified there. +- When citations are **disabled**, never emit `[citation:…]` — plain prose and links per tool guidance. + +Style: +- No emojis unless asked; flat lists for short answers. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/kimi.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/kimi.md new file mode 100644 index 000000000..3fe07d180 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/kimi.md @@ -0,0 +1,21 @@ + +You are running on a Moonshot Kimi model (Kimi-K1.5 / Kimi-K2 / Kimi-K2.5+), SurfSense **main agent**. + +Action bias: +- Default to taking action with tools rather than describing solutions in prose. If a tool can answer the question, call the tool. +- Don't narrate routine reads, searches, or obvious next steps. Combine related progress into one short status line. +- Be thorough in actions (test what you build, verify what you change). Be brief in explanations. + +Tool calls: +- Output multiple non-interfering tool calls in a SINGLE response — parallelism is a major efficiency win on this model. +- When the `task` tool is available, delegate focused subtasks to a subagent with full context (subagents don't inherit yours). +- Don't apologise or pre-announce tool calls. The tool call itself is self-explanatory. + +Language: +- Respond in the SAME language as the user's most recent turn unless explicitly instructed otherwise. + +Discipline: +- Stay on track. Never give the user more than what they asked for. +- Fact-check with tools; don’t fabricate chunk ids or connector outcomes. +- Keep it stupidly simple. Don't overcomplicate. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/openai_classic.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/openai_classic.md new file mode 100644 index 000000000..7ff3ec912 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/openai_classic.md @@ -0,0 +1,20 @@ + +You are running on a classic OpenAI chat model (GPT-4 family), SurfSense **main agent**. + +Persistence: +- Finish the user’s request in the same turn when tools allow — don’t stop at intent only. +- If a tool errors, fix arguments and retry once before giving up. + +Planning: +- For 3+ steps, use the todo / planning tool; mark `in_progress` / `completed` promptly. +- One short sentence before non-trivial tool use is fine. + +Output style: +- Conversational but professional; bullets for findings; fenced code with language tags when needed. +- Summarize tool output — don’t paste walls of text. + +Tool calls: +- Parallelise independent calls in one turn. +- Prefer **search_surfsense_docs** for SurfSense-product questions, **web_search** / **scrape_webpage** + for fresh public facts; integrations and heavy workflows → **task**. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/openai_codex.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/openai_codex.md new file mode 100644 index 000000000..aad52f995 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/openai_codex.md @@ -0,0 +1,13 @@ + +You are running on an OpenAI Codex-class model (SurfSense **main agent**). + +Output style: +- Concise; don’t paste huge fetch blobs — summarize. +- When citations are **enabled** and you rely on chunk-tagged docs, references may use `[citation:chunk_id]` per the citation block above; when **disabled**, use prose and URLs only. +- Numbered lists work well when the user should reply with a single option index. +- No emojis; single-level bullets. + +Tool calls: +- Parallelise independent calls; chain only when required. +- Don’t ask permission for obvious safe defaults — state what you did. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/openai_reasoning.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/openai_reasoning.md new file mode 100644 index 000000000..6c8a34087 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/openai_reasoning.md @@ -0,0 +1,22 @@ + +You are running on an OpenAI reasoning model (GPT-5+ / o-series), SurfSense **main agent**. + +Output style: +- Be terse and direct. Don't restate the user's request before answering. +- Don't begin with conversational openers ("Done!", "Got it", "Great question", "Sure thing"). Get to the answer or the action. +- Match response complexity to the task: simple questions → one-line answer; substantial work → lead with the outcome, then context, then any next steps. +- No nested bullets — keep lists flat (single level). For options the user can pick by replying with a number, use `1.` `2.` `3.`. +- Use inline backticks for paths/commands/identifiers; fenced code blocks (with language tags) for multi-line snippets. + +Channels (for clients that support them): +- `commentary` — short progress updates only when they add genuinely new information (a discovery, a tradeoff, a blocker, the start of a non-trivial step). Don't narrate routine reads or obvious next steps. +- `final` — the completed response. Keep it self-contained; no "see above" / "see below" cross-references. + +Tool calls: +- Parallelise independent tool calls in a single response (`multi_tool_use.parallel` where supported). Only sequence when a later call needs an earlier one's output. +- Connector or integration execution belongs in **task**, not invented main-agent tools. +- Don't ask permission ("Should I proceed?", "Do you want me to…?"). Pick the most reasonable default, do it, and state what you did. + +Autonomy: +- Persist until the task is fully resolved within the current turn whenever feasible — within tools you actually have; delegate the rest via **task**. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/__init__.py @@ -0,0 +1 @@ + diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/_preamble.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/_preamble.md new file mode 100644 index 000000000..137904545 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/_preamble.md @@ -0,0 +1,9 @@ + +You have access to the following **SurfSense** tools (main-agent scope only): + +IMPORTANT: You can ONLY use the tools listed below. Anything else — connectors, +deliverables, or multi-step integration work — goes through **task**, not as a +tool in this list. + +Do NOT claim you can use a capability if it is not listed here. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/scrape_webpage.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/scrape_webpage.md new file mode 100644 index 000000000..ecec982c1 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/scrape_webpage.md @@ -0,0 +1,10 @@ + +- scrape_webpage: Fetch and extract readable content from a single HTTP(S) URL. + - Use when the user wants the *actual page body* (article, table, dashboard snapshot), not just search snippets. + - Try the tool when a URL is given or referenced; don’t refuse without attempting unless the URL is clearly unsafe/invalid. + - Args: + - url: Page to fetch + - max_length: Cap on returned characters (default: 50000) + - Returns: Title, metadata, and markdown-ish body. + - Summarize clearly afterward; link back with `[label](url)`. + - If indexed workspace material is insufficient and the user points at a public URL, scraping is appropriate — still not a substitute for **task** on private connectors. diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/search_surfsense_docs.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/search_surfsense_docs.md new file mode 100644 index 000000000..cfa32e889 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/search_surfsense_docs.md @@ -0,0 +1,9 @@ + +- search_surfsense_docs: Search official SurfSense documentation (product help). + - Use when the user asks how SurfSense works, setup, connectors at a high level, configuration, etc. + - Not a substitute for **task** when they need actions inside Gmail/Slack/Jira/etc. + - Args: + - query: What to look up in SurfSense docs + - top_k: Number of chunks to retrieve (default: 10) + - Returns: Doc excerpts; chunk ids may appear for attribution — follow the **citation** + instructions block above when citations are enabled; otherwise summarize without `[citation:…]`. diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/update_memory_private.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/update_memory_private.md new file mode 100644 index 000000000..3ba11f179 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/update_memory_private.md @@ -0,0 +1,12 @@ + +- update_memory: Curate the **personal** long-term memory document for this user. + - Current memory (if any) appears in `` with usage vs limit. + - Call when the user asks to remember/forget, or shares durable facts/preferences/instructions. + - Use the first name from `` when writing entries — write “Alex prefers…” not “The user prefers…”. + Do not store the name alone as a memory entry. + - Skip ephemeral chat noise (one-off q/a, greetings, session logistics). + - Args: + - updated_memory: FULL replacement markdown (merge and curate — don’t only append). + - Formatting rules: + - Bullets: `- (YYYY-MM-DD) [marker] text` with markers `[fact]`, `[pref]`, `[instr]` (priority when trimming: instr > pref > fact). + - Each bullet under a short `##` heading; keep total size under the limit shown in ``. diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/update_memory_team.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/update_memory_team.md new file mode 100644 index 000000000..7eaca8818 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/update_memory_team.md @@ -0,0 +1,26 @@ + +- update_memory: Update the team's shared memory document for this search space. + - Your current team memory is already in in your context. The `chars` + and `limit` attributes show current usage and the maximum allowed size. + - This is the team's curated long-term memory — decisions, conventions, key facts. + - NEVER store personal memory in team memory (e.g. personal bio, individual + preferences, or user-only standing instructions). + - Call update_memory when: + * A team member explicitly asks to remember or forget something + * The conversation surfaces durable team decisions, conventions, or facts + that will matter in future conversations + - Do not store short-lived or ephemeral info: one-off questions, greetings, + session logistics, or things that only matter for the current task. + - Args: + - updated_memory: The FULL updated markdown document (not a diff). + Merge new facts with existing ones, update contradictions, remove outdated entries. + Treat every update as a curation pass — consolidate, don't just append. + - Every bullet MUST use this format: - (YYYY-MM-DD) [fact] text + Team memory uses ONLY the [fact] marker. Never use [pref] or [instr] in team memory. + - Keep it concise and well under the character limit shown in . + - Every entry MUST be under a `##` heading. Keep heading names short (2-3 words) and + natural. Organize by context — e.g. what the team decided, current architecture, + active processes. Create, split, or merge headings freely as the memory grows. + - Each entry MUST be a single bullet point. Be descriptive but concise — include relevant + details and context rather than just a few words. + - During consolidation, prioritize keeping: decisions/conventions > key facts > current priorities. diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/web_search.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/web_search.md new file mode 100644 index 000000000..79a3a9b12 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/web_search.md @@ -0,0 +1,10 @@ + +- web_search: Live public-web search (whatever search backends the workspace configured). + - Use for current events, prices, weather, news, or anything needing fresh public web data. + - For those queries, call this tool rather than guessing from memory or claiming you lack network access. + - If results are thin, say so and offer to refine the query. + - Args: + - query: Specific search terms + - top_k: Max hits (default: 10, max: 50) + - If snippets are too shallow, follow up with **scrape_webpage** on the best URL. + - Present sources with readable markdown links `[label](url)` — never bare URLs. diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/__init__.py new file mode 100644 index 000000000..80e86e5c8 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/__init__.py @@ -0,0 +1,10 @@ +"""Main-agent SurfSense tool allowlist.""" + +from __future__ import annotations + +from .index import ( + MAIN_AGENT_SURFSENSE_TOOL_NAMES, + MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED, +) + +__all__ = ["MAIN_AGENT_SURFSENSE_TOOL_NAMES", "MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/index.py new file mode 100644 index 000000000..5d309261c --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/index.py @@ -0,0 +1,17 @@ +"""Main-agent SurfSense builtin tool names (not full ``new_chat``). + +Connector integrations, MCP, deliverables, etc. are delegated via ``task`` subagents. +""" + +from __future__ import annotations + +MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED: tuple[str, ...] = ( + "search_surfsense_docs", + "web_search", + "scrape_webpage", + "update_memory", +) + +MAIN_AGENT_SURFSENSE_TOOL_NAMES: frozenset[str] = frozenset( + MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED, +) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/__init__.py new file mode 100644 index 000000000..e6eed9fbe --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/__init__.py @@ -0,0 +1,7 @@ +"""Multi-agent middleware stack assembly.""" + +from __future__ import annotations + +from .stack import build_main_agent_deepagent_middleware + +__all__ = ["build_main_agent_deepagent_middleware"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/action_log.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/action_log.py new file mode 100644 index 000000000..c9f893d97 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/action_log.py @@ -0,0 +1,36 @@ +"""Audit row per tool call (reversibility metadata).""" + +from __future__ import annotations + +import logging + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.middleware import ActionLogMiddleware +from app.agents.new_chat.tools.registry import BUILTIN_TOOLS + +from ..shared.flags import enabled + + +def build_action_log_mw( + *, + flags: AgentFeatureFlags, + thread_id: int | None, + search_space_id: int, + user_id: str | None, +) -> ActionLogMiddleware | None: + 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} + 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( + "ActionLogMiddleware init failed; running without it.", + exc_info=True, + ) + return None diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/anonymous_doc.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/anonymous_doc.py new file mode 100644 index 000000000..afd54a2d3 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/anonymous_doc.py @@ -0,0 +1,16 @@ +"""Anonymous document hydration from Redis (cloud only).""" + +from __future__ import annotations + +from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.agents.new_chat.middleware import AnonymousDocumentMiddleware + + +def build_anonymous_doc_mw( + *, + filesystem_mode: FilesystemMode, + anon_session_id: str | None, +) -> AnonymousDocumentMiddleware | None: + if filesystem_mode != FilesystemMode.CLOUD: + return None + return AnonymousDocumentMiddleware(anon_session_id=anon_session_id) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/busy_mutex.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/busy_mutex.py new file mode 100644 index 000000000..0ea53bf16 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/busy_mutex.py @@ -0,0 +1,12 @@ +"""Per-thread cooperative lock around the whole turn.""" + +from __future__ import annotations + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.middleware import BusyMutexMiddleware + +from ..shared.flags import enabled + + +def build_busy_mutex_mw(flags: AgentFeatureFlags) -> BusyMutexMiddleware | None: + return BusyMutexMiddleware() if enabled(flags, "enable_busy_mutex") else None diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/__init__.py new file mode 100644 index 000000000..d03b571ca --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/__init__.py @@ -0,0 +1,26 @@ +"""SubAgent ``task`` tool wiring required for HITL inside subagents. + +Replaces upstream ``SubAgentMiddleware`` to: + +- share the parent's checkpointer with each subagent, +- forward ``runtime.config`` (thread_id, recursion_limit, …) into nested invokes, +- bridge ``Command(resume=...)`` from the parent into the subagent via the + ``config["configurable"]["surfsense_resume_value"]`` side-channel, +- target the resume at the captured interrupt id so a follow-up + ``HumanInTheLoopMiddleware.after_model`` does not consume the same payload, +- re-raise any new subagent interrupt at the parent so the SSE stream surfaces it. + +Module layout +------------- + +- ``constants`` — shared keys / limits. +- ``config`` — RunnableConfig + side-channel resume read. +- ``resume`` — pending-interrupt detection, fan-out, ``Command(resume=...)`` builder. +- ``propagation`` — re-raise pending subagent interrupts at the parent. +- ``task_tool`` — the ``task`` tool factory (sync + async). +- ``middleware`` — :class:`SurfSenseCheckpointedSubAgentMiddleware` itself. +""" + +from .middleware import SurfSenseCheckpointedSubAgentMiddleware + +__all__ = ["SurfSenseCheckpointedSubAgentMiddleware"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/config.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/config.py new file mode 100644 index 000000000..16211686c --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/config.py @@ -0,0 +1,44 @@ +"""RunnableConfig wiring for nested subagent invocations. + +Forwards the parent's ``runtime.config`` (thread_id, …) into the subagent and +exposes the side-channel ``stream_resume_chat`` uses to ferry resume payloads. +""" + +from __future__ import annotations + +from typing import Any + +from langchain.tools import ToolRuntime + +from .constants import DEFAULT_SUBAGENT_RECURSION_LIMIT + + +def subagent_invoke_config(runtime: ToolRuntime) -> dict[str, Any]: + """RunnableConfig for the nested invoke; raises ``recursion_limit`` to the parent's budget.""" + merged: dict[str, Any] = dict(runtime.config) if runtime.config else {} + current_limit = merged.get("recursion_limit") + try: + current_int = int(current_limit) if current_limit is not None else 0 + except (TypeError, ValueError): + current_int = 0 + if current_int < DEFAULT_SUBAGENT_RECURSION_LIMIT: + merged["recursion_limit"] = DEFAULT_SUBAGENT_RECURSION_LIMIT + return merged + + +def consume_surfsense_resume(runtime: ToolRuntime) -> Any: + """Pop the resume payload; siblings share ``configurable`` by reference.""" + cfg = runtime.config or {} + configurable = cfg.get("configurable") if isinstance(cfg, dict) else None + if not isinstance(configurable, dict): + return None + return configurable.pop("surfsense_resume_value", None) + + +def has_surfsense_resume(runtime: ToolRuntime) -> bool: + """True iff a resume payload is queued on this runtime (non-destructive).""" + cfg = runtime.config or {} + configurable = cfg.get("configurable") if isinstance(cfg, dict) else None + if not isinstance(configurable, dict): + return False + return "surfsense_resume_value" in configurable diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/constants.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/constants.py new file mode 100644 index 000000000..6c4519f3a --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/constants.py @@ -0,0 +1,18 @@ +"""Constants shared by the checkpointed subagent middleware.""" + +from __future__ import annotations + +# Mirror of deepagents.middleware.subagents._EXCLUDED_STATE_KEYS. +EXCLUDED_STATE_KEYS = frozenset( + { + "messages", + "todos", + "structured_response", + "skills_metadata", + "memory_contents", + } +) + +# Match the parent graph's budget; the LangGraph default of 25 trips on +# multi-step subagent runs. +DEFAULT_SUBAGENT_RECURSION_LIMIT = 10_000 diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/middleware.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/middleware.py new file mode 100644 index 000000000..da8a62cdc --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/middleware.py @@ -0,0 +1,103 @@ +"""SubAgent middleware that compiles each subagent against the parent checkpointer.""" + +from __future__ import annotations + +from typing import Any, cast + +from deepagents.backends.protocol import BackendFactory, BackendProtocol +from deepagents.middleware.subagents import ( + TASK_SYSTEM_PROMPT, + CompiledSubAgent, + SubAgent, + SubAgentMiddleware, +) +from langchain.agents import create_agent +from langchain.agents.middleware import HumanInTheLoopMiddleware +from langchain.chat_models import init_chat_model +from langgraph.types import Checkpointer + +from .task_tool import build_task_tool_with_parent_config + + +class SurfSenseCheckpointedSubAgentMiddleware(SubAgentMiddleware): + """``SubAgentMiddleware`` variant that compiles each subagent against the parent checkpointer.""" + + def __init__( + self, + *, + checkpointer: Checkpointer, + backend: BackendProtocol | BackendFactory, + subagents: list[SubAgent | CompiledSubAgent], + system_prompt: str | None = TASK_SYSTEM_PROMPT, + task_description: str | None = None, + ) -> None: + self._surf_checkpointer = checkpointer + super(SubAgentMiddleware, self).__init__() + if not subagents: + raise ValueError( + "At least one subagent must be specified when using the new API" + ) + self._backend = backend + self._subagents = subagents + subagent_specs = self._surf_compile_subagent_graphs() + task_tool = build_task_tool_with_parent_config(subagent_specs, task_description) + if system_prompt and subagent_specs: + agents_desc = "\n".join( + f"- {s['name']}: {s['description']}" for s in subagent_specs + ) + self.system_prompt = ( + system_prompt + "\n\nAvailable subagent types:\n" + agents_desc + ) + else: + self.system_prompt = system_prompt + self.tools = [task_tool] + + def _surf_compile_subagent_graphs(self) -> list[dict[str, Any]]: + """Mirror of ``SubAgentMiddleware._get_subagents`` that threads the parent checkpointer.""" + specs: list[dict[str, Any]] = [] + + for spec in self._subagents: + if "runnable" in spec: + compiled = cast(CompiledSubAgent, spec) + specs.append( + { + "name": compiled["name"], + "description": compiled["description"], + "runnable": compiled["runnable"], + } + ) + continue + + if "model" not in spec: + msg = f"SubAgent '{spec['name']}' must specify 'model'" + raise ValueError(msg) + if "tools" not in spec: + msg = f"SubAgent '{spec['name']}' must specify 'tools'" + raise ValueError(msg) + + model = spec["model"] + if isinstance(model, str): + model = init_chat_model(model) + + middleware: list[Any] = list(spec.get("middleware", [])) + + interrupt_on = spec.get("interrupt_on") + if interrupt_on: + middleware.append(HumanInTheLoopMiddleware(interrupt_on=interrupt_on)) + + specs.append( + { + "name": spec["name"], + "description": spec["description"], + "runnable": create_agent( + model, + system_prompt=spec["system_prompt"], + tools=spec["tools"], + middleware=middleware, + name=spec["name"], + checkpointer=self._surf_checkpointer, + ), + } + ) + + return specs diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/propagation.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/propagation.py new file mode 100644 index 000000000..55aae7201 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/propagation.py @@ -0,0 +1,74 @@ +"""Re-raise still-pending subagent interrupts at the parent graph level. + +After ``subagent.[a]invoke(Command(resume=...))`` returns, the subagent may +still hold a pending interrupt (e.g. the LLM produced a follow-up tool call +that fired a fresh ``interrupt()``). The parent's pregel cannot see that +interrupt because it lives in a separate compiled graph; we re-raise it here +so the parent's SSE stream surfaces it as the next approval card. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from langchain_core.runnables import Runnable +from langgraph.types import interrupt as _lg_interrupt + +from .resume import get_first_pending_subagent_interrupt + +logger = logging.getLogger(__name__) + + +def maybe_propagate_subagent_interrupt( + subagent: Runnable, + sub_config: dict[str, Any], + subagent_type: str, +) -> None: + """Re-raise a still-pending subagent interrupt at the parent so the SSE stream surfaces it.""" + get_state_sync = getattr(subagent, "get_state", None) + if not callable(get_state_sync): + return + try: + snapshot = get_state_sync(sub_config) + except Exception: # pragma: no cover - defensive + logger.debug( + "Subagent get_state failed during re-interrupt check", + exc_info=True, + ) + return + _pending_id, pending_value = get_first_pending_subagent_interrupt(snapshot) + if pending_value is None: + return + logger.info( + "Re-raising subagent %r interrupt to parent (multi-step HITL)", + subagent_type, + ) + _lg_interrupt(pending_value) + + +async def amaybe_propagate_subagent_interrupt( + subagent: Runnable, + sub_config: dict[str, Any], + subagent_type: str, +) -> None: + """Async counterpart of :func:`maybe_propagate_subagent_interrupt`.""" + aget_state = getattr(subagent, "aget_state", None) + if not callable(aget_state): + return + try: + snapshot = await aget_state(sub_config) + except Exception: # pragma: no cover - defensive + logger.debug( + "Subagent aget_state failed during re-interrupt check", + exc_info=True, + ) + return + _pending_id, pending_value = get_first_pending_subagent_interrupt(snapshot) + if pending_value is None: + return + logger.info( + "Re-raising subagent %r interrupt to parent (multi-step HITL)", + subagent_type, + ) + _lg_interrupt(pending_value) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/resume.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/resume.py new file mode 100644 index 000000000..0bb477b6b --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/resume.py @@ -0,0 +1,76 @@ +"""Resume-payload shaping and pending-interrupt detection for subagents. + +Splits the work of "given a state snapshot and a parent-stashed resume value, +produce the right ``Command(resume=...)`` for the subagent" into pure helpers. +""" + +from __future__ import annotations + +from typing import Any + +from langgraph.types import Command + + +def hitlrequest_action_count(pending_value: Any) -> int: + """Bundle size for a LangChain ``HITLRequest`` payload; ``0`` for non-bundle interrupts.""" + if not isinstance(pending_value, dict): + return 0 + actions = pending_value.get("action_requests") + if isinstance(actions, list): + return len(actions) + return 0 + + +def fan_out_decisions_to_match(resume_value: Any, expected_count: int) -> Any: + """Legacy fallback: pad a 1-decision resume to N for an ``action_requests=N`` bundle. + + Modern frontend submits N decisions per bundle (one per action_request) so + this is a no-op; kept for backwards compatibility with old in-flight + threads or non-bundle clients that send a single decision. + """ + if expected_count <= 1: + return resume_value + if not isinstance(resume_value, dict): + return resume_value + decisions = resume_value.get("decisions") + if not isinstance(decisions, list) or len(decisions) >= expected_count: + return resume_value + if not decisions: + return resume_value + padded = list(decisions) + [decisions[-1]] * (expected_count - len(decisions)) + return {**resume_value, "decisions": padded} + + +def get_first_pending_subagent_interrupt(state: Any) -> tuple[str | None, Any]: + """First pending ``(interrupt_id, value)``; ``(None, None)`` if no interrupt. + + Assumes at most one pending interrupt per snapshot (sequential tool nodes). + Parallel tool nodes would need an id-aware lookup instead of first-wins. + """ + if state is None: + return None, None + for it in getattr(state, "interrupts", None) or (): + value = getattr(it, "value", None) + interrupt_id = getattr(it, "id", None) + if value is not None: + return ( + interrupt_id if isinstance(interrupt_id, str) else None, + value, + ) + for sub_task in getattr(state, "tasks", None) or (): + for it in getattr(sub_task, "interrupts", None) or (): + value = getattr(it, "value", None) + interrupt_id = getattr(it, "id", None) + if value is not None: + return ( + interrupt_id if isinstance(interrupt_id, str) else None, + value, + ) + return None, None + + +def build_resume_command(resume_value: Any, pending_id: str | None) -> Command: + """``Command(resume={id: value})`` when ``id`` is known, else fall back to scalar.""" + if pending_id is None: + return Command(resume=resume_value) + return Command(resume={pending_id: resume_value}) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/task_tool.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/task_tool.py new file mode 100644 index 000000000..5668f8ddb --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/task_tool.py @@ -0,0 +1,238 @@ +"""Build the ``task`` tool that invokes subagents with HITL bridging. + +The tool's body is the only place where the parent and the subagent meet at +runtime: it reads the parent's stashed resume value, decides whether to send +fresh state or a targeted ``Command(resume=...)`` to the subagent, then +re-raises any new pending interrupt back to the parent. +""" + +from __future__ import annotations + +import logging +from typing import Annotated, Any + +from deepagents.middleware.subagents import TASK_TOOL_DESCRIPTION +from langchain.tools import BaseTool, ToolRuntime +from langchain_core.messages import HumanMessage, ToolMessage +from langchain_core.runnables import Runnable +from langchain_core.tools import StructuredTool +from langgraph.types import Command + +from .config import ( + consume_surfsense_resume, + has_surfsense_resume, + subagent_invoke_config, +) +from .constants import EXCLUDED_STATE_KEYS +from .propagation import ( + amaybe_propagate_subagent_interrupt, + maybe_propagate_subagent_interrupt, +) +from .resume import ( + build_resume_command, + fan_out_decisions_to_match, + get_first_pending_subagent_interrupt, + hitlrequest_action_count, +) + +logger = logging.getLogger(__name__) + + +def build_task_tool_with_parent_config( + subagents: list[dict[str, Any]], + task_description: str | None = None, +) -> BaseTool: + """Upstream ``_build_task_tool`` + parent ``runtime.config`` propagation + resume bridging.""" + subagent_graphs: dict[str, Runnable] = { + spec["name"]: spec["runnable"] for spec in subagents + } + subagent_description_str = "\n".join( + f"- {s['name']}: {s['description']}" for s in subagents + ) + + if task_description is None: + description = TASK_TOOL_DESCRIPTION.format( + available_agents=subagent_description_str + ) + elif "{available_agents}" in task_description: + description = task_description.format(available_agents=subagent_description_str) + else: + description = task_description + + def _return_command_with_state_update(result: dict, tool_call_id: str) -> Command: + if "messages" not in result: + msg = ( + "CompiledSubAgent must return a state containing a 'messages' key. " + "Custom StateGraphs used with CompiledSubAgent should include 'messages' " + "in their state schema to communicate results back to the main agent." + ) + raise ValueError(msg) + + state_update = {k: v for k, v in result.items() if k not in EXCLUDED_STATE_KEYS} + messages = result["messages"] + if not messages: + msg = ( + "CompiledSubAgent returned an empty 'messages' list. " + "Subagents must produce at least one message so the parent has " + "output to forward back to the user." + ) + raise ValueError(msg) + last_text = getattr(messages[-1], "text", None) or "" + message_text = last_text.rstrip() + return Command( + update={ + **state_update, + "messages": [ToolMessage(message_text, tool_call_id=tool_call_id)], + } + ) + + def _validate_and_prepare_state( + subagent_type: str, description: str, runtime: ToolRuntime + ) -> tuple[Runnable, dict]: + subagent = subagent_graphs[subagent_type] + subagent_state = { + k: v for k, v in runtime.state.items() if k not in EXCLUDED_STATE_KEYS + } + subagent_state["messages"] = [HumanMessage(content=description)] + return subagent, subagent_state + + def task( + description: Annotated[ + str, + "A detailed description of the task for the subagent to perform autonomously. Include all necessary context and specify the expected output format.", + ], + subagent_type: Annotated[ + str, + "The type of subagent to use. Must be one of the available agent types listed in the tool description.", + ], + runtime: ToolRuntime, + ) -> str | Command: + if subagent_type not in subagent_graphs: + allowed_types = ", ".join([f"`{k}`" for k in subagent_graphs]) + return ( + f"We cannot invoke subagent {subagent_type} because it does not exist, " + f"the only allowed types are {allowed_types}" + ) + if not runtime.tool_call_id: + raise ValueError("Tool call ID is required for subagent invocation") + subagent, subagent_state = _validate_and_prepare_state( + subagent_type, description, runtime + ) + sub_config = subagent_invoke_config(runtime) + + # Resume bridge: forward the parent's stashed decision into the + # subagent's pending ``interrupt()``, targeted by id. + pending_id: str | None = None + pending_value: Any = None + get_state = getattr(subagent, "get_state", None) + if callable(get_state): + try: + snapshot = get_state(sub_config) + pending_id, pending_value = get_first_pending_subagent_interrupt( + snapshot + ) + except Exception: + # Fail loud if a resume is queued: silent fallback would + # replay the original interrupt to the user. + if has_surfsense_resume(runtime): + logger.exception( + "Subagent %r get_state raised with resume queued; re-raising.", + subagent_type, + ) + raise + logger.debug( + "Subagent get_state failed; falling back to fresh invoke", + exc_info=True, + ) + + if pending_value is not None: + resume_value = consume_surfsense_resume(runtime) + if resume_value is None: + # Bridge invariant: a queued resume must accompany any pending + # subagent interrupt. Fall-through replay would silently re-prompt + # the user; raise so the streaming layer surfaces a clear error. + raise RuntimeError( + f"Subagent {subagent_type!r} has a pending interrupt but no " + "surfsense_resume_value on config; resume bridge is broken." + ) + expected = hitlrequest_action_count(pending_value) + resume_value = fan_out_decisions_to_match(resume_value, expected) + result = subagent.invoke( + build_resume_command(resume_value, pending_id), + config=sub_config, + ) + else: + result = subagent.invoke(subagent_state, config=sub_config) + maybe_propagate_subagent_interrupt(subagent, sub_config, subagent_type) + return _return_command_with_state_update(result, runtime.tool_call_id) + + async def atask( + description: Annotated[ + str, + "A detailed description of the task for the subagent to perform autonomously. Include all necessary context and specify the expected output format.", + ], + subagent_type: Annotated[ + str, + "The type of subagent to use. Must be one of the available agent types listed in the tool description.", + ], + runtime: ToolRuntime, + ) -> str | Command: + if subagent_type not in subagent_graphs: + allowed_types = ", ".join([f"`{k}`" for k in subagent_graphs]) + return ( + f"We cannot invoke subagent {subagent_type} because it does not exist, " + f"the only allowed types are {allowed_types}" + ) + if not runtime.tool_call_id: + raise ValueError("Tool call ID is required for subagent invocation") + subagent, subagent_state = _validate_and_prepare_state( + subagent_type, description, runtime + ) + sub_config = subagent_invoke_config(runtime) + + # Resume bridge — see ``task`` above. + pending_id: str | None = None + pending_value: Any = None + aget_state = getattr(subagent, "aget_state", None) + if callable(aget_state): + try: + snapshot = await aget_state(sub_config) + pending_id, pending_value = get_first_pending_subagent_interrupt( + snapshot + ) + except Exception: + if has_surfsense_resume(runtime): + logger.exception( + "Subagent %r aget_state raised with resume queued; re-raising.", + subagent_type, + ) + raise + logger.debug( + "Subagent aget_state failed; falling back to fresh ainvoke", + exc_info=True, + ) + + if pending_value is not None: + resume_value = consume_surfsense_resume(runtime) + if resume_value is None: + raise RuntimeError( + f"Subagent {subagent_type!r} has a pending interrupt but no " + "surfsense_resume_value on config; resume bridge is broken." + ) + expected = hitlrequest_action_count(pending_value) + resume_value = fan_out_decisions_to_match(resume_value, expected) + result = await subagent.ainvoke( + build_resume_command(resume_value, pending_id), + config=sub_config, + ) + else: + result = await subagent.ainvoke(subagent_state, config=sub_config) + await amaybe_propagate_subagent_interrupt(subagent, sub_config, subagent_type) + return _return_command_with_state_update(result, runtime.tool_call_id) + + return StructuredTool.from_function( + name="task", + func=task, + coroutine=atask, + description=description, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/context_editing.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/context_editing.py new file mode 100644 index 000000000..e8f99933e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/context_editing.py @@ -0,0 +1,50 @@ +"""Spill + clear-tool-uses passes to keep payloads under budget.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.main_agent.context_prune.prune_tool_names import ( + safe_exclude_tools, +) +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.middleware import ( + ClearToolUsesEdit, + SpillingContextEditingMiddleware, + SpillToBackendEdit, +) + +from ..shared.flags import enabled + + +def build_context_editing_mw( + *, + flags: AgentFeatureFlags, + max_input_tokens: int | None, + tools: Sequence[BaseTool], + backend_resolver: Any, +) -> SpillingContextEditingMiddleware | None: + if not enabled(flags, "enable_context_editing") or not max_input_tokens: + return None + spill_edit = SpillToBackendEdit( + trigger=int(max_input_tokens * 0.55), + clear_at_least=int(max_input_tokens * 0.15), + keep=5, + exclude_tools=safe_exclude_tools(tools), + clear_tool_inputs=True, + ) + clear_edit = ClearToolUsesEdit( + trigger=int(max_input_tokens * 0.55), + clear_at_least=int(max_input_tokens * 0.15), + keep=5, + exclude_tools=safe_exclude_tools(tools), + clear_tool_inputs=True, + placeholder="[cleared - older tool output trimmed for context]", + ) + return SpillingContextEditingMiddleware( + edits=[spill_edit, clear_edit], + backend_resolver=backend_resolver, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/dedup_hitl.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/dedup_hitl.py new file mode 100644 index 000000000..66cae300b --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/dedup_hitl.py @@ -0,0 +1,13 @@ +"""Drop duplicate HITL tool calls before execution.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from langchain_core.tools import BaseTool + +from app.agents.new_chat.middleware import DedupHITLToolCallsMiddleware + + +def build_dedup_hitl_mw(tools: Sequence[BaseTool]) -> DedupHITLToolCallsMiddleware: + return DedupHITLToolCallsMiddleware(agent_tools=list(tools)) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/doom_loop.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/doom_loop.py new file mode 100644 index 000000000..d67b8d518 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/doom_loop.py @@ -0,0 +1,14 @@ +"""Stop N identical tool calls in a row via interrupt.""" + +from __future__ import annotations + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.middleware import DoomLoopMiddleware + +from ..shared.flags import enabled + + +def build_doom_loop_mw(flags: AgentFeatureFlags) -> DoomLoopMiddleware | None: + return ( + DoomLoopMiddleware(threshold=3) if enabled(flags, "enable_doom_loop") else None + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/kb_persistence.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/kb_persistence.py new file mode 100644 index 000000000..4b27581e7 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/kb_persistence.py @@ -0,0 +1,23 @@ +"""Commit staged cloud filesystem mutations to Postgres at end of turn.""" + +from __future__ import annotations + +from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.agents.new_chat.middleware import KnowledgeBasePersistenceMiddleware + + +def build_kb_persistence_mw( + *, + filesystem_mode: FilesystemMode, + search_space_id: int, + user_id: str | None, + thread_id: int | None, +) -> KnowledgeBasePersistenceMiddleware | None: + if filesystem_mode != FilesystemMode.CLOUD: + return None + return KnowledgeBasePersistenceMiddleware( + search_space_id=search_space_id, + created_by_id=user_id, + filesystem_mode=filesystem_mode, + thread_id=thread_id, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/knowledge_priority.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/knowledge_priority.py new file mode 100644 index 000000000..395d2a7af --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/knowledge_priority.py @@ -0,0 +1,27 @@ +"""KB priority planner: injection.""" + +from __future__ import annotations + +from langchain_core.language_models import BaseChatModel + +from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.agents.new_chat.middleware import KnowledgePriorityMiddleware + + +def build_knowledge_priority_mw( + *, + llm: BaseChatModel, + search_space_id: int, + filesystem_mode: FilesystemMode, + available_connectors: list[str] | None, + available_document_types: list[str] | None, + mentioned_document_ids: list[int] | None, +) -> KnowledgePriorityMiddleware: + return KnowledgePriorityMiddleware( + llm=llm, + search_space_id=search_space_id, + filesystem_mode=filesystem_mode, + available_connectors=available_connectors, + available_document_types=available_document_types, + mentioned_document_ids=mentioned_document_ids, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/knowledge_tree.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/knowledge_tree.py new file mode 100644 index 000000000..404082401 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/knowledge_tree.py @@ -0,0 +1,23 @@ +""" injection (cloud only).""" + +from __future__ import annotations + +from langchain_core.language_models import BaseChatModel + +from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.agents.new_chat.middleware import KnowledgeTreeMiddleware + + +def build_knowledge_tree_mw( + *, + filesystem_mode: FilesystemMode, + search_space_id: int, + llm: BaseChatModel, +) -> KnowledgeTreeMiddleware | None: + if filesystem_mode != FilesystemMode.CLOUD: + return None + return KnowledgeTreeMiddleware( + search_space_id=search_space_id, + filesystem_mode=filesystem_mode, + llm=llm, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/noop_injection.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/noop_injection.py new file mode 100644 index 000000000..6e6467ad0 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/noop_injection.py @@ -0,0 +1,12 @@ +"""Provider-compat: append a `_noop` tool when tools=[] but history has tool calls.""" + +from __future__ import annotations + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.middleware import NoopInjectionMiddleware + +from ..shared.flags import enabled + + +def build_noop_injection_mw(flags: AgentFeatureFlags) -> NoopInjectionMiddleware | None: + return NoopInjectionMiddleware() if enabled(flags, "enable_compaction_v2") else None diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/otel.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/otel.py new file mode 100644 index 000000000..bd7516e65 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/otel.py @@ -0,0 +1,12 @@ +"""OTel spans on model and tool calls.""" + +from __future__ import annotations + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.middleware import OtelSpanMiddleware + +from ..shared.flags import enabled + + +def build_otel_mw(flags: AgentFeatureFlags) -> OtelSpanMiddleware | None: + return OtelSpanMiddleware() if enabled(flags, "enable_otel") else None diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/plugins.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/plugins.py new file mode 100644 index 000000000..4418e3806 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/plugins.py @@ -0,0 +1,49 @@ +"""Tail-of-stack plugin slot driven by env allowlist.""" + +from __future__ import annotations + +import logging +from typing import Any + +from langchain_core.language_models import BaseChatModel + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.plugin_loader import ( + PluginContext, + load_allowed_plugin_names_from_env, + load_plugin_middlewares, +) +from app.db import ChatVisibility + +from ..shared.flags import enabled + + +def build_plugin_middlewares( + *, + flags: AgentFeatureFlags, + search_space_id: int, + user_id: str | None, + visibility: ChatVisibility, + llm: BaseChatModel, +) -> list[Any]: + if not enabled(flags, "enable_plugin_loader"): + return [] + try: + allowed_names = load_allowed_plugin_names_from_env() + if not allowed_names: + return [] + return load_plugin_middlewares( + PluginContext.build( + search_space_id=search_space_id, + user_id=user_id, + thread_visibility=visibility, + llm=llm, + ), + allowed_plugin_names=allowed_names, + ) + except Exception: # pragma: no cover - defensive + logging.warning( + "Plugin loader failed; continuing without plugins.", + exc_info=True, + ) + return [] diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/repair.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/repair.py new file mode 100644 index 000000000..378b61be1 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/repair.py @@ -0,0 +1,50 @@ +"""Repair miscased / unknown tool names to the registered set or invalid_tool.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from langchain_core.tools import BaseTool + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.middleware import ToolCallNameRepairMiddleware + +from ..shared.flags import enabled + +# deepagents-built-in tool names the repair pass treats as known. +_DEEPAGENT_BUILTIN_TOOL_NAMES: frozenset[str] = frozenset( + { + "write_todos", + "ls", + "read_file", + "write_file", + "edit_file", + "glob", + "grep", + "execute", + "task", + "mkdir", + "cd", + "pwd", + "move_file", + "rm", + "rmdir", + "list_tree", + "execute_code", + } +) + + +def build_repair_mw( + *, + flags: AgentFeatureFlags, + tools: Sequence[BaseTool], +) -> ToolCallNameRepairMiddleware | None: + if not enabled(flags, "enable_tool_call_repair"): + return None + registered_names: set[str] = {t.name for t in tools} + registered_names |= _DEEPAGENT_BUILTIN_TOOL_NAMES + return ToolCallNameRepairMiddleware( + registered_tool_names=registered_names, + fuzzy_match_threshold=None, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/selector.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/selector.py new file mode 100644 index 000000000..8e7a32be8 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/selector.py @@ -0,0 +1,39 @@ +"""LLM-based tool subset selection (only when >30 tools).""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence + +from langchain.agents.middleware import LLMToolSelectorMiddleware +from langchain_core.tools import BaseTool + +from app.agents.new_chat.feature_flags import AgentFeatureFlags + +from ..shared.flags import enabled + + +def build_selector_mw( + *, + flags: AgentFeatureFlags, + tools: Sequence[BaseTool], +) -> LLMToolSelectorMiddleware | None: + if not enabled(flags, "enable_llm_tool_selector") or len(tools) <= 30: + return None + try: + return LLMToolSelectorMiddleware( + model="openai:gpt-4o-mini", + max_tools=12, + always_include=[ + name + for name in ( + "update_memory", + "get_connected_accounts", + "scrape_webpage", + ) + if name in {t.name for t in tools} + ], + ) + except Exception: + logging.warning("LLMToolSelectorMiddleware init failed; skipping.") + return None diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/skills.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/skills.py new file mode 100644 index 000000000..63a57c5a0 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/skills.py @@ -0,0 +1,39 @@ +"""Skill discovery + injection.""" + +from __future__ import annotations + +import logging + +from deepagents.middleware.skills import SkillsMiddleware + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.agents.new_chat.middleware import ( + build_skills_backend_factory, + default_skills_sources, +) + +from ..shared.flags import enabled + + +def build_skills_mw( + *, + flags: AgentFeatureFlags, + filesystem_mode: FilesystemMode, + search_space_id: int, +) -> SkillsMiddleware | None: + if not enabled(flags, "enable_skills"): + return None + try: + skills_factory = build_skills_backend_factory( + search_space_id=search_space_id + if filesystem_mode == FilesystemMode.CLOUD + else None, + ) + return SkillsMiddleware( + backend=skills_factory, + sources=default_skills_sources(), + ) + except Exception as exc: # pragma: no cover - defensive + logging.warning("SkillsMiddleware init failed; skipping: %s", exc) + return None diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/anthropic_cache.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/anthropic_cache.py new file mode 100644 index 000000000..f99fb9c7f --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/anthropic_cache.py @@ -0,0 +1,9 @@ +"""Anthropic prompt caching annotations on system/tool/message blocks.""" + +from __future__ import annotations + +from langchain_anthropic.middleware import AnthropicPromptCachingMiddleware + + +def build_anthropic_cache_mw() -> AnthropicPromptCachingMiddleware: + return AnthropicPromptCachingMiddleware(unsupported_model_behavior="ignore") diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/compaction.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/compaction.py new file mode 100644 index 000000000..b59e7d2c4 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/compaction.py @@ -0,0 +1,14 @@ +"""Context-window summarization with SurfSense protected sections.""" + +from __future__ import annotations + +from typing import Any + +from deepagents.backends import StateBackend +from langchain_core.language_models import BaseChatModel + +from app.agents.new_chat.middleware import create_surfsense_compaction_middleware + + +def build_compaction_mw(llm: BaseChatModel) -> Any: + return create_surfsense_compaction_middleware(llm, StateBackend) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/file_intent.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/file_intent.py new file mode 100644 index 000000000..5ff65aa12 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/file_intent.py @@ -0,0 +1,11 @@ +"""File-intent classifier that gates strict write contracts.""" + +from __future__ import annotations + +from langchain_core.language_models import BaseChatModel + +from app.agents.new_chat.middleware import FileIntentMiddleware + + +def build_file_intent_mw(llm: BaseChatModel) -> FileIntentMiddleware: + return FileIntentMiddleware(llm=llm) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem.py new file mode 100644 index 000000000..9481f5167 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem.py @@ -0,0 +1,25 @@ +"""SurfSense filesystem tools/middleware.""" + +from __future__ import annotations + +from typing import Any + +from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.agents.new_chat.middleware import SurfSenseFilesystemMiddleware + + +def build_filesystem_mw( + *, + backend_resolver: Any, + filesystem_mode: FilesystemMode, + search_space_id: int, + user_id: str | None, + thread_id: int | None, +) -> SurfSenseFilesystemMiddleware: + return SurfSenseFilesystemMiddleware( + backend=backend_resolver, + filesystem_mode=filesystem_mode, + search_space_id=search_space_id, + created_by_id=user_id, + thread_id=thread_id, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/flags.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/flags.py new file mode 100644 index 000000000..69994ae00 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/flags.py @@ -0,0 +1,10 @@ +"""Single source of truth for the feature-flag predicate.""" + +from __future__ import annotations + +from app.agents.new_chat.feature_flags import AgentFeatureFlags + + +def enabled(flags: AgentFeatureFlags, attr: str) -> bool: + """``flags.`` is on AND the new-agent-stack kill switch is off.""" + return getattr(flags, attr) and not flags.disable_new_agent_stack diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/memory.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/memory.py new file mode 100644 index 000000000..9316b3e21 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/memory.py @@ -0,0 +1,19 @@ +"""User/team memory injection prepended to the conversation.""" + +from __future__ import annotations + +from app.agents.new_chat.middleware import MemoryInjectionMiddleware +from app.db import ChatVisibility + + +def build_memory_mw( + *, + user_id: str | None, + search_space_id: int, + visibility: ChatVisibility, +) -> MemoryInjectionMiddleware: + return MemoryInjectionMiddleware( + user_id=user_id, + search_space_id=search_space_id, + thread_visibility=visibility, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/patch_tool_calls.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/patch_tool_calls.py new file mode 100644 index 000000000..50036dbbe --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/patch_tool_calls.py @@ -0,0 +1,9 @@ +"""Repair dangling tool-call sequences before each agent turn.""" + +from __future__ import annotations + +from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware + + +def build_patch_tool_calls_mw() -> PatchToolCallsMiddleware: + return PatchToolCallsMiddleware() diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/__init__.py new file mode 100644 index 000000000..4f2228170 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/__init__.py @@ -0,0 +1,12 @@ +"""Permission rulesets fanned out to parent / general-purpose / subagent stacks.""" + +from __future__ import annotations + +from .context import PermissionContext, build_permission_context +from .middleware import build_full_permission_mw + +__all__ = [ + "PermissionContext", + "build_full_permission_mw", + "build_permission_context", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/context.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/context.py new file mode 100644 index 000000000..e121421a0 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/context.py @@ -0,0 +1,107 @@ +"""Derive shared permission context once; fan out to all three stack layers. + +The context carries: +- ``rulesets``: full ask/deny/allow rules for the main-agent permission middleware. +- ``general_purpose_interrupt_on``: ``ask`` rules mirrored as deepagents + ``interrupt_on`` so HITL still triggers from inside ``task`` runs (subagents + bypass the main-agent permission middleware). +- ``subagent_deny_mw``: a deny-only ``PermissionMiddleware`` instance shared + across the general-purpose and registry subagent stacks. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +from langchain_core.tools import BaseTool + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.agents.new_chat.middleware import PermissionMiddleware +from app.agents.new_chat.permissions import Rule, Ruleset +from app.agents.new_chat.tools.registry import BUILTIN_TOOLS + +from ..flags import enabled + + +@dataclass(frozen=True) +class PermissionContext: + rulesets: list[Ruleset] + general_purpose_interrupt_on: dict[str, bool] + subagent_deny_mw: PermissionMiddleware | None + + +def build_permission_context( + *, + flags: AgentFeatureFlags, + filesystem_mode: FilesystemMode, + tools: Sequence[BaseTool], + available_connectors: list[str] | None, +) -> PermissionContext: + is_desktop_fs = filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER + permission_enabled = enabled(flags, "enable_permission") + + rulesets: list[Ruleset] = [] + if permission_enabled or is_desktop_fs: + rulesets.append( + Ruleset( + rules=[Rule(permission="*", pattern="*", action="allow")], + origin="surfsense_defaults", + ) + ) + if is_desktop_fs: + rulesets.append( + Ruleset( + rules=[ + Rule(permission="rm", pattern="*", action="ask"), + Rule(permission="rmdir", pattern="*", action="ask"), + Rule(permission="move_file", pattern="*", action="ask"), + Rule(permission="edit_file", pattern="*", action="ask"), + Rule(permission="write_file", pattern="*", action="ask"), + ], + origin="desktop_safety", + ) + ) + + tool_names_in_use = {t.name for t in tools} + + if permission_enabled: + available_set = set(available_connectors or []) + synthesized: list[Rule] = [] + for tool_def in BUILTIN_TOOLS: + if tool_def.name not in tool_names_in_use: + continue + rc = tool_def.required_connector + if rc and rc not in available_set: + synthesized.append( + Rule(permission=tool_def.name, pattern="*", action="deny") + ) + if synthesized: + rulesets.append(Ruleset(rules=synthesized, origin="connector_synthesized")) + + general_purpose_interrupt_on: dict[str, bool] = { + rule.permission: True + for rs in rulesets + for rule in rs.rules + if rule.action == "ask" and rule.permission in tool_names_in_use + } + + deny_rulesets = [ + Ruleset( + rules=[r for r in rs.rules if r.action == "deny"], + origin=rs.origin, + ) + for rs in rulesets + ] + deny_rulesets = [rs for rs in deny_rulesets if rs.rules] + + subagent_deny_mw: PermissionMiddleware | None = ( + PermissionMiddleware(rulesets=deny_rulesets) if deny_rulesets else None + ) + + return PermissionContext( + rulesets=rulesets, + general_purpose_interrupt_on=general_purpose_interrupt_on, + subagent_deny_mw=subagent_deny_mw, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware.py new file mode 100644 index 000000000..704a26fb3 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware.py @@ -0,0 +1,10 @@ +"""Main-agent permission middleware (full ask/deny/allow rules).""" + +from __future__ import annotations + +from app.agents.new_chat.middleware import PermissionMiddleware +from app.agents.new_chat.permissions import Ruleset + + +def build_full_permission_mw(rulesets: list[Ruleset]) -> PermissionMiddleware | None: + return PermissionMiddleware(rulesets=rulesets) if rulesets else None diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/__init__.py new file mode 100644 index 000000000..92596b771 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/__init__.py @@ -0,0 +1,7 @@ +"""Resilience middleware shared as the same instances across parent / general-purpose / registry.""" + +from __future__ import annotations + +from .bundle import ResilienceBundle, build_resilience_bundle + +__all__ = ["ResilienceBundle", "build_resilience_bundle"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/bundle.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/bundle.py new file mode 100644 index 000000000..45f76a6f3 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/bundle.py @@ -0,0 +1,51 @@ +"""Construct each resilience middleware once; same instances flow into every consumer.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from langchain.agents.middleware import ( + ModelCallLimitMiddleware, + ToolCallLimitMiddleware, +) + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.middleware import RetryAfterMiddleware +from app.agents.new_chat.middleware.scoped_model_fallback import ( + ScopedModelFallbackMiddleware, +) + +from .fallback import build_fallback_mw +from .model_call_limit import build_model_call_limit_mw +from .retry import build_retry_mw +from .tool_call_limit import build_tool_call_limit_mw + + +@dataclass(frozen=True) +class ResilienceBundle: + retry: RetryAfterMiddleware | None + fallback: ScopedModelFallbackMiddleware | None + model_call_limit: ModelCallLimitMiddleware | None + tool_call_limit: ToolCallLimitMiddleware | None + + def as_list(self) -> list[Any]: + return [ + m + for m in ( + self.retry, + self.fallback, + self.model_call_limit, + self.tool_call_limit, + ) + if m is not None + ] + + +def build_resilience_bundle(flags: AgentFeatureFlags) -> ResilienceBundle: + return ResilienceBundle( + retry=build_retry_mw(flags), + fallback=build_fallback_mw(flags), + model_call_limit=build_model_call_limit_mw(flags), + tool_call_limit=build_tool_call_limit_mw(flags), + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/fallback.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/fallback.py new file mode 100644 index 000000000..ea68a764e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/fallback.py @@ -0,0 +1,27 @@ +"""Switch to a fallback model on provider/network errors only.""" + +from __future__ import annotations + +import logging + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.middleware.scoped_model_fallback import ( + ScopedModelFallbackMiddleware, +) + +from ..flags import enabled + + +def build_fallback_mw( + flags: AgentFeatureFlags, +) -> ScopedModelFallbackMiddleware | None: + if not enabled(flags, "enable_model_fallback"): + return None + try: + return ScopedModelFallbackMiddleware( + "openai:gpt-4o-mini", + "anthropic:claude-3-5-haiku-20241022", + ) + except Exception: + logging.warning("ScopedModelFallbackMiddleware init failed; skipping.") + return None diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/model_call_limit.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/model_call_limit.py new file mode 100644 index 000000000..85707a385 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/model_call_limit.py @@ -0,0 +1,21 @@ +"""Cap model calls per thread / per run to prevent runaway cost.""" + +from __future__ import annotations + +from langchain.agents.middleware import ModelCallLimitMiddleware + +from app.agents.new_chat.feature_flags import AgentFeatureFlags + +from ..flags import enabled + + +def build_model_call_limit_mw( + flags: AgentFeatureFlags, +) -> ModelCallLimitMiddleware | None: + if not enabled(flags, "enable_model_call_limit"): + return None + return ModelCallLimitMiddleware( + thread_limit=120, + run_limit=80, + exit_behavior="end", + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/retry.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/retry.py new file mode 100644 index 000000000..c98fc4083 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/retry.py @@ -0,0 +1,16 @@ +"""Retry on transient model errors (e.g. Retry-After-bearing 429s).""" + +from __future__ import annotations + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.middleware import RetryAfterMiddleware + +from ..flags import enabled + + +def build_retry_mw(flags: AgentFeatureFlags) -> RetryAfterMiddleware | None: + return ( + RetryAfterMiddleware(max_retries=3) + if enabled(flags, "enable_retry_after") + else None + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/tool_call_limit.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/tool_call_limit.py new file mode 100644 index 000000000..dcde81f37 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/tool_call_limit.py @@ -0,0 +1,21 @@ +"""Cap tool calls per thread / per run to bound infinite-loop blast radius.""" + +from __future__ import annotations + +from langchain.agents.middleware import ToolCallLimitMiddleware + +from app.agents.new_chat.feature_flags import AgentFeatureFlags + +from ..flags import enabled + + +def build_tool_call_limit_mw( + flags: AgentFeatureFlags, +) -> ToolCallLimitMiddleware | None: + if not enabled(flags, "enable_tool_call_limit"): + return None + return ToolCallLimitMiddleware( + thread_limit=300, + run_limit=80, + exit_behavior="continue", + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/todos.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/todos.py new file mode 100644 index 000000000..ea9173a1d --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/todos.py @@ -0,0 +1,9 @@ +"""Todo-list middleware (each consumer needs its own instance).""" + +from __future__ import annotations + +from langchain.agents.middleware import TodoListMiddleware + + +def build_todos_mw() -> TodoListMiddleware: + return TodoListMiddleware() diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/stack.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/stack.py new file mode 100644 index 000000000..6d8faa3f4 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/stack.py @@ -0,0 +1,216 @@ +"""Main-agent middleware list assembly: one line per slot.""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence +from typing import Any + +from deepagents import SubAgent +from deepagents.backends import StateBackend +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool +from langgraph.types import Checkpointer + +from app.agents.multi_agent_chat.subagents import ( + build_subagents, + get_subagents_to_exclude, +) +from app.agents.multi_agent_chat.subagents.builtins.general_purpose.agent import ( + build_subagent as build_general_purpose_subagent, +) +from app.agents.multi_agent_chat.subagents.shared.permissions import ToolsPermissions +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.db import ChatVisibility + +from .main_agent.action_log import build_action_log_mw +from .main_agent.anonymous_doc import build_anonymous_doc_mw +from .main_agent.busy_mutex import build_busy_mutex_mw +from .main_agent.checkpointed_subagent_middleware import ( + SurfSenseCheckpointedSubAgentMiddleware, +) +from .main_agent.context_editing import build_context_editing_mw +from .main_agent.dedup_hitl import build_dedup_hitl_mw +from .main_agent.doom_loop import build_doom_loop_mw +from .main_agent.kb_persistence import build_kb_persistence_mw +from .main_agent.knowledge_priority import build_knowledge_priority_mw +from .main_agent.knowledge_tree import build_knowledge_tree_mw +from .main_agent.noop_injection import build_noop_injection_mw +from .main_agent.otel import build_otel_mw +from .main_agent.plugins import build_plugin_middlewares +from .main_agent.repair import build_repair_mw +from .main_agent.selector import build_selector_mw +from .main_agent.skills import build_skills_mw +from .shared.anthropic_cache import build_anthropic_cache_mw +from .shared.compaction import build_compaction_mw +from .shared.file_intent import build_file_intent_mw +from .shared.filesystem import build_filesystem_mw +from .shared.memory import build_memory_mw +from .shared.patch_tool_calls import build_patch_tool_calls_mw +from .shared.permissions import ( + build_full_permission_mw, + build_permission_context, +) +from .shared.resilience import build_resilience_bundle +from .shared.todos import build_todos_mw +from .subagent.extras import build_subagent_extras + + +def build_main_agent_deepagent_middleware( + *, + llm: BaseChatModel, + tools: Sequence[BaseTool], + backend_resolver: Any, + filesystem_mode: FilesystemMode, + search_space_id: int, + user_id: str | None, + thread_id: int | None, + visibility: ChatVisibility, + anon_session_id: str | None, + available_connectors: list[str] | None, + available_document_types: list[str] | None, + mentioned_document_ids: list[int] | None, + max_input_tokens: int | None, + flags: AgentFeatureFlags, + subagent_dependencies: dict[str, Any], + checkpointer: Checkpointer, + mcp_tools_by_agent: dict[str, ToolsPermissions] | None = None, + disabled_tools: list[str] | None = None, +) -> list[Any]: + """Ordered middleware for ``create_agent`` (None entries already stripped).""" + permissions = build_permission_context( + flags=flags, + filesystem_mode=filesystem_mode, + tools=tools, + available_connectors=available_connectors, + ) + resilience = build_resilience_bundle(flags) + + # Single instance threaded into both the main-agent stack and the general-purpose subagent. + memory_mw = build_memory_mw( + user_id=user_id, + search_space_id=search_space_id, + visibility=visibility, + ) + + general_purpose_subagent = build_general_purpose_subagent( + llm=llm, + tools=tools, + backend_resolver=backend_resolver, + filesystem_mode=filesystem_mode, + search_space_id=search_space_id, + user_id=user_id, + thread_id=thread_id, + permissions=permissions, + resilience=resilience, + memory_mw=memory_mw, + ) + + subagents_registry: list[SubAgent] = [] + try: + subagent_extras = build_subagent_extras( + permissions=permissions, + resilience=resilience, + ) + subagents_registry = build_subagents( + dependencies=subagent_dependencies, + model=llm, + extra_middleware=subagent_extras, + mcp_tools_by_agent=mcp_tools_by_agent or {}, + exclude=get_subagents_to_exclude(available_connectors), + disabled_tools=disabled_tools, + ) + logging.debug( + "Subagents registry: %s", + [s["name"] for s in subagents_registry], + ) + except Exception: + # Degrade to general-purpose-only rather than aborting the turn: + # one bad subagent dep should not deny the user a response. + logging.exception( + "Subagents registry build failed; falling back to general-purpose only" + ) + subagents_registry = [] + + subagents: list[SubAgent] = [general_purpose_subagent, *subagents_registry] + + stack: list[Any] = [ + build_busy_mutex_mw(flags), + build_otel_mw(flags), + build_todos_mw(), + memory_mw, + build_anonymous_doc_mw( + filesystem_mode=filesystem_mode, anon_session_id=anon_session_id + ), + build_knowledge_tree_mw( + filesystem_mode=filesystem_mode, + search_space_id=search_space_id, + llm=llm, + ), + build_knowledge_priority_mw( + llm=llm, + search_space_id=search_space_id, + filesystem_mode=filesystem_mode, + available_connectors=available_connectors, + available_document_types=available_document_types, + mentioned_document_ids=mentioned_document_ids, + ), + build_file_intent_mw(llm), + build_filesystem_mw( + backend_resolver=backend_resolver, + filesystem_mode=filesystem_mode, + search_space_id=search_space_id, + user_id=user_id, + thread_id=thread_id, + ), + build_kb_persistence_mw( + filesystem_mode=filesystem_mode, + search_space_id=search_space_id, + user_id=user_id, + thread_id=thread_id, + ), + build_skills_mw( + flags=flags, + filesystem_mode=filesystem_mode, + search_space_id=search_space_id, + ), + SurfSenseCheckpointedSubAgentMiddleware( + checkpointer=checkpointer, + backend=StateBackend, + subagents=subagents, + ), + build_selector_mw(flags=flags, tools=tools), + resilience.model_call_limit, + resilience.tool_call_limit, + build_context_editing_mw( + flags=flags, + max_input_tokens=max_input_tokens, + tools=tools, + backend_resolver=backend_resolver, + ), + build_compaction_mw(llm), + build_noop_injection_mw(flags), + resilience.retry, + resilience.fallback, + build_repair_mw(flags=flags, tools=tools), + build_full_permission_mw(permissions.rulesets), + build_doom_loop_mw(flags), + build_action_log_mw( + flags=flags, + thread_id=thread_id, + search_space_id=search_space_id, + user_id=user_id, + ), + build_patch_tool_calls_mw(), + build_dedup_hitl_mw(tools), + *build_plugin_middlewares( + flags=flags, + search_space_id=search_space_id, + user_id=user_id, + visibility=visibility, + llm=llm, + ), + build_anthropic_cache_mw(), + ] + return [m for m in stack if m is not None] diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/subagent/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/subagent/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/subagent/extras.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/subagent/extras.py new file mode 100644 index 000000000..46dca8a81 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/subagent/extras.py @@ -0,0 +1,28 @@ +"""Extra middleware threaded into every registry subagent's stack. + +Registry subagents are scoped to one domain (deliverables, research, memory, +connectors, MCP) and never read or write the SurfSense filesystem — that +capability belongs to the main agent and is delegated to the general-purpose +subagent as an escape hatch. Keeping FS off the registry stacks avoids +polluting their tool surface with FS tools they never act on. +""" + +from __future__ import annotations + +from typing import Any + +from ..shared.permissions import PermissionContext +from ..shared.resilience import ResilienceBundle +from ..shared.todos import build_todos_mw + + +def build_subagent_extras( + *, + permissions: PermissionContext, + resilience: ResilienceBundle, +) -> list[Any]: + extras: list[Any] = [build_todos_mw()] + if permissions.subagent_deny_mw is not None: + extras.append(permissions.subagent_deny_mw) + extras.extend(resilience.as_list()) + return extras diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/__init__.py new file mode 100644 index 000000000..ca9e4aa3e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/__init__.py @@ -0,0 +1,19 @@ +"""Registry-backed subagent builders and helpers.""" + +from __future__ import annotations + +from .registry import ( + SUBAGENT_BUILDERS_BY_NAME, + SubagentBuilder, + build_subagents, + get_subagents_to_exclude, + main_prompt_registry_subagent_lines, +) + +__all__ = [ + "SUBAGENT_BUILDERS_BY_NAME", + "SubagentBuilder", + "build_subagents", + "get_subagents_to_exclude", + "main_prompt_registry_subagent_lines", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/agent.py new file mode 100644 index 000000000..0f7070645 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/agent.py @@ -0,0 +1,55 @@ +"""`deliverables` route: ``SubAgent`` spec for deepagents.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from deepagents import SubAgent +from langchain_core.language_models import BaseChatModel + +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, + middleware_gated_interrupt_on, +) +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( + pack_subagent, +) + +from .tools.index import load_tools + +NAME = "deliverables" + + +def build_subagent( + *, + dependencies: dict[str, Any], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, + extra_tools_bucket: ToolsPermissions | None = None, +) -> SubAgent: + buckets = load_tools(dependencies=dependencies) + merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket) + tools = [ + row["tool"] + for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) + if row.get("tool") is not None + ] + interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket) + description = read_md_file(__package__, "description").strip() + if not description: + description = "Handles deliverables tasks for this workspace." + system_prompt = read_md_file(__package__, "system_prompt").strip() + return pack_subagent( + name=NAME, + description=description, + system_prompt=system_prompt, + tools=tools, + interrupt_on=interrupt_on, + model=model, + extra_middleware=extra_middleware, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/description.md new file mode 100644 index 000000000..4dd0f67fe --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/description.md @@ -0,0 +1 @@ +Use for deliverables and shareable artifacts: generated reports, podcasts, video presentations, resumes, and images—not for routine lookups or single small edits elsewhere. diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/system_prompt.md new file mode 100644 index 000000000..c44f131bb --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/system_prompt.md @@ -0,0 +1,55 @@ +You are the SurfSense deliverables operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Produce **deliverables**: shareable **artifacts** the user keeps (reports, slide-style video presentations, podcasts, resumes, images). Use explicit constraints and reliable proof of what was generated. + + + +- `generate_report` +- `generate_podcast` +- `generate_video_presentation` +- `generate_resume` +- `generate_image` + + + +- Use only tools in ``. +- Require essential generation constraints (audience, format, tone, core content). +- If critical constraints are missing, return `status=blocked` with `missing_fields`. +- Never claim artifact generation success without tool confirmation. + + + +- Do not perform connector data mutations unrelated to artifact generation. + + + +- Avoid generating artifacts with missing critical constraints. +- Prefer one complete artifact over partial multi-artifact output. + + + +- On generation failure, return `status=error` with best retry guidance. +- On missing constraints, return `status=blocked` with required fields. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "artifact_type": "report" | "podcast" | "video_presentation" | "resume" | "image" | null, + "artifact_id": string | null, + "artifact_location": string | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/__init__.py new file mode 100644 index 000000000..d0fe94217 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/__init__.py @@ -0,0 +1,15 @@ +"""Deliverable generators: reports, podcasts, video decks, resumes, images.""" + +from .generate_image import create_generate_image_tool +from .podcast import create_generate_podcast_tool +from .report import create_generate_report_tool +from .resume import create_generate_resume_tool +from .video_presentation import create_generate_video_presentation_tool + +__all__ = [ + "create_generate_image_tool", + "create_generate_podcast_tool", + "create_generate_report_tool", + "create_generate_resume_tool", + "create_generate_video_presentation_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/generate_image.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/generate_image.py new file mode 100644 index 000000000..ab9dbc0ea --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/generate_image.py @@ -0,0 +1,247 @@ +"""Image generation via litellm; resolves model config from the search space and returns UI-ready payloads.""" + +import hashlib +import logging +from typing import Any + +from langchain_core.tools import tool +from litellm import aimage_generation +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import config +from app.db import ( + ImageGeneration, + ImageGenerationConfig, + SearchSpace, + shielded_async_session, +) +from app.services.image_gen_router_service import ( + IMAGE_GEN_AUTO_MODE_ID, + ImageGenRouterService, + is_image_gen_auto_mode, +) +from app.utils.signed_image_urls import generate_image_token + +logger = logging.getLogger(__name__) + +# Provider mapping (same as routes) +_PROVIDER_MAP = { + "OPENAI": "openai", + "AZURE_OPENAI": "azure", + "GOOGLE": "gemini", + "VERTEX_AI": "vertex_ai", + "BEDROCK": "bedrock", + "RECRAFT": "recraft", + "OPENROUTER": "openrouter", + "XINFERENCE": "xinference", + "NSCALE": "nscale", +} + + +def _build_model_string( + provider: str, model_name: str, custom_provider: str | None +) -> str: + if custom_provider: + return f"{custom_provider}/{model_name}" + prefix = _PROVIDER_MAP.get(provider.upper(), provider.lower()) + return f"{prefix}/{model_name}" + + +def _get_global_image_gen_config(config_id: int) -> dict | None: + """Get a global image gen config by negative ID.""" + for cfg in config.GLOBAL_IMAGE_GEN_CONFIGS: + if cfg.get("id") == config_id: + return cfg + return None + + +def create_generate_image_tool( + search_space_id: int, + db_session: AsyncSession, +): + """Create ``generate_image`` with bound search space; DB work uses a per-call session.""" + del db_session # use a fresh per-call session, see below + + @tool + async def generate_image( + prompt: str, + n: int = 1, + ) -> dict[str, Any]: + """ + Generate an image from a text description using AI image models. + + Use this tool when the user asks you to create, generate, draw, or make an image. + The generated image will be displayed directly in the chat. + + Args: + prompt: A detailed text description of the image to generate. + Be specific about subject, style, colors, composition, and mood. + n: Number of images to generate (1-4). Default: 1 + + Returns: + A dictionary containing the generated image(s) for display in the chat. + """ + try: + # Use a per-call session so concurrent tool calls don't share an + # AsyncSession (which is not concurrency-safe). The streaming + # task's session is shared across every tool; without isolation, + # autoflushes from a concurrent writer poison this tool too. + async with shielded_async_session() as session: + result = await session.execute( + select(SearchSpace).filter(SearchSpace.id == search_space_id) + ) + search_space = result.scalars().first() + if not search_space: + return {"error": "Search space not found"} + + config_id = ( + search_space.image_generation_config_id or IMAGE_GEN_AUTO_MODE_ID + ) + + # Build generation kwargs + # NOTE: size, quality, and style are intentionally NOT passed. + # Different models support different values for these params + # (e.g. DALL-E 3 wants "hd"/"standard" for quality while + # gpt-image-1 wants "high"/"medium"/"low"; size options also + # differ). Letting the model use its own defaults avoids errors. + gen_kwargs: dict[str, Any] = {} + if n is not None and n > 1: + gen_kwargs["n"] = n + + # Call litellm based on config type + if is_image_gen_auto_mode(config_id): + if not ImageGenRouterService.is_initialized(): + return { + "error": "No image generation models configured. " + "Please add an image model in Settings > Image Models." + } + response = await ImageGenRouterService.aimage_generation( + prompt=prompt, model="auto", **gen_kwargs + ) + elif config_id < 0: + cfg = _get_global_image_gen_config(config_id) + if not cfg: + return { + "error": f"Image generation config {config_id} not found" + } + + model_string = _build_model_string( + cfg.get("provider", ""), + cfg["model_name"], + cfg.get("custom_provider"), + ) + gen_kwargs["api_key"] = cfg.get("api_key") + if cfg.get("api_base"): + gen_kwargs["api_base"] = cfg["api_base"] + if cfg.get("api_version"): + gen_kwargs["api_version"] = cfg["api_version"] + if cfg.get("litellm_params"): + gen_kwargs.update(cfg["litellm_params"]) + + response = await aimage_generation( + prompt=prompt, model=model_string, **gen_kwargs + ) + else: + # Positive ID = user-created ImageGenerationConfig + cfg_result = await session.execute( + select(ImageGenerationConfig).filter( + ImageGenerationConfig.id == config_id + ) + ) + db_cfg = cfg_result.scalars().first() + if not db_cfg: + return { + "error": f"Image generation config {config_id} not found" + } + + model_string = _build_model_string( + db_cfg.provider.value, + db_cfg.model_name, + db_cfg.custom_provider, + ) + gen_kwargs["api_key"] = db_cfg.api_key + if db_cfg.api_base: + gen_kwargs["api_base"] = db_cfg.api_base + if db_cfg.api_version: + gen_kwargs["api_version"] = db_cfg.api_version + if db_cfg.litellm_params: + gen_kwargs.update(db_cfg.litellm_params) + + response = await aimage_generation( + prompt=prompt, model=model_string, **gen_kwargs + ) + + # Parse the response and store in DB + response_dict = ( + response.model_dump() + if hasattr(response, "model_dump") + else dict(response) + ) + + # Generate a random access token for this image + access_token = generate_image_token() + + # Save to image_generations table for history + db_image_gen = ImageGeneration( + prompt=prompt, + model=getattr(response, "_hidden_params", {}).get("model"), + n=n, + image_generation_config_id=config_id, + response_data=response_dict, + search_space_id=search_space_id, + access_token=access_token, + ) + session.add(db_image_gen) + await session.commit() + await session.refresh(db_image_gen) + db_image_gen_id = db_image_gen.id + + # Extract image URLs from response + images = response_dict.get("data", []) + if not images: + return {"error": "No images were generated"} + + first_image = images[0] + revised_prompt = first_image.get("revised_prompt", prompt) + + # Resolve image URL: + # - If the API returned a URL, use it directly. + # - If the API returned b64_json (e.g. gpt-image-1), serve the + # image through our backend endpoint to avoid bloating the + # LLM context with megabytes of base64 data. + if first_image.get("url"): + image_url = first_image["url"] + elif first_image.get("b64_json"): + backend_url = config.BACKEND_URL or "http://localhost:8000" + image_url = ( + f"{backend_url}/api/v1/image-generations/" + f"{db_image_gen_id}/image?token={access_token}" + ) + else: + return {"error": "No displayable image data in the response"} + + image_id = f"image-{hashlib.md5(image_url.encode()).hexdigest()[:12]}" + + return { + "id": image_id, + "assetId": image_url, + "src": image_url, + "alt": revised_prompt or prompt, + "title": "Generated Image", + "description": revised_prompt if revised_prompt != prompt else None, + "domain": "ai-generated", + "ratio": "auto", + "generated": True, + "prompt": prompt, + "image_count": len(images), + } + + except Exception as e: + logger.exception("Image generation failed in tool") + return { + "error": f"Image generation failed: {e!s}", + "prompt": prompt, + } + + return generate_image diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/index.py new file mode 100644 index 000000000..938e73bd4 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/index.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) + +from .generate_image import create_generate_image_tool +from .podcast import create_generate_podcast_tool +from .report import create_generate_report_tool +from .resume import create_generate_resume_tool +from .video_presentation import create_generate_video_presentation_tool + + +def load_tools( + *, dependencies: dict[str, Any] | None = None, **kwargs: Any +) -> ToolsPermissions: + resolved_dependencies = {**(dependencies or {}), **kwargs} + podcast = create_generate_podcast_tool( + search_space_id=resolved_dependencies["search_space_id"], + db_session=resolved_dependencies["db_session"], + thread_id=resolved_dependencies["thread_id"], + ) + video = create_generate_video_presentation_tool( + search_space_id=resolved_dependencies["search_space_id"], + db_session=resolved_dependencies["db_session"], + thread_id=resolved_dependencies["thread_id"], + ) + report = create_generate_report_tool( + search_space_id=resolved_dependencies["search_space_id"], + thread_id=resolved_dependencies["thread_id"], + connector_service=resolved_dependencies.get("connector_service"), + available_connectors=resolved_dependencies.get("available_connectors"), + available_document_types=resolved_dependencies.get("available_document_types"), + ) + resume = create_generate_resume_tool( + search_space_id=resolved_dependencies["search_space_id"], + thread_id=resolved_dependencies["thread_id"], + ) + image = create_generate_image_tool( + search_space_id=resolved_dependencies["search_space_id"], + db_session=resolved_dependencies["db_session"], + ) + return { + "allow": [ + {"name": getattr(podcast, "name", "") or "", "tool": podcast}, + {"name": getattr(video, "name", "") or "", "tool": video}, + {"name": getattr(report, "name", "") or "", "tool": report}, + {"name": getattr(resume, "name", "") or "", "tool": resume}, + {"name": getattr(image, "name", "") or "", "tool": image}, + ], + "ask": [], + } diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/podcast.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/podcast.py new file mode 100644 index 000000000..55d9b3565 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/podcast.py @@ -0,0 +1,92 @@ +"""Factory for a podcast-generation tool that queues background work and returns an ID for polling.""" + +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import Podcast, PodcastStatus, shielded_async_session + + +def create_generate_podcast_tool( + search_space_id: int, + db_session: AsyncSession, + thread_id: int | None = None, +): + """Create ``generate_podcast`` with bound search space and thread; DB writes use a tool-local session.""" + del db_session # writes use a fresh tool-local session, see below + + @tool + async def generate_podcast( + source_content: str, + podcast_title: str = "SurfSense Podcast", + user_prompt: str | None = None, + ) -> dict[str, Any]: + """ + Generate a podcast from the provided content. + + Use this tool when the user asks to create, generate, or make a podcast. + Common triggers include phrases like: + - "Give me a podcast about this" + - "Create a podcast from this conversation" + - "Generate a podcast summary" + - "Make a podcast about..." + - "Turn this into a podcast" + + Args: + source_content: The text content to convert into a podcast. + podcast_title: Title for the podcast (default: "SurfSense Podcast") + user_prompt: Optional instructions for podcast style, tone, or format. + + Returns: + A dictionary containing: + - status: PodcastStatus value (pending, generating, or failed) + - podcast_id: The podcast ID for polling (when status is pending or generating) + - title: The podcast title + - message: Status message (or "error" field if status is failed) + """ + try: + # One DB session per tool call so parallel invocations never share an AsyncSession. + async with shielded_async_session() as session: + podcast = Podcast( + title=podcast_title, + status=PodcastStatus.PENDING, + search_space_id=search_space_id, + thread_id=thread_id, + ) + session.add(podcast) + await session.commit() + await session.refresh(podcast) + podcast_id = podcast.id + + from app.tasks.celery_tasks.podcast_tasks import ( + generate_content_podcast_task, + ) + + task = generate_content_podcast_task.delay( + podcast_id=podcast_id, + source_content=source_content, + search_space_id=search_space_id, + user_prompt=user_prompt, + ) + + print(f"[generate_podcast] Created podcast {podcast_id}, task: {task.id}") + + return { + "status": PodcastStatus.PENDING.value, + "podcast_id": podcast_id, + "title": podcast_title, + "message": "Podcast generation started. This may take a few minutes.", + } + + except Exception as e: + error_message = str(e) + print(f"[generate_podcast] Error: {error_message}") + return { + "status": PodcastStatus.FAILED.value, + "error": error_message, + "title": podcast_title, + "podcast_id": None, + } + + return generate_podcast diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/report.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/report.py new file mode 100644 index 000000000..385100c62 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/report.py @@ -0,0 +1,1061 @@ +"""Factory for inline Markdown reports: optional KB sourcing, section-aware revision, short-lived DB sessions.""" + +import asyncio +import json +import logging +import re +from typing import Any + +from langchain_core.callbacks import dispatch_custom_event +from langchain_core.messages import HumanMessage +from langchain_core.tools import tool + +from app.db import Report, shielded_async_session +from app.services.connector_service import ConnectorService +from app.services.llm_service import get_document_summary_llm + +logger = logging.getLogger(__name__) + +# ─── Shared Formatting Rules ──────────────────────────────────────────────── +# Reusable formatting instructions appended to section-level and review prompts. + +_FORMATTING_RULES = """\ +- IMPORTANT: Output raw Markdown directly. Do NOT wrap the entire output in a \ +code fence (e.g. ```markdown, ````markdown, or any backtick fence). Individual \ +code examples and diagrams inside the report should still use fenced code blocks, \ +but the report itself must NOT be enclosed in one. +- Maintain proper Markdown formatting throughout. +- When including code examples, ALWAYS format them as proper fenced code blocks \ +with the correct language identifier (e.g. ```java, ```python). Code inside code \ +blocks MUST have proper line breaks and indentation — NEVER put multiple statements \ +on a single line. Each statement, brace, and logical block must be on its own line \ +with correct indentation. +- When including Mermaid diagrams, use ```mermaid fenced code blocks. Each Mermaid \ +statement MUST be on its own line — NEVER use semicolons to join multiple statements \ +on one line. For line breaks inside node labels, use
(NOT
). +- When including mathematical formulas or equations, ALWAYS use LaTeX notation. \ +NEVER use backtick code spans or Unicode symbols for math.""" + +# ─── Standard Report Footer ───────────────────────────────────────────────── +# Appended to every generated report after content generation. + +_REPORT_FOOTER = "Powered by SurfSense AI." + +# ─── Prompt: Single-Shot Report Generation ─────────────────────────────────── + +_REPORT_PROMPT = """You are an expert report writer. Generate a comprehensive Markdown report. + +**Topic:** {topic} +**Report Style:** {report_style} +{user_instructions_section} +{previous_version_section} + +**Source Content:** +{source_content} + +--- + +{length_instruction} + +Write a well-structured Markdown report with a # title, executive summary, organized sections, and conclusion. Cite facts from the source content. Be thorough and professional. + +{formatting_rules} +""" + +# ─── Prompt: Full-Document Revision (fallback when section-level fails) ────── + +_REVISION_PROMPT = """You are an expert report editor. Apply ONLY the requested changes — do NOT rewrite from scratch. + +**Topic:** {topic} +**Report Style:** {report_style} +**Modification Instructions:** {user_instructions_section} + +**Source Content (use if relevant):** +{source_content} + +--- + +**EXISTING REPORT:** + +{previous_report_content} + +--- + +{length_instruction} + +Preserve all structure and content not affected by the modification. + +{formatting_rules} +""" + +# ─── Prompt: Section-Level Revision — Identify Affected Sections ───────────── + +_IDENTIFY_SECTIONS_PROMPT = """You are analyzing a Markdown report to determine which sections need modification based on the user's request. + +**User's Modification Request:** {user_instructions} + +**Report Sections (indexed starting at 0):** +{sections_listing} + +--- + +Determine which sections need to be modified, added, or removed to fulfill the user's request. + +Return ONLY a JSON object with these fields: +- "modify": Array of section indices (0-based) that need content changes +- "add": Array of objects like {{"after_index": 2, "heading": "## New Section Title", "description": "What this section should cover"}} for new sections to insert +- "remove": Array of section indices to remove entirely (use sparingly) +- "reasoning": A brief explanation of your decisions + +Guidelines: +- If the change is GLOBAL (e.g., "change the tone", "make the whole report shorter", "translate to Spanish"), include ALL section indices in "modify". +- If the change is TARGETED (e.g., "expand the budget section", "fix the conclusion"), include ONLY the affected section indices. +- For "add a section about X", use the "add" field with the appropriate insertion point. +- Prefer modifying over removing+adding when possible. + +Return ONLY valid JSON, no markdown fences: +""" + +# ─── Prompt: Section-Level Revision — Revise a Single Section ──────────────── + +_REVISE_SECTION_PROMPT = """Revise ONLY this section based on the instructions. If the instructions don't apply, return it UNCHANGED. + +**Modification Instructions:** {user_instructions} + +**Current Section:** +{section_content} + +**Context (surrounding sections — for coherence only, do NOT output them):** +{context_sections} + +**Source Content:** +{source_content} + +--- + +Keep the same heading and heading level. Preserve content not affected by the modification. +{formatting_rules} +""" + +# ─── Prompt: New Section Generation (for section-level add) ───────────────── + +_NEW_SECTION_PROMPT = """You are an expert report writer. Write a new section to be inserted into an existing report. + +**Report Topic:** {topic} +**Report Style:** {report_style} +**Section Heading:** {heading} +**Section Goal:** {description} +**User Instructions:** {user_instructions} + +**Surrounding Context:** +{context_sections} + +**Source Content:** +{source_content} + +--- + +**Rules:** +1. Write ONLY this section, starting with the heading "{heading}". +2. Ensure the section flows naturally with the surrounding context. +3. Be comprehensive — cover the topic described above. +{formatting_rules} + +Write the new section now: +""" + + +# ─── Utility Functions ────────────────────────────────────────────────────── + + +def _strip_wrapping_code_fences(text: str) -> str: + """Remove wrapping code fences that LLMs often add around Markdown output. + + Handles patterns like: + ```markdown\\n...content...\\n``` + ````markdown\\n...content...\\n```` + ```md\\n...content...\\n``` + ```\\n...content...\\n``` + ```json\\n...content...\\n``` + Supports 3 or more backticks (LLMs escalate when content has triple-backtick blocks). + """ + stripped = text.strip() + # Match opening fence with 3+ backticks and optional language tag + m = re.match(r"^(`{3,})(?:markdown|md|json)?\s*\n", stripped) + if m: + fence = m.group(1) # e.g. "```" or "````" + if stripped.endswith(fence): + stripped = stripped[m.end() :] # remove opening fence + stripped = stripped[: -len(fence)].rstrip() # remove closing fence + return stripped + + +def _extract_metadata(content: str) -> dict[str, Any]: + """Extract metadata from generated Markdown content.""" + # Count section headings + headings = re.findall(r"^(#{1,6})\s+(.+)$", content, re.MULTILINE) + + # Word count + word_count = len(content.split()) + + # Character count + char_count = len(content) + + return { + "status": "ready", + "word_count": word_count, + "char_count": char_count, + "section_count": len(headings), + } + + +def _parse_sections(content: str) -> list[dict[str, str]]: + """Parse Markdown content into sections split by # and ## headings. + + Returns a list of dicts: [{"heading": "## Title", "body": "content..."}, ...] + Content before the first heading is captured with heading="". + ### and deeper headings are kept inside their parent ## section's body. + """ + lines = content.split("\n") + sections: list[dict[str, str]] = [] + current_heading = "" + current_body_lines: list[str] = [] + in_code_block = False + + for line in lines: + # Track code blocks to avoid matching headings inside them + stripped = line.strip() + if stripped.startswith("```"): + in_code_block = not in_code_block + + # Only split on # or ## headings (not ### or deeper) and only outside code blocks + is_section_heading = ( + not in_code_block + and re.match(r"^#{1,2}\s+", line) + and not re.match(r"^#{3,}\s+", line) + ) + + if is_section_heading: + # Save previous section + if current_heading or current_body_lines: + sections.append( + { + "heading": current_heading, + "body": "\n".join(current_body_lines).strip(), + } + ) + current_heading = line.strip() + current_body_lines = [] + else: + current_body_lines.append(line) + + # Save last section + if current_heading or current_body_lines: + sections.append( + { + "heading": current_heading, + "body": "\n".join(current_body_lines).strip(), + } + ) + + return sections + + +def _stitch_sections(sections: list[dict[str, str]]) -> str: + """Stitch parsed sections back into a single Markdown string.""" + parts = [] + for section in sections: + if section["heading"]: + parts.append(section["heading"]) + if section["body"]: + parts.append(section["body"]) + return "\n\n".join(parts) + + +# ─── Async Generation Helpers ─────────────────────────────────────────────── + + +async def _revise_with_sections( + llm: Any, + parent_content: str, + user_instructions: str, + source_content: str, + topic: str, + report_style: str, +) -> str | None: + """Section-level revision: identify affected sections and revise only those. + + Unchanged sections are kept byte-for-byte identical. + Returns the revised content, or None to trigger full-document revision fallback. + """ + # Parse report into sections + sections = _parse_sections(parent_content) + if len(sections) < 2: + logger.info( + "[generate_report] Too few sections for section-level revision, using full revision" + ) + return None + + # Build a sections listing for the LLM + sections_listing = "" + for i, sec in enumerate(sections): + heading = sec["heading"] or "(preamble — content before first heading)" + body_preview = ( + sec["body"][:200] + "..." if len(sec["body"]) > 200 else sec["body"] + ) + sections_listing += f"\n[{i}] {heading}\n Preview: {body_preview}\n" + + # Step 1: Ask LLM which sections need modification + identify_prompt = _IDENTIFY_SECTIONS_PROMPT.format( + user_instructions=user_instructions, + sections_listing=sections_listing, + ) + + try: + response = await llm.ainvoke([HumanMessage(content=identify_prompt)]) + raw = response.content + if not raw or not isinstance(raw, str): + return None + + raw = _strip_wrapping_code_fences(raw).strip() + json_match = re.search(r"\{[\s\S]*\}", raw) + if json_match: + raw = json_match.group(0) + + plan = json.loads(raw) + modify_indices: list[int] = plan.get("modify", []) + add_sections: list[dict[str, Any]] = plan.get("add", []) + remove_indices: list[int] = plan.get("remove", []) + reasoning = plan.get("reasoning", "") + + logger.info( + f"[generate_report] Section-level revision plan: " + f"modify={modify_indices}, add={len(add_sections)}, " + f"remove={remove_indices}, reasoning={reasoning}" + ) + except Exception: + logger.warning( + "[generate_report] Failed to identify sections for revision, " + "falling back to full revision", + exc_info=True, + ) + return None + + # If ALL sections need modification, full revision is more efficient and coherent + if len(modify_indices) >= len(sections): + logger.info( + "[generate_report] All sections need modification, deferring to full revision" + ) + return None + + # Compute total operations for progress tracking + total_ops = len(modify_indices) + len(add_sections) + current_op = 0 + + # Emit plan summary + parts = [] + if modify_indices: + parts.append( + f"modifying {len(modify_indices)} section{'s' if len(modify_indices) > 1 else ''}" + ) + if add_sections: + parts.append( + f"adding {len(add_sections)} new section{'s' if len(add_sections) > 1 else ''}" + ) + if remove_indices: + parts.append( + f"removing {len(remove_indices)} section{'s' if len(remove_indices) > 1 else ''}" + ) + plan_summary = ", ".join(parts) if parts else "no changes needed" + + dispatch_custom_event( + "report_progress", + { + "phase": "revision_plan", + "message": plan_summary.capitalize(), + "modify_count": len(modify_indices), + "add_count": len(add_sections), + "remove_count": len(remove_indices), + "total_ops": total_ops, + }, + ) + + # Step 2: Revise only the affected sections + revised_sections = list(sections) # shallow copy — unmodified sections stay as-is + + for idx in modify_indices: + if idx < 0 or idx >= len(sections): + continue + + current_op += 1 + sec = sections[idx] + + # Extract plain section name (strip markdown heading markers) + section_name = ( + re.sub(r"^#+\s*", "", sec["heading"]).strip() + if sec["heading"] + else "Preamble" + ) + dispatch_custom_event( + "report_progress", + { + "phase": "revising_section", + "message": f"Revising: {section_name} ({current_op}/{total_ops})...", + }, + ) + + section_content = ( + f"{sec['heading']}\n\n{sec['body']}" if sec["heading"] else sec["body"] + ) + + # Build context from surrounding sections + context_parts = [] + if idx > 0: + prev = sections[idx - 1] + prev_preview = prev["body"][:300] + ( + "..." if len(prev["body"]) > 300 else "" + ) + context_parts.append( + f"**Previous section:** {prev['heading']}\n{prev_preview}" + ) + if idx < len(sections) - 1: + nxt = sections[idx + 1] + nxt_preview = nxt["body"][:300] + ("..." if len(nxt["body"]) > 300 else "") + context_parts.append(f"**Next section:** {nxt['heading']}\n{nxt_preview}") + context = ( + "\n\n".join(context_parts) if context_parts else "(No surrounding sections)" + ) + + revise_prompt = _REVISE_SECTION_PROMPT.format( + user_instructions=user_instructions, + section_content=section_content, + context_sections=context, + source_content=source_content[:40000], + formatting_rules=_FORMATTING_RULES, + ) + + resp = await llm.ainvoke([HumanMessage(content=revise_prompt)]) + revised_text = resp.content + if revised_text and isinstance(revised_text, str): + revised_text = _strip_wrapping_code_fences(revised_text).strip() + # Parse the LLM output back into heading + body + revised_parsed = _parse_sections(revised_text) + if revised_parsed: + revised_sections[idx] = revised_parsed[0] + else: + revised_sections[idx] = { + "heading": sec["heading"], + "body": revised_text, + } + + logger.info(f"[generate_report] Revised section [{idx}]: {sec['heading']}") + + # Step 3: Handle new section additions (insert in reverse order to preserve indices) + for add_info in sorted( + add_sections, + key=lambda x: x.get("after_index", len(revised_sections) - 1), + reverse=True, + ): + current_op += 1 + after_idx = add_info.get("after_index", len(revised_sections) - 1) + heading = add_info.get("heading", "## New Section") + description = add_info.get("description", "") + + # Extract plain section name for progress display + plain_heading = re.sub(r"^#+\s*", "", heading).strip() + dispatch_custom_event( + "report_progress", + { + "phase": "adding_section", + "message": f"Adding: {plain_heading} ({current_op}/{total_ops})...", + }, + ) + + # Build context from the surrounding sections at the insertion point + ctx_parts = [] + if 0 <= after_idx < len(revised_sections): + before_sec = revised_sections[after_idx] + ctx_parts.append( + f"**Section before:** {before_sec['heading']}\n{before_sec['body'][:300]}" + ) + insert_idx = min(after_idx + 1, len(revised_sections)) + if insert_idx < len(revised_sections): + after_sec = revised_sections[insert_idx] + ctx_parts.append( + f"**Section after:** {after_sec['heading']}\n{after_sec['body'][:300]}" + ) + + new_prompt = _NEW_SECTION_PROMPT.format( + topic=topic, + report_style=report_style, + heading=heading, + description=description, + user_instructions=user_instructions, + context_sections="\n\n".join(ctx_parts) if ctx_parts else "(None)", + source_content=source_content[:30000], + formatting_rules=_FORMATTING_RULES, + ) + + resp = await llm.ainvoke([HumanMessage(content=new_prompt)]) + new_content = resp.content + if new_content and isinstance(new_content, str): + new_content = _strip_wrapping_code_fences(new_content).strip() + new_parsed = _parse_sections(new_content) + if new_parsed: + revised_sections.insert(insert_idx, new_parsed[0]) + else: + revised_sections.insert( + insert_idx, + { + "heading": heading, + "body": new_content, + }, + ) + + logger.info( + f"[generate_report] Added new section after [{after_idx}]: {heading}" + ) + + # Step 4: Handle removals (reverse order to preserve indices) + for idx in sorted(remove_indices, reverse=True): + if 0 <= idx < len(revised_sections): + logger.info( + f"[generate_report] Removed section [{idx}]: " + f"{revised_sections[idx]['heading']}" + ) + revised_sections.pop(idx) + + return _stitch_sections(revised_sections) + + +# ─── Tool Factory ─────────────────────────────────────────────────────────── + + +def create_generate_report_tool( + search_space_id: int, + thread_id: int | None = None, + connector_service: ConnectorService | None = None, + available_connectors: list[str] | None = None, + available_document_types: list[str] | None = None, +): + """ + Factory function to create the generate_report tool with injected dependencies. + + The tool generates a Markdown report inline using the search space's + document summary LLM, saves it to the database, and returns immediately. + + Uses short-lived database sessions for each DB operation so no connection + is held during the long LLM API call. + + Generation strategies: + - New reports: single-shot generation (1 LLM call) + - Revisions (targeted edits): section-level (unchanged sections preserved) + - Revisions (global changes): full-document revision fallback + + Source strategies: + - "provided"/"conversation": use only the supplied source_content + - "kb_search": search the knowledge base internally using targeted queries + - "auto": use source_content if sufficient, otherwise fall back to KB search + + Args: + search_space_id: The user's search space ID + thread_id: The chat thread ID for associating the report + connector_service: Optional connector service for internal KB search. + When provided, the tool can search the knowledge base internally + (used by the "kb_search" and "auto" source strategies). + available_connectors: Optional list of connector types available in the + search space (used to scope internal KB searches). + + Returns: + A configured tool function for generating reports + """ + + @tool + async def generate_report( + topic: str, + source_content: str = "", + source_strategy: str = "provided", + search_queries: list[str] | None = None, + report_style: str = "detailed", + user_instructions: str | None = None, + parent_report_id: int | None = None, + ) -> dict[str, Any]: + """ + Generate a structured Markdown report artifact from provided content. + + Use this tool when the user asks to create, generate, write, produce, + draft, or summarize into a report-style deliverable. + + Trigger classes include: + - Direct trigger words WITH creation/modification verb: report, + document, memo, letter, template, article, guide, blog post, + one-pager, briefing, comprehensive guide. + - Creation-intent phrases: "write a report", "generate a document", + "draft a summary", "create an executive summary". + - Modification-intent phrases: "revise the report", "update the + report", "make it shorter", "add a section about X", "expand the + budget section", "rewrite in formal tone". + + IMPORTANT — what does NOT count as "asking for a report": + - Questions or discussion about a report or its topic are NOT report + requests. Respond to these conversationally in chat. + Examples: "What other examples to put there?", "What else could be + added?", "Can you explain section 2?", "Is the data accurate?", + "What's missing?", "How could this be improved?", "What other + topics are related?" + - Quick summary requests, explanations, or follow-up questions. + - The test: Does the message contain a creation/modification VERB + (write, create, generate, draft, add, revise, update, expand, + rewrite, make) directed at producing a deliverable? If no verb + → answer in chat. + + FORMAT/EXPORT RULE: + - Always generate the report content in Markdown. + - If the user requests DOCX/Word/PDF or another file format, export + from the generated Markdown report. + + SOURCE STRATEGY (how to collect source material): + - source_strategy="conversation" — The conversation already has + enough context (prior Q&A, filesystem exploration, pasted text, + uploaded files, scraped webpages). Pass a thorough summary as + source_content. + - source_strategy="kb_search" — Search the knowledge base + internally. Provide 1-5 targeted search_queries. The tool + handles searching internally — do NOT manually read and dump + /documents/ files into source_content. + - source_strategy="provided" — Use only what is in source_content + (default, backward-compatible). + - source_strategy="auto" — Use source_content if it has enough + material; otherwise fall back to internal KB search using + search_queries. + + CONVERSATION REUSE (HIGH PRIORITY): + - If the user has been asking questions in this chat and the + conversation contains substantive answers/discussion on the + topic, prefer source_strategy="conversation" with a thorough + summary of the full chat history as source_content. + - The user's prior questions and your answers ARE the source + material. Do NOT redundantly search the knowledge base for + information that is already in the chat. + + VERSIONING — parent_report_id: + - Set parent_report_id when the user wants to MODIFY, REVISE, + IMPROVE, UPDATE, EXPAND, or ADD CONTENT TO an existing report + that was already generated in this conversation. + - This includes both explicit AND implicit modification requests. + If the user references the existing report using words like "it", + "this", "here", "the report", or clearly refers to a previously + generated report, treat it as a revision request. + - The value must be the report_id from a previous generate_report + result in this same conversation. + - Do NOT set parent_report_id when: + * The user asks for a report on a completely NEW/DIFFERENT topic + * The user says "generate another report" (new report, not revision) + * There is no prior report to reference + + Examples of when to SET parent_report_id: + User: "Make that report shorter" → parent_report_id = + User: "Add a cost analysis section to the report" → parent_report_id = + User: "Rewrite the report in a more formal tone" → parent_report_id = + User: "I want more details about pricing in here" → parent_report_id = + User: "Include more examples" → parent_report_id = + User: "Can you also cover nutrition in this?" → parent_report_id = + User: "Make it more detailed" → parent_report_id = + User: "Not bad, but expand on the budget section" → parent_report_id = + User: "Also mention the competitor landscape" → parent_report_id = + + Examples of when to LEAVE parent_report_id as None: + User: "Generate a report on climate change" → None (new topic) + User: "Write me a report about the budget" → None (new topic) + User: "Create another report, this time about marketing" → None + User: "Now write one about travel trends in Europe" → None (new topic) + + Args: + topic: Short title for the report (max ~8 words). + source_content: Text to base the report on. Can be empty when + using source_strategy="kb_search". + source_strategy: How to collect source material. One of + "provided", "conversation", "kb_search", or "auto". + search_queries: When source_strategy is "kb_search" or "auto", + provide 1-5 targeted search queries for the knowledge base. + These should be specific, not just the topic repeated. + report_style: "detailed", "deep_research", or "brief". + user_instructions: Optional focus or modification instructions. + When revising (parent_report_id set), describe WHAT TO CHANGE. + parent_report_id: ID of a previous report to revise (creates new + version in the same version group). + + Returns: + Dict with status, report_id, title, word_count, and message. + """ + # Initialize version tracking variables (used by _save_failed_report closure) + parent_report_content: str | None = None + report_group_id: int | None = None + + async def _save_failed_report(error_msg: str) -> int | None: + """Persist a failed report row using a short-lived session.""" + try: + async with shielded_async_session() as session: + failed_report = Report( + title=topic, + content=None, + report_metadata={ + "status": "failed", + "error_message": error_msg, + }, + report_style=report_style, + search_space_id=search_space_id, + thread_id=thread_id, + report_group_id=report_group_id, + ) + session.add(failed_report) + await session.commit() + await session.refresh(failed_report) + # If this is a new group (v1 failed), set group to self + if not failed_report.report_group_id: + failed_report.report_group_id = failed_report.id + await session.commit() + logger.info( + f"[generate_report] Saved failed report {failed_report.id}: {error_msg}" + ) + return failed_report.id + except Exception: + logger.exception( + "[generate_report] Could not persist failed report row" + ) + return None + + try: + # ── Phase 1: READ (short-lived session) ────────────────────── + # Fetch parent report and LLM config, then close the session + # so no DB connection is held during the long LLM call. + async with shielded_async_session() as read_session: + if parent_report_id: + parent_report = await read_session.get(Report, parent_report_id) + if parent_report: + report_group_id = parent_report.report_group_id + parent_report_content = parent_report.content + logger.info( + f"[generate_report] Creating new version from parent {parent_report_id} " + f"(group {report_group_id})" + ) + else: + logger.warning( + f"[generate_report] parent_report_id={parent_report_id} not found, " + "creating standalone report" + ) + + llm = await get_document_summary_llm(read_session, search_space_id) + # read_session closed — connection returned to pool + + if not llm: + error_msg = ( + "No LLM configured. Please configure a language model in Settings." + ) + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": topic, + } + + # Build the user instructions string + user_instructions_section = "" + if user_instructions: + user_instructions_section = ( + f"**Additional Instructions:** {user_instructions}" + ) + + # ── Phase 1b: SOURCE COLLECTION (smart KB search) ──────────── + # Decide whether to augment source_content with KB search results. + effective_source = source_content or "" + + strategy = (source_strategy or "provided").lower().strip() + + needs_kb_search = False + if strategy == "kb_search": + needs_kb_search = True + elif strategy == "auto": + # Heuristic: if source_content has fewer than 200 words, + # it's likely insufficient — augment with KB search. + word_count_estimate = len(effective_source.split()) + if word_count_estimate < 200: + needs_kb_search = True + logger.info( + f"[generate_report] auto strategy: source has ~{word_count_estimate} words, " + "triggering KB search" + ) + # "provided" and "conversation" → use source_content as-is + + if needs_kb_search and connector_service and search_queries: + query_count = min(len(search_queries), 5) + dispatch_custom_event( + "report_progress", + { + "phase": "kb_search", + "message": f"Searching knowledge base ({query_count} queries)...", + }, + ) + logger.info( + f"[generate_report] Running internal KB search with " + f"{query_count} queries: {search_queries[:5]}" + ) + try: + from .knowledge_base import search_knowledge_base_async + + # Run all queries in parallel, each with its own session + async def _run_single_query(q: str) -> str: + async with shielded_async_session() as kb_session: + kb_connector_svc = ConnectorService( + kb_session, search_space_id + ) + return await search_knowledge_base_async( + query=q, + search_space_id=search_space_id, + db_session=kb_session, + connector_service=kb_connector_svc, + top_k=10, + available_connectors=available_connectors, + available_document_types=available_document_types, + ) + + kb_results = await asyncio.gather( + *[_run_single_query(q) for q in search_queries[:5]] + ) + + # Merge non-empty results into source_content + kb_text_parts = [r for r in kb_results if r and r.strip()] + if kb_text_parts: + kb_combined = "\n\n---\n\n".join(kb_text_parts) + if effective_source.strip(): + effective_source = ( + effective_source + + "\n\n--- Knowledge Base Search Results ---\n\n" + + kb_combined + ) + else: + effective_source = kb_combined + + # Count docs found (rough: count tags) + doc_count = kb_combined.count("") + dispatch_custom_event( + "report_progress", + { + "phase": "kb_search_done", + "message": f"Found {doc_count} relevant documents" + if doc_count + else f"Found results from {len(kb_text_parts)} queries", + }, + ) + logger.info( + f"[generate_report] KB search added ~{len(kb_combined)} chars " + f"from {len(kb_text_parts)} queries" + ) + else: + dispatch_custom_event( + "report_progress", + { + "phase": "kb_search_done", + "message": "No results found in knowledge base", + }, + ) + logger.info("[generate_report] KB search returned no results") + + except Exception as e: + logger.warning( + f"[generate_report] Internal KB search failed: {e}. " + "Proceeding with existing source_content." + ) + elif needs_kb_search and not connector_service: + logger.warning( + "[generate_report] KB search requested but connector_service " + "not available. Using source_content as-is." + ) + elif needs_kb_search and not search_queries: + logger.warning( + "[generate_report] KB search requested but no search_queries " + "provided. Using source_content as-is." + ) + + capped_source = effective_source[:100000] # Cap source content + + # Length constraint — only when user explicitly asks for brevity + length_instruction = "" + if report_style == "brief": + length_instruction = ( + "**LENGTH CONSTRAINT (MANDATORY):** The user wants a SHORT report. " + "Keep it concise — aim for ~400 words (~1 page) unless a different " + "length is specified in the Additional Instructions above. " + "Prioritize brevity over thoroughness. Do NOT write a long report." + ) + + # ── Phase 2: LLM GENERATION (no DB connection held) ────────── + + report_content: str | None = None + + if parent_report_content: + # ─── REVISION MODE ─────────────────────────────────────── + # Strategy: Try section-level revision first (preserves + # unchanged sections byte-for-byte). Falls back to full- + # document revision if section identification fails or if + # all sections need changes. + dispatch_custom_event( + "report_progress", + { + "phase": "revision_start", + "message": "Analyzing sections to modify...", + }, + ) + logger.info( + "[generate_report] Revision mode — attempting section-level revision" + ) + report_content = await _revise_with_sections( + llm=llm, + parent_content=parent_report_content, + user_instructions=user_instructions + or "Improve and refine the report.", + source_content=capped_source, + topic=topic, + report_style=report_style, + ) + + if report_content is None: + # Fallback: full-document revision + dispatch_custom_event( + "report_progress", + {"phase": "writing", "message": "Rewriting your full report"}, + ) + logger.info( + "[generate_report] Section-level revision deferred, " + "using full-document revision" + ) + prompt = _REVISION_PROMPT.format( + topic=topic, + report_style=report_style, + user_instructions_section=user_instructions_section + or "Improve and refine the report.", + source_content=capped_source, + previous_report_content=parent_report_content, + length_instruction=length_instruction, + formatting_rules=_FORMATTING_RULES, + ) + response = await llm.ainvoke([HumanMessage(content=prompt)]) + report_content = response.content + + else: + # ─── NEW REPORT MODE ───────────────────────────────────── + # Single-shot generation: one LLM call produces the full + # report. Fast, globally coherent, and cost-efficient. + dispatch_custom_event( + "report_progress", + {"phase": "writing", "message": "Writing your report"}, + ) + logger.info( + "[generate_report] New report — using single-shot generation" + ) + prompt = _REPORT_PROMPT.format( + topic=topic, + report_style=report_style, + user_instructions_section=user_instructions_section, + previous_version_section="", + source_content=capped_source, + length_instruction=length_instruction, + formatting_rules=_FORMATTING_RULES, + ) + response = await llm.ainvoke([HumanMessage(content=prompt)]) + report_content = response.content + + # ── Validate LLM output ────────────────────────────────────── + + if not report_content or not isinstance(report_content, str): + error_msg = "LLM returned empty or invalid content" + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": topic, + } + + # LLMs often wrap output in ```markdown ... ``` fences — strip them + report_content = _strip_wrapping_code_fences(report_content) + + if not report_content: + error_msg = "LLM returned empty or invalid content" + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": topic, + } + + # Strip any existing footer(s) carried over from parent version(s) + while report_content.rstrip().endswith(_REPORT_FOOTER): + idx = report_content.rstrip().rfind(_REPORT_FOOTER) + report_content = report_content[:idx].rstrip() + if report_content.rstrip().endswith("---"): + report_content = report_content.rstrip()[:-3].rstrip() + + # Append exactly one standard disclaimer + report_content += "\n\n---\n\n" + _REPORT_FOOTER + + # Extract metadata (includes "status": "ready") + metadata = _extract_metadata(report_content) + + # ── Phase 3: WRITE (short-lived session) ───────────────────── + # Save the report to the database, then close the session. + async with shielded_async_session() as write_session: + report = Report( + title=topic, + content=report_content, + report_metadata=metadata, + report_style=report_style, + search_space_id=search_space_id, + thread_id=thread_id, + report_group_id=report_group_id, + ) + write_session.add(report) + await write_session.commit() + await write_session.refresh(report) + + # If this is a brand-new report (v1), set report_group_id = own id + if not report.report_group_id: + report.report_group_id = report.id + await write_session.commit() + + saved_report_id = report.id + saved_group_id = report.report_group_id + # write_session closed — connection returned to pool + + logger.info( + f"[generate_report] Created report {saved_report_id} " + f"(group={saved_group_id}): " + f"{metadata.get('word_count', 0)} words, " + f"{metadata.get('section_count', 0)} sections" + ) + + return { + "status": "ready", + "report_id": saved_report_id, + "title": topic, + "word_count": metadata.get("word_count", 0), + "is_revision": bool(parent_report_content), + "report_markdown": report_content, + "message": f"Report generated successfully: {topic}", + } + + except Exception as e: + error_message = str(e) + logger.exception(f"[generate_report] Error: {error_message}") + report_id = await _save_failed_report(error_message) + + return { + "status": "failed", + "error": error_message, + "report_id": report_id, + "title": topic, + } + + return generate_report diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/resume.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/resume.py new file mode 100644 index 000000000..ece3ce241 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/resume.py @@ -0,0 +1,799 @@ +"""Resume as Typst: LLM fills the body; backend prepends a template from ``_TEMPLATES`` and compiles.""" + +import io +import logging +import re +from datetime import UTC, datetime +from typing import Any + +import pypdf +import typst +from langchain_core.callbacks import dispatch_custom_event +from langchain_core.messages import HumanMessage +from langchain_core.tools import tool + +from app.db import Report, shielded_async_session +from app.services.llm_service import get_document_summary_llm + +logger = logging.getLogger(__name__) + + +# ─── Template Registry ─────────────────────────────────────────────────────── +# Each template defines: +# header - Typst import + show rule with {name}, {year}, {month}, {day} placeholders +# component_reference - component docs shown to the LLM +# rules - generation rules for the LLM + +_TEMPLATES: dict[str, dict[str, str]] = { + "classic": { + "header": """\ +#import "@preview/rendercv:0.3.0": * + +#show: rendercv.with( + name: "{name}", + title: "{name} - Resume", + footer: context {{ [#emph[{name} -- #str(here().page())\\/#str(counter(page).final().first())]] }}, + top-note: [ #emph[Last updated in {month_name} {year}] ], + locale-catalog-language: "en", + text-direction: ltr, + page-size: "us-letter", + page-top-margin: 0.7in, + page-bottom-margin: 0.7in, + page-left-margin: 0.7in, + page-right-margin: 0.7in, + page-show-footer: false, + page-show-top-note: true, + colors-body: rgb(0, 0, 0), + colors-name: rgb(0, 0, 0), + colors-headline: rgb(0, 0, 0), + colors-connections: rgb(0, 0, 0), + colors-section-titles: rgb(0, 0, 0), + colors-links: rgb(0, 0, 0), + colors-footer: rgb(128, 128, 128), + colors-top-note: rgb(128, 128, 128), + typography-line-spacing: 0.6em, + typography-alignment: "justified", + typography-date-and-location-column-alignment: right, + typography-font-family-body: "XCharter", + typography-font-family-name: "XCharter", + typography-font-family-headline: "XCharter", + typography-font-family-connections: "XCharter", + typography-font-family-section-titles: "XCharter", + typography-font-size-body: 10pt, + typography-font-size-name: 25pt, + typography-font-size-headline: 10pt, + typography-font-size-connections: 10pt, + typography-font-size-section-titles: 1.2em, + typography-small-caps-name: false, + typography-small-caps-headline: false, + typography-small-caps-connections: false, + typography-small-caps-section-titles: false, + typography-bold-name: false, + typography-bold-headline: false, + typography-bold-connections: false, + typography-bold-section-titles: true, + links-underline: true, + links-show-external-link-icon: false, + header-alignment: center, + header-photo-width: 3.5cm, + header-space-below-name: 0.7cm, + header-space-below-headline: 0.7cm, + header-space-below-connections: 0.7cm, + header-connections-hyperlink: true, + header-connections-show-icons: false, + header-connections-display-urls-instead-of-usernames: true, + header-connections-separator: "|", + header-connections-space-between-connections: 0.5cm, + section-titles-type: "with_full_line", + section-titles-line-thickness: 0.5pt, + section-titles-space-above: 0.5cm, + section-titles-space-below: 0.3cm, + sections-allow-page-break: true, + sections-space-between-text-based-entries: 0.15cm, + sections-space-between-regular-entries: 0.42cm, + entries-date-and-location-width: 4.15cm, + entries-side-space: 0cm, + entries-space-between-columns: 0.1cm, + entries-allow-page-break: false, + entries-short-second-row: false, + entries-degree-width: 1cm, + entries-summary-space-left: 0cm, + entries-summary-space-above: 0.08cm, + entries-highlights-bullet: text(13pt, [\\u{2022}], baseline: -0.6pt), + entries-highlights-nested-bullet: text(13pt, [\\u{2022}], baseline: -0.6pt), + entries-highlights-space-left: 0cm, + entries-highlights-space-above: 0.08cm, + entries-highlights-space-between-items: 0.02cm, + entries-highlights-space-between-bullet-and-text: 0.3em, + date: datetime( + year: {year}, + month: {month}, + day: {day}, + ), +) + +""", + "component_reference": """\ +Available components (use ONLY these): + += Full Name // Top-level heading — person's full name + +#connections( // Contact info row (pipe-separated) + [City, Country], + [#link("mailto:email@example.com", icon: false, if-underline: false, if-color: false)[email\\@example.com]], + [#link("https://linkedin.com/in/user", icon: false, if-underline: false, if-color: false)[linkedin.com\\/in\\/user]], + [#link("https://github.com/user", icon: false, if-underline: false, if-color: false)[github.com\\/user]], +) + +== Section Title // Section heading (arbitrary name) + +#regular-entry( // Work experience, projects, publications, etc. + [ + #strong[Role/Title], Company Name -- Location + ], + [ + Start -- End + ], + main-column-second-row: [ + - Achievement or responsibility + - Another bullet point + ], +) + +#education-entry( // Education entries + [ + #strong[Institution], Degree in Field -- Location + ], + [ + Start -- End + ], + main-column-second-row: [ + - GPA, honours, relevant coursework + ], +) + +#summary([Short paragraph summary]) // Optional summary inside an entry +#content-area([Free-form content]) // Freeform text block + +For skills sections, use one bullet per category label: +- #strong[Category:] item1, item2, item3 + +For simple list sections (e.g. Honors), use plain bullet points: +- Item one +- Item two +""", + "rules": """\ +RULES: +- Do NOT include any #import or #show lines. Start directly with = Full Name. +- Output ONLY valid Typst content. No explanatory text before or after. +- Do NOT wrap output in ```typst code fences. +- The = heading MUST use the person's COMPLETE full name exactly as provided. NEVER shorten or abbreviate. +- Escape @ symbols inside link labels with a backslash: email\\@example.com +- Escape forward slashes in link display text: linkedin.com\\/in\\/user +- Every section MUST use == heading. +- Use #regular-entry() for experience, projects, publications, certifications, and similar entries. +- Use #education-entry() for education. +- For skills sections, use one bullet line per category with a bold label. +- Keep content professional, concise, and achievement-oriented. +- Use action verbs for bullet points (Led, Built, Designed, Reduced, etc.). +- This template works for ALL professions — adapt sections to the user's field. +- Default behavior should prioritize concise one-page content. +""", + }, +} + +DEFAULT_TEMPLATE = "classic" +MIN_RESUME_PAGES = 1 +MAX_RESUME_PAGES = 5 +MAX_COMPRESSION_ATTEMPTS = 2 + + +# ─── Template Helpers ───────────────────────────────────────────────────────── + + +def _get_template(template_id: str | None = None) -> dict[str, str]: + """Get a template by ID, falling back to default.""" + return _TEMPLATES.get(template_id or DEFAULT_TEMPLATE, _TEMPLATES[DEFAULT_TEMPLATE]) + + +_MONTH_NAMES = [ + "", + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +] + + +def _build_header(template: dict[str, str], name: str) -> str: + """Build the template header with the person's name and current date.""" + now = datetime.now(tz=UTC) + return ( + template["header"] + .replace("{name}", name) + .replace("{year}", str(now.year)) + .replace("{month}", str(now.month)) + .replace("{day}", str(now.day)) + .replace("{month_name}", _MONTH_NAMES[now.month]) + ) + + +def _strip_header(full_source: str) -> str: + """Strip the import + show rule from stored source to get the body only. + + Finds the closing parenthesis of the rendercv.with(...) block by tracking + nesting depth, then returns everything after it. + """ + show_match = re.search(r"#show:\s*rendercv\.with\(", full_source) + if not show_match: + return full_source + + start = show_match.end() + depth = 1 + i = start + while i < len(full_source) and depth > 0: + if full_source[i] == "(": + depth += 1 + elif full_source[i] == ")": + depth -= 1 + i += 1 + + return full_source[i:].lstrip("\n") + + +def _extract_name(body: str) -> str | None: + """Extract the person's full name from the = heading in the body.""" + match = re.search(r"^=\s+(.+)$", body, re.MULTILINE) + return match.group(1).strip() if match else None + + +def _strip_imports(body: str) -> str: + """Remove any #import or #show lines the LLM might accidentally include.""" + lines = body.split("\n") + cleaned: list[str] = [] + skip_show = False + depth = 0 + + for line in lines: + stripped = line.strip() + + if stripped.startswith("#import"): + continue + + if skip_show: + depth += stripped.count("(") - stripped.count(")") + if depth <= 0: + skip_show = False + continue + + if stripped.startswith("#show:") and "rendercv" in stripped: + depth = stripped.count("(") - stripped.count(")") + if depth > 0: + skip_show = True + continue + + cleaned.append(line) + + result = "\n".join(cleaned).strip() + return result + + +def _build_llm_reference(template: dict[str, str]) -> str: + """Build the LLM prompt reference from a template.""" + return f"""\ +You MUST output valid Typst content for a resume. +Do NOT include any #import or #show lines — those are handled automatically. +Start directly with the = Full Name heading. + +{template["component_reference"]} + +{template["rules"]}""" + + +# ─── Prompts ───────────────────────────────────────────────────────────────── + +_RESUME_PROMPT = """\ +You are an expert resume writer. Generate professional resume content as Typst markup. + +{llm_reference} + +**User Information:** +{user_info} + +**Target Maximum Pages:** {max_pages} + +{user_instructions_section} + +Generate the resume content now (starting with = Full Name): +""" + +_REVISION_PROMPT = """\ +You are an expert resume editor. Modify the existing resume according to the instructions. +Apply ONLY the requested changes — do NOT rewrite sections that are not affected. + +{llm_reference} + +**Target Maximum Pages:** {max_pages} + +**Modification Instructions:** {user_instructions} + +**EXISTING RESUME CONTENT:** + +{previous_content} + +--- + +Output the complete, updated resume content with the changes applied (starting with = Full Name): +""" + +_FIX_COMPILE_PROMPT = """\ +The resume content you generated failed to compile. Fix the error while preserving all content. + +{llm_reference} + +**Compilation Error:** +{error} + +**Full Typst Source (for context — error line numbers refer to this):** +{full_source} + +**Your content starts after the template header. Output ONLY the content portion \ +(starting with = Full Name), NOT the #import or #show rule:** +""" + +_COMPRESS_TO_PAGE_LIMIT_PROMPT = """\ +The resume compiles, but it exceeds the maximum allowed page count. +Compress the resume while preserving high-impact accomplishments and role relevance. + +{llm_reference} + +**Target Maximum Pages:** {max_pages} +**Current Page Count:** {actual_pages} +**Compression Attempt:** {attempt_number} + +Compression priorities (in this order): +1) Keep recent, high-impact, role-relevant bullets. +2) Remove low-impact or redundant bullets. +3) Shorten verbose wording while preserving meaning. +4) Trim older or less relevant details before recent ones. + +Return the complete updated Typst content (starting with = Full Name), and keep it at or below the target pages. + +**EXISTING RESUME CONTENT:** +{previous_content} +""" + + +# ─── Helpers ───────────────────────────────────────────────────────────────── + + +def _strip_typst_fences(text: str) -> str: + """Remove wrapping ```typst ... ``` fences that LLMs sometimes add.""" + stripped = text.strip() + m = re.match(r"^(`{3,})(?:typst|typ)?\s*\n", stripped) + if m: + fence = m.group(1) + if stripped.endswith(fence): + stripped = stripped[m.end() :] + stripped = stripped[: -len(fence)].rstrip() + return stripped + + +def _compile_typst(source: str) -> bytes: + """Compile Typst source to PDF bytes. Raises on failure.""" + return typst.compile(source.encode("utf-8")) + + +def _count_pdf_pages(pdf_bytes: bytes) -> int: + """Count the number of pages in compiled PDF bytes.""" + with io.BytesIO(pdf_bytes) as pdf_stream: + reader = pypdf.PdfReader(pdf_stream) + return len(reader.pages) + + +def _validate_max_pages(max_pages: int) -> int: + """Validate and normalize max_pages input.""" + if MIN_RESUME_PAGES <= max_pages <= MAX_RESUME_PAGES: + return max_pages + msg = ( + f"max_pages must be between {MIN_RESUME_PAGES} and " + f"{MAX_RESUME_PAGES}. Received: {max_pages}" + ) + raise ValueError(msg) + + +# ─── Tool Factory ─────────────────────────────────────────────────────────── + + +def create_generate_resume_tool( + search_space_id: int, + thread_id: int | None = None, +): + """ + Factory function to create the generate_resume tool. + + Generates a Typst-based resume, validates it via compilation, + and stores the source in the Report table with content_type='typst'. + The LLM generates only the content body; the template header is + prepended by the backend. + """ + + @tool + async def generate_resume( + user_info: str, + user_instructions: str | None = None, + parent_report_id: int | None = None, + max_pages: int = 1, + ) -> dict[str, Any]: + """ + Generate a professional resume as a Typst document. + + Use this tool when the user asks to create, build, generate, write, + or draft a resume or CV. Also use it when the user wants to modify, + update, or revise an existing resume generated in this conversation. + + Trigger phrases include: + - "build me a resume", "create my resume", "generate a CV" + - "update my resume", "change my title", "add my new job" + - "make my resume more concise", "reformat my resume" + + Do NOT use this tool for: + - General questions about resumes or career advice + - Reviewing or critiquing a resume without changes + - Cover letters (use generate_report instead) + + VERSIONING — parent_report_id: + - Set parent_report_id when the user wants to MODIFY an existing + resume that was already generated in this conversation. + - Leave as None for new resumes. + + Args: + user_info: The user's resume content — work experience, + education, skills, contact info, etc. Can be structured + or unstructured text. + user_instructions: Optional style or content preferences + (e.g. "emphasize leadership", "keep it to one page", + "use a modern style"). For revisions, describe what to change. + parent_report_id: ID of a previous resume to revise (creates + new version in the same version group). + max_pages: Maximum number of pages for the generated resume. + Defaults to 1. Allowed range: 1-5. + + Returns: + Dict with status, report_id, title, and content_type. + """ + report_group_id: int | None = None + parent_content: str | None = None + + template = _get_template() + llm_reference = _build_llm_reference(template) + + async def _save_failed_report(error_msg: str) -> int | None: + try: + async with shielded_async_session() as session: + failed = Report( + title="Resume", + content=None, + content_type="typst", + report_metadata={ + "status": "failed", + "error_message": error_msg, + }, + report_style="resume", + search_space_id=search_space_id, + thread_id=thread_id, + report_group_id=report_group_id, + ) + session.add(failed) + await session.commit() + await session.refresh(failed) + if not failed.report_group_id: + failed.report_group_id = failed.id + await session.commit() + logger.info( + f"[generate_resume] Saved failed report {failed.id}: {error_msg}" + ) + return failed.id + except Exception: + logger.exception( + "[generate_resume] Could not persist failed report row" + ) + return None + + try: + try: + validated_max_pages = _validate_max_pages(max_pages) + except ValueError as e: + error_msg = str(e) + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + # ── Phase 1: READ ───────────────────────────────────────────── + async with shielded_async_session() as read_session: + if parent_report_id: + parent_report = await read_session.get(Report, parent_report_id) + if parent_report: + report_group_id = parent_report.report_group_id + parent_content = parent_report.content + logger.info( + f"[generate_resume] Revising from parent {parent_report_id} " + f"(group {report_group_id})" + ) + + llm = await get_document_summary_llm(read_session, search_space_id) + + if not llm: + error_msg = ( + "No LLM configured. Please configure a language model in Settings." + ) + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + # ── Phase 2: LLM GENERATION ─────────────────────────────────── + + user_instructions_section = "" + if user_instructions: + user_instructions_section = ( + f"**Additional Instructions:** {user_instructions}" + ) + + if parent_content: + dispatch_custom_event( + "report_progress", + {"phase": "writing", "message": "Updating your resume"}, + ) + parent_body = _strip_header(parent_content) + prompt = _REVISION_PROMPT.format( + llm_reference=llm_reference, + max_pages=validated_max_pages, + user_instructions=user_instructions + or "Improve and refine the resume.", + previous_content=parent_body, + ) + else: + dispatch_custom_event( + "report_progress", + {"phase": "writing", "message": "Building your resume"}, + ) + prompt = _RESUME_PROMPT.format( + llm_reference=llm_reference, + user_info=user_info, + max_pages=validated_max_pages, + user_instructions_section=user_instructions_section, + ) + + response = await llm.ainvoke([HumanMessage(content=prompt)]) + body = response.content + + if not body or not isinstance(body, str): + error_msg = "LLM returned empty or invalid content" + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + body = _strip_typst_fences(body) + body = _strip_imports(body) + + # ── Phase 3: ASSEMBLE + COMPILE ─────────────────────────────── + dispatch_custom_event( + "report_progress", + {"phase": "compiling", "message": "Compiling resume..."}, + ) + + name = _extract_name(body) or "Resume" + typst_source = "" + actual_pages = 0 + compression_attempts = 0 + target_page_met = False + + for compression_round in range(MAX_COMPRESSION_ATTEMPTS + 1): + header = _build_header(template, name) + typst_source = header + body + compile_error: str | None = None + pdf_bytes: bytes | None = None + + for compile_attempt in range(2): + try: + pdf_bytes = _compile_typst(typst_source) + compile_error = None + break + except Exception as e: + compile_error = str(e) + logger.warning( + "[generate_resume] Compile attempt %s failed: %s", + compile_attempt + 1, + compile_error, + ) + + if compile_attempt == 0: + dispatch_custom_event( + "report_progress", + { + "phase": "fixing", + "message": "Fixing compilation issue...", + }, + ) + fix_prompt = _FIX_COMPILE_PROMPT.format( + llm_reference=llm_reference, + error=compile_error, + full_source=typst_source, + ) + fix_response = await llm.ainvoke( + [HumanMessage(content=fix_prompt)] + ) + if fix_response.content and isinstance( + fix_response.content, str + ): + body = _strip_typst_fences(fix_response.content) + body = _strip_imports(body) + name = _extract_name(body) or name + header = _build_header(template, name) + typst_source = header + body + + if compile_error or not pdf_bytes: + error_msg = ( + "Typst compilation failed after 2 attempts: " + f"{compile_error or 'Unknown compile error'}" + ) + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + actual_pages = _count_pdf_pages(pdf_bytes) + if actual_pages <= validated_max_pages: + target_page_met = True + break + + if compression_round >= MAX_COMPRESSION_ATTEMPTS: + break + + compression_attempts += 1 + dispatch_custom_event( + "report_progress", + { + "phase": "compressing", + "message": f"Condensing resume to {validated_max_pages} page(s)...", + }, + ) + compress_prompt = _COMPRESS_TO_PAGE_LIMIT_PROMPT.format( + llm_reference=llm_reference, + max_pages=validated_max_pages, + actual_pages=actual_pages, + attempt_number=compression_attempts, + previous_content=body, + ) + compress_response = await llm.ainvoke( + [HumanMessage(content=compress_prompt)] + ) + if not compress_response.content or not isinstance( + compress_response.content, str + ): + error_msg = "LLM returned empty content while compressing resume" + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + body = _strip_typst_fences(compress_response.content) + body = _strip_imports(body) + name = _extract_name(body) or name + + if actual_pages > MAX_RESUME_PAGES: + error_msg = ( + "Resume exceeds hard page limit after compression retries. " + f"Hard limit: <= {MAX_RESUME_PAGES} page(s), actual: {actual_pages}." + ) + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + # ── Phase 4: SAVE ───────────────────────────────────────────── + dispatch_custom_event( + "report_progress", + {"phase": "saving", "message": "Saving your resume"}, + ) + + resume_title = f"{name} - Resume" if name != "Resume" else "Resume" + + metadata: dict[str, Any] = { + "status": "ready", + "word_count": len(typst_source.split()), + "char_count": len(typst_source), + "target_max_pages": validated_max_pages, + "actual_page_count": actual_pages, + "page_limit_enforced": True, + "compression_attempts": compression_attempts, + "target_page_met": target_page_met, + } + + async with shielded_async_session() as write_session: + report = Report( + title=resume_title, + content=typst_source, + content_type="typst", + report_metadata=metadata, + report_style="resume", + search_space_id=search_space_id, + thread_id=thread_id, + report_group_id=report_group_id, + ) + write_session.add(report) + await write_session.commit() + await write_session.refresh(report) + + if not report.report_group_id: + report.report_group_id = report.id + await write_session.commit() + + saved_id = report.id + + logger.info(f"[generate_resume] Created resume {saved_id}: {resume_title}") + + return { + "status": "ready", + "report_id": saved_id, + "title": resume_title, + "content_type": "typst", + "is_revision": bool(parent_content), + "message": ( + f"Resume generated successfully: {resume_title}" + if target_page_met + else ( + f"Resume generated, but could not fit the target of <= {validated_max_pages} " + f"page(s). Final length: {actual_pages} page(s)." + ) + ), + } + + except Exception as e: + error_message = str(e) + logger.exception(f"[generate_resume] Error: {error_message}") + report_id = await _save_failed_report(error_message) + return { + "status": "failed", + "error": error_message, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + return generate_resume diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/video_presentation.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/video_presentation.py new file mode 100644 index 000000000..a9f3447ab --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/video_presentation.py @@ -0,0 +1,80 @@ +"""Factory for a video-presentation tool that queues background work and returns an ID for polling.""" + +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import VideoPresentation, VideoPresentationStatus, shielded_async_session + + +def create_generate_video_presentation_tool( + search_space_id: int, + db_session: AsyncSession, + thread_id: int | None = None, +): + """Create ``generate_video_presentation`` with bound search space and thread; writes use a tool-local session.""" + del db_session # writes use a fresh tool-local session, see below + + @tool + async def generate_video_presentation( + source_content: str, + video_title: str = "SurfSense Presentation", + user_prompt: str | None = None, + ) -> dict[str, Any]: + """Generate a video presentation from the provided content. + + Use this tool when the user asks to create a video, presentation, slides, or slide deck. + + Args: + source_content: The text content to turn into a presentation. + video_title: Title for the presentation (default: "SurfSense Presentation") + user_prompt: Optional style/tone instructions. + """ + try: + # One DB session per tool call so parallel invocations never share an AsyncSession. + async with shielded_async_session() as session: + video_pres = VideoPresentation( + title=video_title, + status=VideoPresentationStatus.PENDING, + search_space_id=search_space_id, + thread_id=thread_id, + ) + session.add(video_pres) + await session.commit() + await session.refresh(video_pres) + video_pres_id = video_pres.id + + from app.tasks.celery_tasks.video_presentation_tasks import ( + generate_video_presentation_task, + ) + + task = generate_video_presentation_task.delay( + video_presentation_id=video_pres_id, + source_content=source_content, + search_space_id=search_space_id, + user_prompt=user_prompt, + ) + + print( + f"[generate_video_presentation] Created video presentation {video_pres_id}, task: {task.id}" + ) + + return { + "status": VideoPresentationStatus.PENDING.value, + "video_presentation_id": video_pres_id, + "title": video_title, + "message": "Video presentation generation started. This may take a few minutes.", + } + + except Exception as e: + error_message = str(e) + print(f"[generate_video_presentation] Error: {error_message}") + return { + "status": VideoPresentationStatus.FAILED.value, + "error": error_message, + "title": video_title, + "video_presentation_id": None, + } + + return generate_video_presentation diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/general_purpose/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/general_purpose/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/general_purpose/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/general_purpose/agent.py new file mode 100644 index 000000000..1c3c44f12 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/general_purpose/agent.py @@ -0,0 +1,105 @@ +"""General-purpose subagent for the multi-agent main agent.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any, cast + +from deepagents import SubAgent +from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware +from deepagents.middleware.subagents import GENERAL_PURPOSE_SUBAGENT +from langchain_anthropic.middleware import AnthropicPromptCachingMiddleware +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.middleware.shared.anthropic_cache import ( + build_anthropic_cache_mw, +) +from app.agents.multi_agent_chat.middleware.shared.compaction import ( + build_compaction_mw, +) +from app.agents.multi_agent_chat.middleware.shared.file_intent import ( + build_file_intent_mw, +) +from app.agents.multi_agent_chat.middleware.shared.filesystem import ( + build_filesystem_mw, +) +from app.agents.multi_agent_chat.middleware.shared.patch_tool_calls import ( + build_patch_tool_calls_mw, +) +from app.agents.multi_agent_chat.middleware.shared.permissions import ( + PermissionContext, +) +from app.agents.multi_agent_chat.middleware.shared.resilience import ( + ResilienceBundle, +) +from app.agents.multi_agent_chat.middleware.shared.todos import build_todos_mw +from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.agents.new_chat.middleware import MemoryInjectionMiddleware + +NAME = "general-purpose" + + +def build_subagent( + *, + llm: BaseChatModel, + tools: Sequence[BaseTool], + backend_resolver: Any, + filesystem_mode: FilesystemMode, + search_space_id: int, + user_id: str | None, + thread_id: int | None, + permissions: PermissionContext, + resilience: ResilienceBundle, + memory_mw: MemoryInjectionMiddleware, +) -> SubAgent: + """Deny + resilience inserts encapsulated here so the orchestrator never mutates the list.""" + middleware: list[Any] = [ + build_todos_mw(), + memory_mw, + build_file_intent_mw(llm), + build_filesystem_mw( + backend_resolver=backend_resolver, + filesystem_mode=filesystem_mode, + search_space_id=search_space_id, + user_id=user_id, + thread_id=thread_id, + ), + build_compaction_mw(llm), + build_patch_tool_calls_mw(), + build_anthropic_cache_mw(), + ] + + if permissions.subagent_deny_mw is not None: + patch_idx = next( + ( + i + for i, m in enumerate(middleware) + if isinstance(m, PatchToolCallsMiddleware) + ), + len(middleware), + ) + middleware.insert(patch_idx, permissions.subagent_deny_mw) + + resilience_mws = resilience.as_list() + if resilience_mws: + cache_idx = next( + ( + i + for i, m in enumerate(middleware) + if isinstance(m, AnthropicPromptCachingMiddleware) + ), + len(middleware), + ) + for offset, mw in enumerate(resilience_mws): + middleware.insert(cache_idx + offset, mw) + + spec: dict[str, Any] = { + **GENERAL_PURPOSE_SUBAGENT, + "model": llm, + "tools": tools, + "middleware": middleware, + } + if permissions.general_purpose_interrupt_on: + spec["interrupt_on"] = permissions.general_purpose_interrupt_on + return cast(SubAgent, spec) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/agent.py new file mode 100644 index 000000000..0afe207ce --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/agent.py @@ -0,0 +1,55 @@ +"""`memory` route: ``SubAgent`` spec for deepagents.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from deepagents import SubAgent +from langchain_core.language_models import BaseChatModel + +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, + middleware_gated_interrupt_on, +) +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( + pack_subagent, +) + +from .tools.index import load_tools + +NAME = "memory" + + +def build_subagent( + *, + dependencies: dict[str, Any], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, + extra_tools_bucket: ToolsPermissions | None = None, +) -> SubAgent: + buckets = load_tools(dependencies=dependencies) + merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket) + tools = [ + row["tool"] + for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) + if row.get("tool") is not None + ] + interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket) + description = read_md_file(__package__, "description").strip() + if not description: + description = "Handles memory tasks for this workspace." + system_prompt = read_md_file(__package__, "system_prompt").strip() + return pack_subagent( + name=NAME, + description=description, + system_prompt=system_prompt, + tools=tools, + interrupt_on=interrupt_on, + model=model, + extra_middleware=extra_middleware, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/description.md new file mode 100644 index 000000000..4c2cdcd0e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/description.md @@ -0,0 +1 @@ +Use for storing durable user memory (private team variant selected at runtime). diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/system_prompt.md new file mode 100644 index 000000000..32becf233 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/system_prompt.md @@ -0,0 +1,56 @@ +You are the SurfSense memory operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Persist durable preferences/facts/instructions with `update_memory` while avoiding transient or unsafe storage. + + + +{{MEMORY_VISIBILITY_POLICY}} + + + +- `update_memory` + + + +- Save only durable information with future value. +- Do not store transient chatter. +- Do not store secrets unless explicitly instructed. +- If memory intent is unclear, return `status=blocked` with the missing intent signal. + + + +- Do not execute non-memory tool actions. +- Do not store irrelevant, transient, or speculative information. + + + +- Prefer minimal-memory writes over over-collection. +- Never claim memory was updated unless `update_memory` succeeded. + + + +- On tool failure, return `status=error` with concise recovery steps. +- When intent is ambiguous, return `status=blocked` with required disambiguation fields. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "memory_updated": boolean, + "memory_category": "preference" | "fact" | "instruction" | null, + "stored_summary": string | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/__init__.py new file mode 100644 index 000000000..0441a8cb4 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/__init__.py @@ -0,0 +1,8 @@ +"""Memory tools: persist user or team markdown memory for later turns.""" + +from .update_memory import create_update_memory_tool, create_update_team_memory_tool + +__all__ = [ + "create_update_memory_tool", + "create_update_team_memory_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/index.py new file mode 100644 index 000000000..6c65b2cee --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/index.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) +from app.db import ChatVisibility + +from .update_memory import create_update_memory_tool, create_update_team_memory_tool + + +def load_tools( + *, dependencies: dict[str, Any] | None = None, **kwargs: Any +) -> ToolsPermissions: + resolved_dependencies = {**(dependencies or {}), **kwargs} + if resolved_dependencies.get("thread_visibility") == ChatVisibility.SEARCH_SPACE: + mem = create_update_team_memory_tool( + search_space_id=resolved_dependencies["search_space_id"], + db_session=resolved_dependencies["db_session"], + llm=resolved_dependencies.get("llm"), + ) + return { + "allow": [{"name": getattr(mem, "name", "") or "", "tool": mem}], + "ask": [], + } + mem = create_update_memory_tool( + user_id=resolved_dependencies["user_id"], + db_session=resolved_dependencies["db_session"], + llm=resolved_dependencies.get("llm"), + ) + return {"allow": [{"name": getattr(mem, "name", "") or "", "tool": mem}], "ask": []} diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/update_memory.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/update_memory.py new file mode 100644 index 000000000..23375a081 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/update_memory.py @@ -0,0 +1,375 @@ +"""Overwrite one markdown memory document per user or team, with size and shrink guards.""" + +from __future__ import annotations + +import logging +import re +from typing import Any, Literal +from uuid import UUID + +from langchain_core.messages import HumanMessage +from langchain_core.tools import tool +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import SearchSpace, User + +logger = logging.getLogger(__name__) + +MEMORY_SOFT_LIMIT = 18_000 +MEMORY_HARD_LIMIT = 25_000 + +_SECTION_HEADING_RE = re.compile(r"^##\s+(.+)$", re.MULTILINE) +_HEADING_NORMALIZE_RE = re.compile(r"\s+") + +_MARKER_RE = re.compile(r"\[(fact|pref|instr)\]") +_BULLET_FORMAT_RE = re.compile(r"^- \(\d{4}-\d{2}-\d{2}\) \[(fact|pref|instr)\] .+$") +_PERSONAL_ONLY_MARKERS = {"pref", "instr"} + + +# --------------------------------------------------------------------------- +# Diff validation +# --------------------------------------------------------------------------- + + +def _extract_headings(memory: str) -> set[str]: + """Return all ``## …`` heading texts (without the ``## `` prefix).""" + return set(_SECTION_HEADING_RE.findall(memory)) + + +def _normalize_heading(heading: str) -> str: + """Normalize heading text for robust scope checks.""" + return _HEADING_NORMALIZE_RE.sub(" ", heading.strip().lower()) + + +def _validate_memory_scope( + content: str, scope: Literal["user", "team"] +) -> dict[str, Any] | None: + """Reject personal-only markers ([pref], [instr]) in team memory.""" + if scope != "team": + return None + + markers = set(_MARKER_RE.findall(content)) + leaked = sorted(markers & _PERSONAL_ONLY_MARKERS) + if leaked: + tags = ", ".join(f"[{m}]" for m in leaked) + return { + "status": "error", + "message": ( + f"Team memory cannot include personal markers: {tags}. " + "Use [fact] only in team memory." + ), + } + return None + + +def _validate_bullet_format(content: str) -> list[str]: + """Return warnings for bullet lines that don't match the required format. + + Expected: ``- (YYYY-MM-DD) [fact|pref|instr] text`` + """ + warnings: list[str] = [] + for line in content.splitlines(): + stripped = line.strip() + if not stripped.startswith("- "): + continue + if not _BULLET_FORMAT_RE.match(stripped): + short = stripped[:80] + ("..." if len(stripped) > 80 else "") + warnings.append(f"Malformed bullet: {short}") + return warnings + + +def _validate_diff(old_memory: str | None, new_memory: str) -> list[str]: + """Return a list of warning strings about suspicious changes.""" + if not old_memory: + return [] + + warnings: list[str] = [] + old_headings = _extract_headings(old_memory) + new_headings = _extract_headings(new_memory) + dropped = old_headings - new_headings + if dropped: + names = ", ".join(sorted(dropped)) + warnings.append( + f"Sections removed: {names}. " + "If unintentional, the user can restore from the settings page." + ) + + old_len = len(old_memory) + new_len = len(new_memory) + if old_len > 0 and new_len < old_len * 0.4: + warnings.append( + f"Memory shrank significantly ({old_len:,} -> {new_len:,} chars). " + "Possible data loss." + ) + return warnings + + +# --------------------------------------------------------------------------- +# Size validation & soft warning +# --------------------------------------------------------------------------- + + +def _validate_memory_size(content: str) -> dict[str, Any] | None: + """Return an error/warning dict if *content* is too large, else None.""" + length = len(content) + if length > MEMORY_HARD_LIMIT: + return { + "status": "error", + "message": ( + f"Memory exceeds {MEMORY_HARD_LIMIT:,} character limit " + f"({length:,} chars). Consolidate by merging related items, " + "removing outdated entries, and shortening descriptions. " + "Then call update_memory again." + ), + } + return None + + +def _soft_warning(content: str) -> str | None: + """Return a warning string if content exceeds the soft limit.""" + length = len(content) + if length > MEMORY_SOFT_LIMIT: + return ( + f"Memory is at {length:,}/{MEMORY_HARD_LIMIT:,} characters. " + "Consolidate by merging related items and removing less important " + "entries on your next update." + ) + return None + + +# --------------------------------------------------------------------------- +# Forced rewrite when memory exceeds the hard limit +# --------------------------------------------------------------------------- + +_FORCED_REWRITE_PROMPT = """\ +You are a memory curator. The following memory document exceeds the character \ +limit and must be shortened. + +RULES: +1. Rewrite the document to be under {target} characters. +2. Preserve existing ## headings. Every entry must remain under a heading. You may merge + or rename headings to consolidate, but keep names personal and descriptive. +3. Priority for keeping content: [instr] > [pref] > [fact]. +4. Merge duplicate entries, remove outdated entries, shorten verbose descriptions. +5. Every bullet MUST have format: - (YYYY-MM-DD) [fact|pref|instr] text +6. Preserve the user's first name in entries — do not replace it with "the user". +7. Output ONLY the consolidated markdown — no explanations, no wrapping. + + +{content} +""" + + +async def _forced_rewrite(content: str, llm: Any) -> str | None: + """Use a focused LLM call to compress *content* under the hard limit. + + Returns the rewritten string, or ``None`` if the call fails. + """ + try: + prompt = _FORCED_REWRITE_PROMPT.format( + target=MEMORY_HARD_LIMIT, content=content + ) + response = await llm.ainvoke( + [HumanMessage(content=prompt)], + config={"tags": ["surfsense:internal"]}, + ) + text = ( + response.content + if isinstance(response.content, str) + else str(response.content) + ) + return text.strip() + except Exception: + logger.exception("Forced rewrite LLM call failed") + return None + + +# --------------------------------------------------------------------------- +# Shared save-and-respond logic +# --------------------------------------------------------------------------- + + +async def _save_memory( + *, + updated_memory: str, + old_memory: str | None, + llm: Any | None, + apply_fn, + commit_fn, + rollback_fn, + label: str, + scope: Literal["user", "team"], +) -> dict[str, Any]: + """Validate, optionally force-rewrite if over the hard limit, save, and + return a response dict. + + Parameters + ---------- + updated_memory : str + The new document the agent submitted. + old_memory : str | None + The previously persisted document (for diff checks). + llm : Any | None + LLM instance for forced rewrite (may be ``None``). + apply_fn : callable(str) -> None + Callback that sets the new memory on the ORM object. + commit_fn : coroutine + ``session.commit``. + rollback_fn : coroutine + ``session.rollback``. + label : str + Human label for log messages (e.g. "user memory", "team memory"). + """ + content = updated_memory + + # --- forced rewrite if over the hard limit --- + if len(content) > MEMORY_HARD_LIMIT and llm is not None: + rewritten = await _forced_rewrite(content, llm) + if rewritten is not None and len(rewritten) < len(content): + content = rewritten + + # --- hard-limit gate (reject if still too large after rewrite) --- + size_err = _validate_memory_size(content) + if size_err: + return size_err + + scope_err = _validate_memory_scope(content, scope) + if scope_err: + return scope_err + + # --- persist --- + try: + apply_fn(content) + await commit_fn() + except Exception as e: + logger.exception("Failed to update %s: %s", label, e) + await rollback_fn() + return {"status": "error", "message": f"Failed to update {label}: {e}"} + + # --- build response --- + resp: dict[str, Any] = { + "status": "saved", + "message": f"{label.capitalize()} updated.", + } + + if content is not updated_memory: + resp["notice"] = "Memory was automatically rewritten to fit within limits." + + diff_warnings = _validate_diff(old_memory, content) + if diff_warnings: + resp["diff_warnings"] = diff_warnings + + format_warnings = _validate_bullet_format(content) + if format_warnings: + resp["format_warnings"] = format_warnings + + warning = _soft_warning(content) + if warning: + resp["warning"] = warning + + return resp + + +# --------------------------------------------------------------------------- +# Tool factories +# --------------------------------------------------------------------------- + + +def create_update_memory_tool( + user_id: str | UUID, + db_session: AsyncSession, + llm: Any | None = None, +): + uid = UUID(user_id) if isinstance(user_id, str) else user_id + + @tool + async def update_memory(updated_memory: str) -> dict[str, Any]: + """Update the user's personal memory document. + + Your current memory is shown in in the system prompt. + When the user shares important long-term information (preferences, + facts, instructions, context), rewrite the memory document to include + the new information. Merge new facts with existing ones, update + contradictions, remove outdated entries, and keep it concise. + + Args: + updated_memory: The FULL updated markdown document (not a diff). + """ + try: + result = await db_session.execute(select(User).where(User.id == uid)) + user = result.scalars().first() + if not user: + return {"status": "error", "message": "User not found."} + + old_memory = user.memory_md + + return await _save_memory( + updated_memory=updated_memory, + old_memory=old_memory, + llm=llm, + apply_fn=lambda content: setattr(user, "memory_md", content), + commit_fn=db_session.commit, + rollback_fn=db_session.rollback, + label="memory", + scope="user", + ) + except Exception as e: + logger.exception("Failed to update user memory: %s", e) + await db_session.rollback() + return { + "status": "error", + "message": f"Failed to update memory: {e}", + } + + return update_memory + + +def create_update_team_memory_tool( + search_space_id: int, + db_session: AsyncSession, + llm: Any | None = None, +): + @tool + async def update_memory(updated_memory: str) -> dict[str, Any]: + """Update the team's shared memory document for this search space. + + Your current team memory is shown in in the system + prompt. When the team shares important long-term information + (decisions, conventions, key facts, priorities), rewrite the memory + document to include the new information. Merge new facts with + existing ones, update contradictions, remove outdated entries, and + keep it concise. + + Args: + updated_memory: The FULL updated markdown document (not a diff). + """ + try: + result = await db_session.execute( + select(SearchSpace).where(SearchSpace.id == search_space_id) + ) + space = result.scalars().first() + if not space: + return {"status": "error", "message": "Search space not found."} + + old_memory = space.shared_memory_md + + return await _save_memory( + updated_memory=updated_memory, + old_memory=old_memory, + llm=llm, + apply_fn=lambda content: setattr(space, "shared_memory_md", content), + commit_fn=db_session.commit, + rollback_fn=db_session.rollback, + label="team memory", + scope="team", + ) + except Exception as e: + logger.exception("Failed to update team memory: %s", e) + await db_session.rollback() + return { + "status": "error", + "message": f"Failed to update team memory: {e}", + } + + return update_memory diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/agent.py new file mode 100644 index 000000000..1b7998153 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/agent.py @@ -0,0 +1,55 @@ +"""`research` route: ``SubAgent`` spec for deepagents.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from deepagents import SubAgent +from langchain_core.language_models import BaseChatModel + +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, + middleware_gated_interrupt_on, +) +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( + pack_subagent, +) + +from .tools.index import load_tools + +NAME = "research" + + +def build_subagent( + *, + dependencies: dict[str, Any], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, + extra_tools_bucket: ToolsPermissions | None = None, +) -> SubAgent: + buckets = load_tools(dependencies=dependencies) + merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket) + tools = [ + row["tool"] + for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) + if row.get("tool") is not None + ] + interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket) + description = read_md_file(__package__, "description").strip() + if not description: + description = "Handles research tasks for this workspace." + system_prompt = read_md_file(__package__, "system_prompt").strip() + return pack_subagent( + name=NAME, + description=description, + system_prompt=system_prompt, + tools=tools, + interrupt_on=interrupt_on, + model=model, + extra_middleware=extra_middleware, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/description.md new file mode 100644 index 000000000..dd2ced3fb --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/description.md @@ -0,0 +1 @@ +Use for external research: find sources on the web, extract evidence, and answer documentation questions. diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/system_prompt.md new file mode 100644 index 000000000..cf558db62 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/system_prompt.md @@ -0,0 +1,53 @@ +You are the SurfSense research operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Gather and synthesize evidence using SurfSense research tools with clear citations and uncertainty reporting. + + + +- `web_search` +- `scrape_webpage` +- `search_surfsense_docs` + + + +- Use only tools in ``. +- Prefer primary and recent sources when recency matters. +- If the delegated request is underspecified, return `status=blocked` with the missing research constraints. +- Never fabricate facts, citations, URLs, or quote text. + + + +- Do not execute connector mutations (email/calendar/docs/chat writes) or deliverable generation. + + + +- Report uncertainty explicitly when evidence is incomplete or conflicting. +- Never present unverified claims as facts. + + + +- On tool failure, return `status=error` with a concise recovery `next_step`. +- On no useful evidence, return `status=blocked` with recommended narrower filters. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "findings": string[], + "sources": string[], + "confidence": "high" | "medium" | "low" + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/__init__.py new file mode 100644 index 000000000..414cc96f4 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/__init__.py @@ -0,0 +1,11 @@ +"""Research-stage tools: web search, scrape, and in-product doc search.""" + +from .scrape_webpage import create_scrape_webpage_tool +from .search_surfsense_docs import create_search_surfsense_docs_tool +from .web_search import create_web_search_tool + +__all__ = [ + "create_scrape_webpage_tool", + "create_search_surfsense_docs_tool", + "create_web_search_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/index.py new file mode 100644 index 000000000..3546d4d01 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/index.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) + +from .scrape_webpage import create_scrape_webpage_tool +from .search_surfsense_docs import create_search_surfsense_docs_tool +from .web_search import create_web_search_tool + + +def load_tools( + *, dependencies: dict[str, Any] | None = None, **kwargs: Any +) -> ToolsPermissions: + resolved_dependencies = {**(dependencies or {}), **kwargs} + web = create_web_search_tool( + search_space_id=resolved_dependencies.get("search_space_id"), + available_connectors=resolved_dependencies.get("available_connectors"), + ) + scrape = create_scrape_webpage_tool( + firecrawl_api_key=resolved_dependencies.get("firecrawl_api_key") + ) + docs = create_search_surfsense_docs_tool( + db_session=resolved_dependencies["db_session"] + ) + return { + "allow": [ + {"name": getattr(web, "name", "") or "", "tool": web}, + {"name": getattr(scrape, "name", "") or "", "tool": scrape}, + {"name": getattr(docs, "name", "") or "", "tool": docs}, + ], + "ask": [], + } diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/scrape_webpage.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/scrape_webpage.py new file mode 100644 index 000000000..bb7c8e5a3 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/scrape_webpage.py @@ -0,0 +1,300 @@ +"""Scrape pages via WebCrawlerConnector; YouTube URLs use the transcript API instead of HTML crawl.""" + +import hashlib +import logging +from typing import Any +from urllib.parse import urlparse + +import aiohttp +from fake_useragent import UserAgent +from langchain_core.tools import tool +from requests import Session +from youtube_transcript_api import YouTubeTranscriptApi + +from app.connectors.webcrawler_connector import WebCrawlerConnector +from app.tasks.document_processors.youtube_processor import get_youtube_video_id +from app.utils.proxy_config import get_requests_proxies + +logger = logging.getLogger(__name__) + + +def extract_domain(url: str) -> str: + """Extract the domain from a URL.""" + try: + parsed = urlparse(url) + domain = parsed.netloc + # Remove 'www.' prefix if present + if domain.startswith("www."): + domain = domain[4:] + return domain + except Exception: + return "" + + +def generate_scrape_id(url: str) -> str: + """Generate a unique ID for a scraped webpage.""" + hash_val = hashlib.md5(url.encode()).hexdigest()[:12] + return f"scrape-{hash_val}" + + +def truncate_content(content: str, max_length: int = 50000) -> tuple[str, bool]: + """ + Truncate content to a maximum length. + + Returns: + Tuple of (truncated_content, was_truncated) + """ + if len(content) <= max_length: + return content, False + + # Try to truncate at a sentence boundary + truncated = content[:max_length] + last_period = truncated.rfind(".") + last_newline = truncated.rfind("\n\n") + + # Use the later of the two boundaries, or just truncate + boundary = max(last_period, last_newline) + if boundary > max_length * 0.8: # Only use boundary if it's not too far back + truncated = content[: boundary + 1] + + return truncated + "\n\n[Content truncated...]", True + + +async def _scrape_youtube_video( + url: str, video_id: str, max_length: int +) -> dict[str, Any]: + """ + Fetch YouTube video metadata and transcript via the YouTubeTranscriptApi. + + Returns a result dict in the same shape as the regular scrape_webpage output. + """ + scrape_id = generate_scrape_id(url) + domain = "youtube.com" + + # --- Video metadata via oEmbed --- + residential_proxies = get_requests_proxies() + + params = { + "format": "json", + "url": f"https://www.youtube.com/watch?v={video_id}", + } + oembed_url = "https://www.youtube.com/oembed" + + try: + async with ( + aiohttp.ClientSession() as http_session, + http_session.get( + oembed_url, + params=params, + proxy=residential_proxies["http"] if residential_proxies else None, + ) as response, + ): + video_data = await response.json() + except Exception: + video_data = {} + + title = video_data.get("title", "YouTube Video") + author = video_data.get("author_name", "Unknown") + + # --- Transcript via YouTubeTranscriptApi --- + try: + ua = UserAgent() + http_client = Session() + http_client.headers.update({"User-Agent": ua.random}) + if residential_proxies: + http_client.proxies.update(residential_proxies) + ytt_api = YouTubeTranscriptApi(http_client=http_client) + + # List all available transcripts and pick the first one + # (the video's primary language) instead of defaulting to English + transcript_list = ytt_api.list(video_id) + transcript = next(iter(transcript_list)) + captions = transcript.fetch() + + logger.info( + f"[scrape_webpage] Fetched transcript for {video_id} " + f"in {transcript.language} ({transcript.language_code})" + ) + + transcript_segments = [] + for line in captions: + start_time = line.start + duration = line.duration + text = line.text + timestamp = f"[{start_time:.2f}s-{start_time + duration:.2f}s]" + transcript_segments.append(f"{timestamp} {text}") + transcript_text = "\n".join(transcript_segments) + except Exception as e: + logger.warning(f"[scrape_webpage] No transcript for video {video_id}: {e}") + transcript_text = f"No captions available for this video. Error: {e!s}" + + # Build combined content + content = f"# {title}\n\n**Author:** {author}\n**Video ID:** {video_id}\n\n## Transcript\n\n{transcript_text}" + + # Truncate if needed + content, was_truncated = truncate_content(content, max_length) + word_count = len(content.split()) + + description = f"YouTube video by {author}" + + return { + "id": scrape_id, + "assetId": url, + "kind": "article", + "href": url, + "title": title, + "description": description, + "content": content, + "domain": domain, + "word_count": word_count, + "was_truncated": was_truncated, + "crawler_type": "youtube_transcript", + "author": author, + } + + +def create_scrape_webpage_tool(firecrawl_api_key: str | None = None): + """ + Factory function to create the scrape_webpage tool. + + Args: + firecrawl_api_key: Optional Firecrawl API key for premium web scraping. + Falls back to Chromium/Trafilatura if not provided. + + Returns: + A configured tool function for scraping webpages. + """ + + @tool + async def scrape_webpage( + url: str, + max_length: int = 50000, + ) -> dict[str, Any]: + """ + Scrape and extract the main content from a webpage. + + Use this tool when the user wants you to read, summarize, or answer + questions about a specific webpage's content. This tool actually + fetches and reads the full page content. For YouTube video URLs it + fetches the transcript directly instead of crawling the page. + + Common triggers: + - "Read this article and summarize it" + - "What does this page say about X?" + - "Summarize this blog post for me" + - "Tell me the key points from this article" + - "What's in this webpage?" + + Args: + url: The URL of the webpage to scrape (must be HTTP/HTTPS) + max_length: Maximum content length to return (default: 50000 chars) + + Returns: + A dictionary containing: + - id: Unique identifier for this scrape + - assetId: The URL (for deduplication) + - kind: "article" (type of content) + - href: The URL to open when clicked + - title: Page title + - description: Brief description or excerpt + - content: The extracted main content (markdown format) + - domain: The domain name + - word_count: Approximate word count + - was_truncated: Whether content was truncated + - error: Error message (if scraping failed) + """ + scrape_id = generate_scrape_id(url) + domain = extract_domain(url) + + # Validate and normalize URL + if not url.startswith(("http://", "https://")): + url = f"https://{url}" + + try: + # Check if this is a YouTube URL and use transcript API instead + video_id = get_youtube_video_id(url) + if video_id: + return await _scrape_youtube_video(url, video_id, max_length) + + # Create webcrawler connector + connector = WebCrawlerConnector(firecrawl_api_key=firecrawl_api_key) + + # Crawl the URL + result, error = await connector.crawl_url(url, formats=["markdown"]) + + if error: + return { + "id": scrape_id, + "assetId": url, + "kind": "article", + "href": url, + "title": domain or "Webpage", + "domain": domain, + "error": error, + } + + if not result: + return { + "id": scrape_id, + "assetId": url, + "kind": "article", + "href": url, + "title": domain or "Webpage", + "domain": domain, + "error": "No content returned from crawler", + } + + # Extract content and metadata + content = result.get("content", "") + metadata = result.get("metadata", {}) + + # Get title from metadata + title = metadata.get("title", "") + if not title: + title = domain or url.split("/")[-1] or "Webpage" + + # Get description from metadata + description = metadata.get("description", "") + if not description and content: + # Use first paragraph as description + first_para = content.split("\n\n")[0] if content else "" + description = ( + first_para[:300] + "..." if len(first_para) > 300 else first_para + ) + + # Truncate content if needed + content, was_truncated = truncate_content(content, max_length) + + # Calculate word count + word_count = len(content.split()) + + return { + "id": scrape_id, + "assetId": url, + "kind": "article", + "href": url, + "title": title, + "description": description, + "content": content, + "domain": domain, + "word_count": word_count, + "was_truncated": was_truncated, + "crawler_type": result.get("crawler_type", "unknown"), + "author": metadata.get("author"), + "date": metadata.get("date"), + } + + except Exception as e: + error_message = str(e) + logger.error(f"[scrape_webpage] Error scraping {url}: {error_message}") + return { + "id": scrape_id, + "assetId": url, + "kind": "article", + "href": url, + "title": domain or "Webpage", + "domain": domain, + "error": f"Failed to scrape: {error_message[:100]}", + } + + return scrape_webpage diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/search_surfsense_docs.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/search_surfsense_docs.py new file mode 100644 index 000000000..0d702be4c --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/search_surfsense_docs.py @@ -0,0 +1,143 @@ +"""Semantic search over pre-indexed in-app documentation chunks for user how-to questions.""" + +import asyncio +import json + +from langchain_core.tools import tool +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import SurfsenseDocsChunk, SurfsenseDocsDocument +from app.utils.document_converters import embed_text + + +def format_surfsense_docs_results(results: list[tuple]) -> str: + """Format (chunk, document) rows as XML with ``doc-`` chunk IDs for citations and UI routing.""" + if not results: + return "No relevant Surfsense documentation found for your query." + + # Group chunks by document + grouped: dict[int, dict] = {} + for chunk, doc in results: + if doc.id not in grouped: + grouped[doc.id] = { + "document_id": f"doc-{doc.id}", + "document_type": "SURFSENSE_DOCS", + "title": doc.title, + "url": doc.source, + "metadata": {"source": doc.source}, + "chunks": [], + } + grouped[doc.id]["chunks"].append( + { + "chunk_id": f"doc-{chunk.id}", + "content": chunk.content, + } + ) + + # Render XML matching format_documents_for_context structure + parts: list[str] = [] + for g in grouped.values(): + metadata_json = json.dumps(g["metadata"], ensure_ascii=False) + + parts.append("") + parts.append("") + parts.append(f" {g['document_id']}") + parts.append(f" {g['document_type']}") + parts.append(f" <![CDATA[{g['title']}]]>") + parts.append(f" ") + parts.append(f" ") + parts.append("") + parts.append("") + parts.append("") + + for ch in g["chunks"]: + parts.append( + f" " + ) + + parts.append("") + parts.append("") + parts.append("") + + return "\n".join(parts).strip() + + +async def search_surfsense_docs_async( + query: str, + db_session: AsyncSession, + top_k: int = 10, +) -> str: + """ + Search Surfsense documentation using vector similarity. + + Args: + query: The search query about Surfsense usage + db_session: Database session for executing queries + top_k: Number of results to return + + Returns: + Formatted string with relevant documentation content + """ + # Get embedding for the query + query_embedding = await asyncio.to_thread(embed_text, query) + + # Vector similarity search on chunks, joining with documents + stmt = ( + select(SurfsenseDocsChunk, SurfsenseDocsDocument) + .join( + SurfsenseDocsDocument, + SurfsenseDocsChunk.document_id == SurfsenseDocsDocument.id, + ) + .order_by(SurfsenseDocsChunk.embedding.op("<=>")(query_embedding)) + .limit(top_k) + ) + + result = await db_session.execute(stmt) + rows = result.all() + + return format_surfsense_docs_results(rows) + + +def create_search_surfsense_docs_tool(db_session: AsyncSession): + """ + Factory function to create the search_surfsense_docs tool. + + Args: + db_session: Database session for executing queries + + Returns: + A configured tool function for searching Surfsense documentation + """ + + @tool + async def search_surfsense_docs(query: str, top_k: int = 10) -> str: + """ + Search Surfsense documentation for help with using the application. + + Use this tool when the user asks questions about: + - How to use Surfsense features + - Installation and setup instructions + - Configuration options and settings + - Troubleshooting common issues + - Available connectors and integrations + - Browser extension usage + - API documentation + + This searches the official Surfsense documentation that was indexed + at deployment time. It does NOT search the user's personal knowledge base. + + Args: + query: The search query about Surfsense usage or features + top_k: Number of documentation chunks to retrieve (default: 10) + + Returns: + Relevant documentation content formatted with chunk IDs for citations + """ + return await search_surfsense_docs_async( + query=query, + db_session=db_session, + top_k=top_k, + ) + + return search_surfsense_docs diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/web_search.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/web_search.py new file mode 100644 index 000000000..2fe6bd378 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/web_search.py @@ -0,0 +1,241 @@ +"""Real-time web search: SearXNG plus configured live-search connectors (Tavily, Linkup, Baidu, etc.).""" + +import asyncio +import json +import time +from typing import Any + +from langchain_core.tools import StructuredTool +from pydantic import BaseModel, Field + +from app.db import shielded_async_session +from app.services.connector_service import ConnectorService +from app.utils.perf import get_perf_logger + +_LIVE_SEARCH_CONNECTORS: set[str] = { + "TAVILY_API", + "LINKUP_API", + "BAIDU_SEARCH_API", +} + +_LIVE_CONNECTOR_SPECS: dict[str, tuple[str, bool, bool, dict[str, Any]]] = { + "TAVILY_API": ("search_tavily", False, True, {}), + "LINKUP_API": ("search_linkup", False, False, {"mode": "standard"}), + "BAIDU_SEARCH_API": ("search_baidu", False, True, {}), +} + +_CONNECTOR_LABELS: dict[str, str] = { + "TAVILY_API": "Tavily", + "LINKUP_API": "Linkup", + "BAIDU_SEARCH_API": "Baidu", +} + + +class WebSearchInput(BaseModel): + """Input schema for the web_search tool.""" + + query: str = Field( + description="The search query to look up on the web. Use specific, descriptive terms.", + ) + top_k: int = Field( + default=10, + description="Number of results to retrieve (default: 10, max: 50).", + ) + + +def _format_web_results( + documents: list[dict[str, Any]], + *, + max_chars: int = 50_000, +) -> str: + """Format web search results into XML suitable for the LLM context.""" + if not documents: + return "No web search results found." + + parts: list[str] = [] + total_chars = 0 + + for doc in documents: + doc_info = doc.get("document") or {} + metadata = doc_info.get("metadata") or {} + title = doc_info.get("title") or "Web Result" + url = metadata.get("url") or "" + content = (doc.get("content") or "").strip() + source = metadata.get("document_type") or doc.get("source") or "WEB_SEARCH" + if not content: + continue + + metadata_json = json.dumps(metadata, ensure_ascii=False) + doc_xml = "\n".join( + [ + "", + "", + f" {source}", + f" <![CDATA[{title}]]>", + f" ", + f" ", + "", + "", + f" ", + "", + "", + "", + ] + ) + + if total_chars + len(doc_xml) > max_chars: + parts.append("") + break + + parts.append(doc_xml) + total_chars += len(doc_xml) + + return "\n".join(parts).strip() or "No web search results found." + + +async def _search_live_connector( + connector: str, + query: str, + search_space_id: int, + top_k: int, + semaphore: asyncio.Semaphore, +) -> list[dict[str, Any]]: + """Dispatch a single live-search connector (Tavily / Linkup / Baidu).""" + perf = get_perf_logger() + spec = _LIVE_CONNECTOR_SPECS.get(connector) + if spec is None: + return [] + + method_name, _includes_date_range, includes_top_k, extra_kwargs = spec + kwargs: dict[str, Any] = { + "user_query": query, + "search_space_id": search_space_id, + **extra_kwargs, + } + if includes_top_k: + kwargs["top_k"] = top_k + + try: + t0 = time.perf_counter() + async with semaphore, shielded_async_session() as session: + svc = ConnectorService(session, search_space_id) + _, chunks = await getattr(svc, method_name)(**kwargs) + perf.info( + "[web_search] connector=%s results=%d in %.3fs", + connector, + len(chunks), + time.perf_counter() - t0, + ) + return chunks + except Exception as e: + perf.warning("[web_search] connector=%s FAILED: %s", connector, e) + return [] + + +def create_web_search_tool( + search_space_id: int | None = None, + available_connectors: list[str] | None = None, +) -> StructuredTool: + """Factory for the ``web_search`` tool. + + Dispatches in parallel to the platform SearXNG instance and any + user-configured live-search connectors (Tavily, Linkup, Baidu). + """ + active_live_connectors: list[str] = [] + if available_connectors: + active_live_connectors = [ + c for c in available_connectors if c in _LIVE_SEARCH_CONNECTORS + ] + + engine_names = ["SearXNG (platform default)"] + engine_names.extend(_CONNECTOR_LABELS.get(c, c) for c in active_live_connectors) + engines_summary = ", ".join(engine_names) + + description = ( + "Search the web for real-time information. " + "Use this for current events, news, prices, weather, public facts, or any " + "question that requires up-to-date information from the internet.\n\n" + f"Active search engines: {engines_summary}.\n" + "All configured engines are queried in parallel and results are merged." + ) + + _search_space_id = search_space_id + _active_live = active_live_connectors + + async def _web_search_impl(query: str, top_k: int = 10) -> str: + from app.services import web_search_service + + perf = get_perf_logger() + t0 = time.perf_counter() + clamped_top_k = min(max(1, top_k), 50) + + semaphore = asyncio.Semaphore(4) + tasks: list[asyncio.Task[list[dict[str, Any]]]] = [] + + if web_search_service.is_available(): + + async def _searxng() -> list[dict[str, Any]]: + async with semaphore: + _result_obj, docs = await web_search_service.search( + query=query, + top_k=clamped_top_k, + ) + return docs + + tasks.append(asyncio.ensure_future(_searxng())) + + if _search_space_id is not None: + for connector in _active_live: + tasks.append( + asyncio.ensure_future( + _search_live_connector( + connector=connector, + query=query, + search_space_id=_search_space_id, + top_k=clamped_top_k, + semaphore=semaphore, + ) + ) + ) + + if not tasks: + return "Web search is not available — no search engines are configured." + + results_lists = await asyncio.gather(*tasks, return_exceptions=True) + + all_documents: list[dict[str, Any]] = [] + for result in results_lists: + if isinstance(result, BaseException): + perf.warning("[web_search] a search engine failed: %s", result) + continue + all_documents.extend(result) + + seen_urls: set[str] = set() + deduplicated: list[dict[str, Any]] = [] + for doc in all_documents: + url = ((doc.get("document") or {}).get("metadata") or {}).get("url", "") + if url and url in seen_urls: + continue + if url: + seen_urls.add(url) + deduplicated.append(doc) + + formatted = _format_web_results(deduplicated) + + perf.info( + "[web_search] query=%r engines=%d results=%d deduped=%d chars=%d in %.3fs", + query[:60], + len(tasks), + len(all_documents), + len(deduplicated), + len(formatted), + time.perf_counter() - t0, + ) + return formatted + + return StructuredTool( + name="web_search", + description=description, + coroutine=_web_search_impl, + args_schema=WebSearchInput, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/agent.py new file mode 100644 index 000000000..7b78f4565 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/agent.py @@ -0,0 +1,55 @@ +"""`airtable` route: ``SubAgent`` spec for deepagents.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from deepagents import SubAgent +from langchain_core.language_models import BaseChatModel + +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, + middleware_gated_interrupt_on, +) +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( + pack_subagent, +) + +from .tools.index import load_tools + +NAME = "airtable" + + +def build_subagent( + *, + dependencies: dict[str, Any], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, + extra_tools_bucket: ToolsPermissions | None = None, +) -> SubAgent: + buckets = load_tools(dependencies=dependencies) + merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket) + tools = [ + row["tool"] + for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) + if row.get("tool") is not None + ] + interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket) + description = read_md_file(__package__, "description").strip() + if not description: + description = "Handles airtable tasks for this workspace." + system_prompt = read_md_file(__package__, "system_prompt").strip() + return pack_subagent( + name=NAME, + description=description, + system_prompt=system_prompt, + tools=tools, + interrupt_on=interrupt_on, + model=model, + extra_middleware=extra_middleware, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/description.md new file mode 100644 index 000000000..71d75f67a --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/description.md @@ -0,0 +1 @@ +Use for Airtable structured data operations: locate bases/tables and create/read/update records. diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/system_prompt.md new file mode 100644 index 000000000..0f15f137f --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/system_prompt.md @@ -0,0 +1,46 @@ +You are the Airtable MCP operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute Airtable MCP base/table/record operations accurately. + + + +- Runtime-provided Airtable MCP tools for bases, tables, and records. + + + +- Resolve base and table targets before record-level actions. +- Do not guess IDs or schema fields. +- If targets are ambiguous, return `status=blocked` with candidate options. +- Never claim mutation success without tool confirmation. + + + +- Do not execute non-Airtable tasks. + + + +- Never claim record mutations succeeded without tool confirmation. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On unresolved target/schema ambiguity, return `status=blocked` with required options. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { "items": object | null }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/tools/__init__.py new file mode 100644 index 000000000..a9b004975 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/tools/__init__.py @@ -0,0 +1,3 @@ +"""Airtable route: native tool factories are empty; MCP supplies tools when configured.""" + +__all__: list[str] = [] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/tools/index.py new file mode 100644 index 000000000..08b0e005e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/tools/index.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) + + +def load_tools( + *, dependencies: dict[str, Any] | None = None, **kwargs: Any +) -> ToolsPermissions: + _ = {**(dependencies or {}), **kwargs} + return {"allow": [], "ask": []} diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/agent.py new file mode 100644 index 000000000..42ccba213 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/agent.py @@ -0,0 +1,55 @@ +"""`calendar` route: ``SubAgent`` spec for deepagents.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from deepagents import SubAgent +from langchain_core.language_models import BaseChatModel + +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, + middleware_gated_interrupt_on, +) +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( + pack_subagent, +) + +from .tools.index import load_tools + +NAME = "calendar" + + +def build_subagent( + *, + dependencies: dict[str, Any], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, + extra_tools_bucket: ToolsPermissions | None = None, +) -> SubAgent: + buckets = load_tools(dependencies=dependencies) + merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket) + tools = [ + row["tool"] + for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) + if row.get("tool") is not None + ] + interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket) + description = read_md_file(__package__, "description").strip() + if not description: + description = "Handles calendar tasks for this workspace." + system_prompt = read_md_file(__package__, "system_prompt").strip() + return pack_subagent( + name=NAME, + description=description, + system_prompt=system_prompt, + tools=tools, + interrupt_on=interrupt_on, + model=model, + extra_middleware=extra_middleware, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/description.md new file mode 100644 index 000000000..43865ef53 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/description.md @@ -0,0 +1 @@ +Use for calendar planning and scheduling: check availability, read event details, create events, and update events. diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/system_prompt.md new file mode 100644 index 000000000..a7ef846d5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/system_prompt.md @@ -0,0 +1,62 @@ +You are the Google Calendar operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute calendar event operations (search, create, update, delete) accurately with timezone-safe scheduling. + + + +- `search_calendar_events` +- `create_calendar_event` +- `update_calendar_event` +- `delete_calendar_event` + + + +- Use only tools in ``. +- Resolve relative dates against current runtime timestamp. +- If required fields (date/time/timezone/target event) are missing or ambiguous, return `status=blocked` with `missing_fields` and supervisor `next_step`. +- Never invent event IDs or mutation results. + + + +- Do not perform non-calendar tasks. + + + +- Before update/delete, ensure event target is explicit. +- Never claim event mutation success without tool confirmation. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On ambiguity, return `status=blocked` with top event candidates. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "event_id": string | null, + "title": string | null, + "start_at": string (ISO 8601 with timezone) | null, + "end_at": string (ISO 8601 with timezone) | null, + "matched_candidates": [ + { + "event_id": string, + "title": string | null, + "start_at": string (ISO 8601 with timezone) | null + } + ] | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/__init__.py new file mode 100644 index 000000000..13d4c06cb --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/__init__.py @@ -0,0 +1,19 @@ +from app.agents.new_chat.tools.google_calendar.create_event import ( + create_create_calendar_event_tool, +) +from app.agents.new_chat.tools.google_calendar.delete_event import ( + create_delete_calendar_event_tool, +) +from app.agents.new_chat.tools.google_calendar.search_events import ( + create_search_calendar_events_tool, +) +from app.agents.new_chat.tools.google_calendar.update_event import ( + create_update_calendar_event_tool, +) + +__all__ = [ + "create_create_calendar_event_tool", + "create_delete_calendar_event_tool", + "create_search_calendar_events_tool", + "create_update_calendar_event_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/create_event.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/create_event.py new file mode 100644 index 000000000..a8183314a --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/create_event.py @@ -0,0 +1,349 @@ +import asyncio +import logging +from datetime import datetime +from typing import Any + +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval +from app.services.google_calendar import GoogleCalendarToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_create_calendar_event_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def create_calendar_event( + summary: str, + start_datetime: str, + end_datetime: str, + description: str | None = None, + location: str | None = None, + attendees: list[str] | None = None, + ) -> dict[str, Any]: + """Create a new event on Google Calendar. + + Use when the user asks to schedule, create, or add a calendar event. + Ask for event details if not provided. + + Args: + summary: The event title. + start_datetime: Start time in ISO 8601 format (e.g. "2026-03-20T10:00:00"). + end_datetime: End time in ISO 8601 format (e.g. "2026-03-20T11:00:00"). + description: Optional event description. + location: Optional event location. + attendees: Optional list of attendee email addresses. + + Returns: + Dictionary with: + - status: "success", "rejected", "auth_error", or "error" + - event_id: Google Calendar event ID (if success) + - html_link: URL to open the event (if success) + - message: Result message + + IMPORTANT: + - If status is "rejected", the user explicitly declined the action. + Respond with a brief acknowledgment and do NOT retry or suggest alternatives. + + Examples: + - "Schedule a meeting with John tomorrow at 10am" + - "Create a calendar event for the team standup" + """ + logger.info( + f"create_calendar_event called: summary='{summary}', start='{start_datetime}', end='{end_datetime}'" + ) + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Google Calendar tool not properly configured. Please contact support.", + } + + try: + metadata_service = GoogleCalendarToolMetadataService(db_session) + context = await metadata_service.get_creation_context( + search_space_id, user_id + ) + + if "error" in context: + logger.error(f"Failed to fetch creation context: {context['error']}") + return {"status": "error", "message": context["error"]} + + accounts = context.get("accounts", []) + if accounts and all(a.get("auth_expired") for a in accounts): + logger.warning( + "All Google Calendar accounts have expired authentication" + ) + return { + "status": "auth_error", + "message": "All connected Google Calendar accounts need re-authentication. Please re-authenticate in your connector settings.", + "connector_type": "google_calendar", + } + + logger.info( + f"Requesting approval for creating calendar event: summary='{summary}'" + ) + result = request_approval( + action_type="google_calendar_event_creation", + tool_name="create_calendar_event", + params={ + "summary": summary, + "start_datetime": start_datetime, + "end_datetime": end_datetime, + "description": description, + "location": location, + "attendees": attendees, + "timezone": context.get("timezone"), + "connector_id": None, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. The event was not created. Do not ask again or suggest alternatives.", + } + + final_summary = result.params.get("summary", summary) + final_start_datetime = result.params.get("start_datetime", start_datetime) + final_end_datetime = result.params.get("end_datetime", end_datetime) + final_description = result.params.get("description", description) + final_location = result.params.get("location", location) + final_attendees = result.params.get("attendees", attendees) + final_connector_id = result.params.get("connector_id") + + if not final_summary or not final_summary.strip(): + return {"status": "error", "message": "Event summary cannot be empty."} + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + _calendar_types = [ + SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR, + SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR, + ] + + if final_connector_id is not None: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type.in_(_calendar_types), + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Google Calendar connector is invalid or has been disconnected.", + } + actual_connector_id = connector.id + else: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type.in_(_calendar_types), + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "No Google Calendar connector found. Please connect Google Calendar in your workspace settings.", + } + actual_connector_id = connector.id + + logger.info( + f"Creating calendar event: summary='{final_summary}', connector={actual_connector_id}" + ) + + tz = context.get("timezone", "UTC") + + if ( + connector.connector_type + == SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR + ): + cca_id = connector.config.get("composio_connected_account_id") + if not cca_id: + return { + "status": "error", + "message": "Composio connected account ID not found for this connector.", + } + + from app.services.composio_service import ComposioService + + ( + event_id, + html_link, + error, + ) = await ComposioService().create_calendar_event( + connected_account_id=cca_id, + entity_id=f"surfsense_{user_id}", + summary=final_summary, + start_datetime=final_start_datetime, + end_datetime=final_end_datetime, + timezone=tz, + description=final_description, + location=final_location, + attendees=final_attendees, + ) + if error: + return {"status": "error", "message": error} + created = { + "id": event_id, + "summary": final_summary, + "htmlLink": html_link, + } + logger.info( + f"Calendar event created via Composio: id={event_id}, summary={final_summary}" + ) + else: + config_data = dict(connector.config) + + from app.config import config as app_config + from app.utils.oauth_security import TokenEncryption + + token_encrypted = config_data.get("_token_encrypted", False) + if token_encrypted and app_config.SECRET_KEY: + token_encryption = TokenEncryption(app_config.SECRET_KEY) + for key in ("token", "refresh_token", "client_secret"): + if config_data.get(key): + config_data[key] = token_encryption.decrypt_token( + config_data[key] + ) + + exp = config_data.get("expiry", "") + if exp: + exp = exp.replace("Z", "") + + creds = Credentials( + token=config_data.get("token"), + refresh_token=config_data.get("refresh_token"), + token_uri=config_data.get("token_uri"), + client_id=config_data.get("client_id"), + client_secret=config_data.get("client_secret"), + scopes=config_data.get("scopes", []), + expiry=datetime.fromisoformat(exp) if exp else None, + ) + + service = await asyncio.get_event_loop().run_in_executor( + None, lambda: build("calendar", "v3", credentials=creds) + ) + + event_body: dict[str, Any] = { + "summary": final_summary, + "start": {"dateTime": final_start_datetime, "timeZone": tz}, + "end": {"dateTime": final_end_datetime, "timeZone": tz}, + } + if final_description: + event_body["description"] = final_description + if final_location: + event_body["location"] = final_location + if final_attendees: + event_body["attendees"] = [ + {"email": e.strip()} for e in final_attendees if e.strip() + ] + + try: + created = await asyncio.get_event_loop().run_in_executor( + None, + lambda: ( + service.events() + .insert(calendarId="primary", body=event_body) + .execute() + ), + ) + except Exception as api_err: + from googleapiclient.errors import HttpError + + if isinstance(api_err, HttpError) and api_err.resp.status == 403: + logger.warning( + f"Insufficient permissions for connector {actual_connector_id}: {api_err}" + ) + try: + from sqlalchemy.orm.attributes import flag_modified + + _res = await db_session.execute( + select(SearchSourceConnector).where( + SearchSourceConnector.id == actual_connector_id + ) + ) + _conn = _res.scalar_one_or_none() + if _conn and not _conn.config.get("auth_expired"): + _conn.config = {**_conn.config, "auth_expired": True} + flag_modified(_conn, "config") + await db_session.commit() + except Exception: + logger.warning( + "Failed to persist auth_expired for connector %s", + actual_connector_id, + exc_info=True, + ) + return { + "status": "insufficient_permissions", + "connector_id": actual_connector_id, + "message": "This Google Calendar account needs additional permissions. Please re-authenticate in connector settings.", + } + raise + + logger.info( + f"Calendar event created via Google API: id={created.get('id')}, summary={created.get('summary')}" + ) + + kb_message_suffix = "" + try: + from app.services.google_calendar import GoogleCalendarKBSyncService + + kb_service = GoogleCalendarKBSyncService(db_session) + kb_result = await kb_service.sync_after_create( + event_id=created.get("id"), + event_summary=final_summary, + calendar_id="primary", + start_time=final_start_datetime, + end_time=final_end_datetime, + location=final_location, + html_link=created.get("htmlLink"), + description=final_description, + connector_id=actual_connector_id, + search_space_id=search_space_id, + user_id=user_id, + ) + if kb_result["status"] == "success": + kb_message_suffix = " Your knowledge base has also been updated." + else: + kb_message_suffix = " This event will be added to your knowledge base in the next scheduled sync." + except Exception as kb_err: + logger.warning(f"KB sync after create failed: {kb_err}") + kb_message_suffix = " This event will be added to your knowledge base in the next scheduled sync." + + return { + "status": "success", + "event_id": created.get("id"), + "html_link": created.get("htmlLink"), + "message": f"Successfully created '{final_summary}' on Google Calendar.{kb_message_suffix}", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error creating calendar event: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while creating the event. Please try again.", + } + + return create_calendar_event diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/delete_event.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/delete_event.py new file mode 100644 index 000000000..3d160e669 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/delete_event.py @@ -0,0 +1,310 @@ +import asyncio +import logging +from datetime import datetime +from typing import Any + +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval +from app.services.google_calendar import GoogleCalendarToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_delete_calendar_event_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def delete_calendar_event( + event_title_or_id: str, + delete_from_kb: bool = False, + ) -> dict[str, Any]: + """Delete a Google Calendar event. + + Use when the user asks to delete, remove, or cancel a calendar event. + + Args: + event_title_or_id: The exact title or event ID of the event to delete. + delete_from_kb: Whether to also remove the event from the knowledge base. + Default is False. + Set to True to remove from both Google Calendar and knowledge base. + + Returns: + Dictionary with: + - status: "success", "rejected", "not_found", "auth_error", or "error" + - event_id: Google Calendar event ID (if success) + - deleted_from_kb: whether the document was removed from the knowledge base + - message: Result message + + IMPORTANT: + - If status is "rejected", the user explicitly declined. Respond with a brief + acknowledgment and do NOT retry or suggest alternatives. + - If status is "not_found", relay the exact message to the user and ask them + to verify the event name or check if it has been indexed. + Examples: + - "Delete the team standup event" + - "Cancel my dentist appointment on Friday" + """ + logger.info( + f"delete_calendar_event called: event_ref='{event_title_or_id}', delete_from_kb={delete_from_kb}" + ) + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Google Calendar tool not properly configured. Please contact support.", + } + + try: + metadata_service = GoogleCalendarToolMetadataService(db_session) + context = await metadata_service.get_deletion_context( + search_space_id, user_id, event_title_or_id + ) + + if "error" in context: + error_msg = context["error"] + if "not found" in error_msg.lower(): + logger.warning(f"Event not found: {error_msg}") + return {"status": "not_found", "message": error_msg} + logger.error(f"Failed to fetch deletion context: {error_msg}") + return {"status": "error", "message": error_msg} + + account = context.get("account", {}) + if account.get("auth_expired"): + logger.warning( + "Google Calendar account %s has expired authentication", + account.get("id"), + ) + return { + "status": "auth_error", + "message": "The Google Calendar account for this event needs re-authentication. Please re-authenticate in your connector settings.", + "connector_type": "google_calendar", + } + + event = context["event"] + event_id = event["event_id"] + document_id = event.get("document_id") + connector_id_from_context = context["account"]["id"] + + if not event_id: + return { + "status": "error", + "message": "Event ID is missing from the indexed document. Please re-index the event and try again.", + } + + logger.info( + f"Requesting approval for deleting calendar event: '{event_title_or_id}' (event_id={event_id}, delete_from_kb={delete_from_kb})" + ) + result = request_approval( + action_type="google_calendar_event_deletion", + tool_name="delete_calendar_event", + params={ + "event_id": event_id, + "connector_id": connector_id_from_context, + "delete_from_kb": delete_from_kb, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. The event was not deleted. Do not ask again or suggest alternatives.", + } + + final_event_id = result.params.get("event_id", event_id) + final_connector_id = result.params.get( + "connector_id", connector_id_from_context + ) + final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb) + + if not final_connector_id: + return { + "status": "error", + "message": "No connector found for this event.", + } + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + _calendar_types = [ + SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR, + SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR, + ] + + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type.in_(_calendar_types), + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Google Calendar connector is invalid or has been disconnected.", + } + + actual_connector_id = connector.id + + logger.info( + f"Deleting calendar event: event_id='{final_event_id}', connector={actual_connector_id}" + ) + + if ( + connector.connector_type + == SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR + ): + cca_id = connector.config.get("composio_connected_account_id") + if not cca_id: + return { + "status": "error", + "message": "Composio connected account ID not found for this connector.", + } + + from app.services.composio_service import ComposioService + + error = await ComposioService().delete_calendar_event( + connected_account_id=cca_id, + entity_id=f"surfsense_{user_id}", + event_id=final_event_id, + ) + if error: + return {"status": "error", "message": error} + else: + config_data = dict(connector.config) + + from app.config import config as app_config + from app.utils.oauth_security import TokenEncryption + + token_encrypted = config_data.get("_token_encrypted", False) + if token_encrypted and app_config.SECRET_KEY: + token_encryption = TokenEncryption(app_config.SECRET_KEY) + for key in ("token", "refresh_token", "client_secret"): + if config_data.get(key): + config_data[key] = token_encryption.decrypt_token( + config_data[key] + ) + + exp = config_data.get("expiry", "") + if exp: + exp = exp.replace("Z", "") + + creds = Credentials( + token=config_data.get("token"), + refresh_token=config_data.get("refresh_token"), + token_uri=config_data.get("token_uri"), + client_id=config_data.get("client_id"), + client_secret=config_data.get("client_secret"), + scopes=config_data.get("scopes", []), + expiry=datetime.fromisoformat(exp) if exp else None, + ) + + service = await asyncio.get_event_loop().run_in_executor( + None, lambda: build("calendar", "v3", credentials=creds) + ) + + try: + await asyncio.get_event_loop().run_in_executor( + None, + lambda: ( + service.events() + .delete(calendarId="primary", eventId=final_event_id) + .execute() + ), + ) + except Exception as api_err: + from googleapiclient.errors import HttpError + + if isinstance(api_err, HttpError) and api_err.resp.status == 403: + logger.warning( + f"Insufficient permissions for connector {actual_connector_id}: {api_err}" + ) + try: + from sqlalchemy.orm.attributes import flag_modified + + _res = await db_session.execute( + select(SearchSourceConnector).where( + SearchSourceConnector.id == actual_connector_id + ) + ) + _conn = _res.scalar_one_or_none() + if _conn and not _conn.config.get("auth_expired"): + _conn.config = {**_conn.config, "auth_expired": True} + flag_modified(_conn, "config") + await db_session.commit() + except Exception: + logger.warning( + "Failed to persist auth_expired for connector %s", + actual_connector_id, + exc_info=True, + ) + return { + "status": "insufficient_permissions", + "connector_id": actual_connector_id, + "message": "This Google Calendar account needs additional permissions. Please re-authenticate in connector settings.", + } + raise + + logger.info(f"Calendar event deleted: event_id={final_event_id}") + + delete_result: dict[str, Any] = { + "status": "success", + "event_id": final_event_id, + "message": f"Successfully deleted the calendar event '{event.get('summary', event_title_or_id)}'.", + } + + deleted_from_kb = False + if final_delete_from_kb and document_id: + try: + from app.db import Document + + doc_result = await db_session.execute( + select(Document).filter(Document.id == document_id) + ) + document = doc_result.scalars().first() + if document: + await db_session.delete(document) + await db_session.commit() + deleted_from_kb = True + logger.info( + f"Deleted document {document_id} from knowledge base" + ) + else: + logger.warning(f"Document {document_id} not found in KB") + except Exception as e: + logger.error(f"Failed to delete document from KB: {e}") + await db_session.rollback() + delete_result["warning"] = ( + f"Event deleted, but failed to remove from knowledge base: {e!s}" + ) + + delete_result["deleted_from_kb"] = deleted_from_kb + if deleted_from_kb: + delete_result["message"] = ( + f"{delete_result.get('message', '')} (also removed from knowledge base)" + ) + + return delete_result + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error deleting calendar event: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while deleting the event. Please try again.", + } + + return delete_calendar_event diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/index.py new file mode 100644 index 000000000..2538a494b --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/index.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) + +from .create_event import create_create_calendar_event_tool +from .delete_event import create_delete_calendar_event_tool +from .search_events import create_search_calendar_events_tool +from .update_event import create_update_calendar_event_tool + + +def load_tools( + *, dependencies: dict[str, Any] | None = None, **kwargs: Any +) -> ToolsPermissions: + resolved_dependencies = {**(dependencies or {}), **kwargs} + session_dependencies = { + "db_session": resolved_dependencies["db_session"], + "search_space_id": resolved_dependencies["search_space_id"], + "user_id": resolved_dependencies["user_id"], + } + search = create_search_calendar_events_tool(**session_dependencies) + create = create_create_calendar_event_tool(**session_dependencies) + update = create_update_calendar_event_tool(**session_dependencies) + delete = create_delete_calendar_event_tool(**session_dependencies) + return { + "allow": [{"name": getattr(search, "name", "") or "", "tool": search}], + "ask": [ + {"name": getattr(create, "name", "") or "", "tool": create}, + {"name": getattr(update, "name", "") or "", "tool": update}, + {"name": getattr(delete, "name", "") or "", "tool": delete}, + ], + } diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/search_events.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/search_events.py new file mode 100644 index 000000000..6772d5a1e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/search_events.py @@ -0,0 +1,165 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.agents.new_chat.tools.gmail.search_emails import _build_credentials +from app.db import SearchSourceConnector, SearchSourceConnectorType + +logger = logging.getLogger(__name__) + +_CALENDAR_TYPES = [ + SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR, + SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR, +] + + +def _to_calendar_boundary(value: str, *, is_end: bool) -> str: + """Promote a bare YYYY-MM-DD to RFC3339 with a day-edge time, leave full datetimes alone.""" + if "T" in value: + return value + time = "23:59:59" if is_end else "00:00:00" + return f"{value}T{time}Z" + + +def create_search_calendar_events_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def search_calendar_events( + start_date: str, + end_date: str, + max_results: int = 25, + ) -> dict[str, Any]: + """Search Google Calendar events within a date range. + + Args: + start_date: Start date in YYYY-MM-DD format (e.g. "2026-04-01"). + end_date: End date in YYYY-MM-DD format (e.g. "2026-04-30"). + max_results: Maximum number of events to return (default 25, max 50). + + Returns: + Dictionary with status and a list of events including + event_id, summary, start, end, location, attendees. + """ + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Calendar tool not properly configured.", + } + + max_results = min(max_results, 50) + + try: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type.in_(_CALENDAR_TYPES), + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "No Google Calendar connector found. Please connect Google Calendar in your workspace settings.", + } + + if ( + connector.connector_type + == SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR + ): + cca_id = connector.config.get("composio_connected_account_id") + if not cca_id: + return { + "status": "error", + "message": "Composio connected account ID not found for this connector.", + } + + from app.services.composio_service import ComposioService + + events_raw, error = await ComposioService().get_calendar_events( + connected_account_id=cca_id, + entity_id=f"surfsense_{user_id}", + time_min=_to_calendar_boundary(start_date, is_end=False), + time_max=_to_calendar_boundary(end_date, is_end=True), + max_results=max_results, + ) + if not events_raw and not error: + error = "No events found in the specified date range." + else: + creds = _build_credentials(connector) + + from app.connectors.google_calendar_connector import ( + GoogleCalendarConnector, + ) + + cal = GoogleCalendarConnector( + credentials=creds, + session=db_session, + user_id=user_id, + connector_id=connector.id, + ) + + events_raw, error = await cal.get_all_primary_calendar_events( + start_date=start_date, + end_date=end_date, + max_results=max_results, + ) + + if error: + if ( + "re-authenticate" in error.lower() + or "authentication failed" in error.lower() + ): + return { + "status": "auth_error", + "message": error, + "connector_type": "google_calendar", + } + if "no events found" in error.lower(): + return { + "status": "success", + "events": [], + "total": 0, + "message": error, + } + return {"status": "error", "message": error} + + events = [] + for ev in events_raw: + start = ev.get("start", {}) + end = ev.get("end", {}) + attendees_raw = ev.get("attendees", []) + events.append( + { + "event_id": ev.get("id"), + "summary": ev.get("summary", "No Title"), + "start": start.get("dateTime") or start.get("date", ""), + "end": end.get("dateTime") or end.get("date", ""), + "location": ev.get("location", ""), + "description": ev.get("description", ""), + "html_link": ev.get("htmlLink", ""), + "attendees": [a.get("email", "") for a in attendees_raw[:10]], + "status": ev.get("status", ""), + } + ) + + return {"status": "success", "events": events, "total": len(events)} + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error("Error searching calendar events: %s", e, exc_info=True) + return { + "status": "error", + "message": "Failed to search calendar events. Please try again.", + } + + return search_calendar_events diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/update_event.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/update_event.py new file mode 100644 index 000000000..a74979484 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/update_event.py @@ -0,0 +1,396 @@ +import asyncio +import logging +from datetime import datetime +from typing import Any + +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval +from app.services.google_calendar import GoogleCalendarToolMetadataService + +logger = logging.getLogger(__name__) + + +def _is_date_only(value: str) -> bool: + """Return True when *value* looks like a bare date (YYYY-MM-DD) with no time component.""" + return len(value) <= 10 and "T" not in value + + +def _build_time_body(value: str, context: dict[str, Any] | Any) -> dict[str, str]: + """Build a Google Calendar start/end body using ``date`` for all-day + events and ``dateTime`` for timed events.""" + if _is_date_only(value): + return {"date": value} + tz = context.get("timezone", "UTC") if isinstance(context, dict) else "UTC" + return {"dateTime": value, "timeZone": tz} + + +def create_update_calendar_event_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def update_calendar_event( + event_title_or_id: str, + new_summary: str | None = None, + new_start_datetime: str | None = None, + new_end_datetime: str | None = None, + new_description: str | None = None, + new_location: str | None = None, + new_attendees: list[str] | None = None, + ) -> dict[str, Any]: + """Update an existing Google Calendar event. + + Use when the user asks to modify, reschedule, or change a calendar event. + + Args: + event_title_or_id: The exact title or event ID of the event to update. + new_summary: New event title (if changing). + new_start_datetime: New start time in ISO 8601 format (if rescheduling). + new_end_datetime: New end time in ISO 8601 format (if rescheduling). + new_description: New event description (if changing). + new_location: New event location (if changing). + new_attendees: New list of attendee email addresses (if changing). + + Returns: + Dictionary with: + - status: "success", "rejected", "not_found", "auth_error", or "error" + - event_id: Google Calendar event ID (if success) + - html_link: URL to open the event (if success) + - message: Result message + + IMPORTANT: + - If status is "rejected", the user explicitly declined. Respond with a brief + acknowledgment and do NOT retry or suggest alternatives. + - If status is "not_found", relay the exact message to the user and ask them + to verify the event name or check if it has been indexed. + Examples: + - "Reschedule the team standup to 3pm" + - "Change the location of my dentist appointment" + """ + logger.info(f"update_calendar_event called: event_ref='{event_title_or_id}'") + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Google Calendar tool not properly configured. Please contact support.", + } + + try: + metadata_service = GoogleCalendarToolMetadataService(db_session) + context = await metadata_service.get_update_context( + search_space_id, user_id, event_title_or_id + ) + + if "error" in context: + error_msg = context["error"] + if "not found" in error_msg.lower(): + logger.warning(f"Event not found: {error_msg}") + return {"status": "not_found", "message": error_msg} + logger.error(f"Failed to fetch update context: {error_msg}") + return {"status": "error", "message": error_msg} + + if context.get("auth_expired"): + logger.warning("Google Calendar account has expired authentication") + return { + "status": "auth_error", + "message": "The Google Calendar account for this event needs re-authentication. Please re-authenticate in your connector settings.", + "connector_type": "google_calendar", + } + + event = context["event"] + event_id = event["event_id"] + document_id = event.get("document_id") + connector_id_from_context = context["account"]["id"] + + if not event_id: + return { + "status": "error", + "message": "Event ID is missing from the indexed document. Please re-index the event and try again.", + } + + logger.info( + f"Requesting approval for updating calendar event: '{event_title_or_id}' (event_id={event_id})" + ) + result = request_approval( + action_type="google_calendar_event_update", + tool_name="update_calendar_event", + params={ + "event_id": event_id, + "document_id": document_id, + "connector_id": connector_id_from_context, + "new_summary": new_summary, + "new_start_datetime": new_start_datetime, + "new_end_datetime": new_end_datetime, + "new_description": new_description, + "new_location": new_location, + "new_attendees": new_attendees, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. The event was not updated. Do not ask again or suggest alternatives.", + } + + final_event_id = result.params.get("event_id", event_id) + final_connector_id = result.params.get( + "connector_id", connector_id_from_context + ) + final_new_summary = result.params.get("new_summary", new_summary) + final_new_start_datetime = result.params.get( + "new_start_datetime", new_start_datetime + ) + final_new_end_datetime = result.params.get( + "new_end_datetime", new_end_datetime + ) + final_new_description = result.params.get( + "new_description", new_description + ) + final_new_location = result.params.get("new_location", new_location) + final_new_attendees = result.params.get("new_attendees", new_attendees) + + if not final_connector_id: + return { + "status": "error", + "message": "No connector found for this event.", + } + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + _calendar_types = [ + SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR, + SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR, + ] + + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type.in_(_calendar_types), + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Google Calendar connector is invalid or has been disconnected.", + } + + actual_connector_id = connector.id + + logger.info( + f"Updating calendar event: event_id='{final_event_id}', connector={actual_connector_id}" + ) + + has_changes = any( + v is not None + for v in ( + final_new_summary, + final_new_start_datetime, + final_new_end_datetime, + final_new_description, + final_new_location, + final_new_attendees, + ) + ) + if not has_changes: + return { + "status": "error", + "message": "No changes specified. Please provide at least one field to update.", + } + + if ( + connector.connector_type + == SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR + ): + cca_id = connector.config.get("composio_connected_account_id") + if not cca_id: + return { + "status": "error", + "message": "Composio connected account ID not found for this connector.", + } + + from app.services.composio_service import ComposioService + + tz_for_composio: str | None = None + if final_new_start_datetime is not None and not _is_date_only( + final_new_start_datetime + ): + tz_for_composio = ( + context.get("timezone") if isinstance(context, dict) else None + ) + + _, html_link, error = await ComposioService().update_calendar_event( + connected_account_id=cca_id, + entity_id=f"surfsense_{user_id}", + event_id=final_event_id, + summary=final_new_summary, + start_time=final_new_start_datetime, + end_time=final_new_end_datetime, + timezone=tz_for_composio, + description=final_new_description, + location=final_new_location, + attendees=final_new_attendees, + ) + if error: + return {"status": "error", "message": error} + updated = {"htmlLink": html_link} + logger.info( + f"Calendar event updated via Composio: event_id={final_event_id}" + ) + else: + config_data = dict(connector.config) + + from app.config import config as app_config + from app.utils.oauth_security import TokenEncryption + + token_encrypted = config_data.get("_token_encrypted", False) + if token_encrypted and app_config.SECRET_KEY: + token_encryption = TokenEncryption(app_config.SECRET_KEY) + for key in ("token", "refresh_token", "client_secret"): + if config_data.get(key): + config_data[key] = token_encryption.decrypt_token( + config_data[key] + ) + + exp = config_data.get("expiry", "") + if exp: + exp = exp.replace("Z", "") + + creds = Credentials( + token=config_data.get("token"), + refresh_token=config_data.get("refresh_token"), + token_uri=config_data.get("token_uri"), + client_id=config_data.get("client_id"), + client_secret=config_data.get("client_secret"), + scopes=config_data.get("scopes", []), + expiry=datetime.fromisoformat(exp) if exp else None, + ) + + service = await asyncio.get_event_loop().run_in_executor( + None, lambda: build("calendar", "v3", credentials=creds) + ) + + update_body: dict[str, Any] = {} + if final_new_summary is not None: + update_body["summary"] = final_new_summary + if final_new_start_datetime is not None: + update_body["start"] = _build_time_body( + final_new_start_datetime, context + ) + if final_new_end_datetime is not None: + update_body["end"] = _build_time_body( + final_new_end_datetime, context + ) + if final_new_description is not None: + update_body["description"] = final_new_description + if final_new_location is not None: + update_body["location"] = final_new_location + if final_new_attendees is not None: + update_body["attendees"] = [ + {"email": e.strip()} for e in final_new_attendees if e.strip() + ] + + try: + updated = await asyncio.get_event_loop().run_in_executor( + None, + lambda: ( + service.events() + .patch( + calendarId="primary", + eventId=final_event_id, + body=update_body, + ) + .execute() + ), + ) + except Exception as api_err: + from googleapiclient.errors import HttpError + + if isinstance(api_err, HttpError) and api_err.resp.status == 403: + logger.warning( + f"Insufficient permissions for connector {actual_connector_id}: {api_err}" + ) + try: + from sqlalchemy.orm.attributes import flag_modified + + _res = await db_session.execute( + select(SearchSourceConnector).where( + SearchSourceConnector.id == actual_connector_id + ) + ) + _conn = _res.scalar_one_or_none() + if _conn and not _conn.config.get("auth_expired"): + _conn.config = {**_conn.config, "auth_expired": True} + flag_modified(_conn, "config") + await db_session.commit() + except Exception: + logger.warning( + "Failed to persist auth_expired for connector %s", + actual_connector_id, + exc_info=True, + ) + return { + "status": "insufficient_permissions", + "connector_id": actual_connector_id, + "message": "This Google Calendar account needs additional permissions. Please re-authenticate in connector settings.", + } + raise + + logger.info( + f"Calendar event updated via Google API: event_id={final_event_id}" + ) + + kb_message_suffix = "" + if document_id is not None: + try: + from app.services.google_calendar import GoogleCalendarKBSyncService + + kb_service = GoogleCalendarKBSyncService(db_session) + kb_result = await kb_service.sync_after_update( + document_id=document_id, + event_id=final_event_id, + connector_id=actual_connector_id, + search_space_id=search_space_id, + user_id=user_id, + ) + if kb_result["status"] == "success": + kb_message_suffix = ( + " Your knowledge base has also been updated." + ) + else: + kb_message_suffix = " The knowledge base will be updated in the next scheduled sync." + except Exception as kb_err: + logger.warning(f"KB sync after update failed: {kb_err}") + kb_message_suffix = " The knowledge base will be updated in the next scheduled sync." + + return { + "status": "success", + "event_id": final_event_id, + "html_link": updated.get("htmlLink"), + "message": f"Successfully updated the calendar event.{kb_message_suffix}", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error updating calendar event: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while updating the event. Please try again.", + } + + return update_calendar_event diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/agent.py new file mode 100644 index 000000000..057351c77 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/agent.py @@ -0,0 +1,55 @@ +"""`clickup` route: ``SubAgent`` spec for deepagents.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from deepagents import SubAgent +from langchain_core.language_models import BaseChatModel + +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, + middleware_gated_interrupt_on, +) +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( + pack_subagent, +) + +from .tools.index import load_tools + +NAME = "clickup" + + +def build_subagent( + *, + dependencies: dict[str, Any], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, + extra_tools_bucket: ToolsPermissions | None = None, +) -> SubAgent: + buckets = load_tools(dependencies=dependencies) + merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket) + tools = [ + row["tool"] + for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) + if row.get("tool") is not None + ] + interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket) + description = read_md_file(__package__, "description").strip() + if not description: + description = "Handles clickup tasks for this workspace." + system_prompt = read_md_file(__package__, "system_prompt").strip() + return pack_subagent( + name=NAME, + description=description, + system_prompt=system_prompt, + tools=tools, + interrupt_on=interrupt_on, + model=model, + extra_middleware=extra_middleware, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/description.md new file mode 100644 index 000000000..07ce599a5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/description.md @@ -0,0 +1 @@ +Use for ClickUp task management: find tasks/lists, update task fields, and track execution progress. diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/system_prompt.md new file mode 100644 index 000000000..84014246d --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/system_prompt.md @@ -0,0 +1,45 @@ +You are the ClickUp MCP operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute ClickUp MCP operations accurately using only runtime-provided tools. + + + +- Runtime-provided ClickUp MCP tools for task/workspace search and mutation. + + + +- Follow tool descriptions exactly. +- If task/workspace target is ambiguous or missing, return `status=blocked` with required disambiguation fields. +- Never claim mutation success without tool confirmation. + + + +- Do not execute non-ClickUp tasks. + + + +- Never claim update/create success without tool confirmation. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On unresolved ambiguity, return `status=blocked` with candidate options. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { "items": object | null }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/tools/__init__.py new file mode 100644 index 000000000..b629234f9 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/tools/__init__.py @@ -0,0 +1,3 @@ +"""ClickUp route: native tool factories are empty; MCP supplies tools when configured.""" + +__all__: list[str] = [] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/tools/index.py new file mode 100644 index 000000000..08b0e005e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/tools/index.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) + + +def load_tools( + *, dependencies: dict[str, Any] | None = None, **kwargs: Any +) -> ToolsPermissions: + _ = {**(dependencies or {}), **kwargs} + return {"allow": [], "ask": []} diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/agent.py new file mode 100644 index 000000000..3b021ee70 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/agent.py @@ -0,0 +1,55 @@ +"""`confluence` route: ``SubAgent`` spec for deepagents.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from deepagents import SubAgent +from langchain_core.language_models import BaseChatModel + +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, + middleware_gated_interrupt_on, +) +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( + pack_subagent, +) + +from .tools.index import load_tools + +NAME = "confluence" + + +def build_subagent( + *, + dependencies: dict[str, Any], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, + extra_tools_bucket: ToolsPermissions | None = None, +) -> SubAgent: + buckets = load_tools(dependencies=dependencies) + merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket) + tools = [ + row["tool"] + for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) + if row.get("tool") is not None + ] + interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket) + description = read_md_file(__package__, "description").strip() + if not description: + description = "Handles confluence tasks for this workspace." + system_prompt = read_md_file(__package__, "system_prompt").strip() + return pack_subagent( + name=NAME, + description=description, + system_prompt=system_prompt, + tools=tools, + interrupt_on=interrupt_on, + model=model, + extra_middleware=extra_middleware, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/description.md new file mode 100644 index 000000000..b6f1353d0 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/description.md @@ -0,0 +1 @@ +Use for Confluence knowledge pages: search/read existing pages, create new pages, and update page content. diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/system_prompt.md new file mode 100644 index 000000000..4d3b7462c --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/system_prompt.md @@ -0,0 +1,55 @@ +You are the Confluence operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute Confluence page operations accurately in the connected space. + + + +- `create_confluence_page` +- `update_confluence_page` +- `delete_confluence_page` + + + +- Use only tools in ``. +- Verify target page and intended mutation before update/delete. +- If target page is ambiguous, return `status=blocked` with candidate options for supervisor disambiguation. +- Never invent page IDs, titles, or mutation outcomes. + + + +- Do not perform non-Confluence tasks. + + + +- Never claim page mutation success without tool confirmation. +- If destructive action appears already completed in this session, do not repeat; return prior evidence with an `assumptions` note. + + + +- On tool failure, return `status=error` with concise retry/recovery `next_step`. +- On unresolved page ambiguity, return `status=blocked` with candidates. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "page_id": string | null, + "page_title": string | null, + "matched_candidates": [ + { "page_id": string, "page_title": string | null } + ] | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/__init__.py new file mode 100644 index 000000000..3bf80b61b --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/__init__.py @@ -0,0 +1,11 @@ +"""Confluence tools for creating, updating, and deleting pages.""" + +from .create_page import create_create_confluence_page_tool +from .delete_page import create_delete_confluence_page_tool +from .update_page import create_update_confluence_page_tool + +__all__ = [ + "create_create_confluence_page_tool", + "create_delete_confluence_page_tool", + "create_update_confluence_page_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/create_page.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/create_page.py new file mode 100644 index 000000000..095413bdb --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/create_page.py @@ -0,0 +1,211 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.attributes import flag_modified + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.confluence_history import ConfluenceHistoryConnector +from app.services.confluence import ConfluenceToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_create_confluence_page_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + @tool + async def create_confluence_page( + title: str, + content: str | None = None, + space_id: str | None = None, + ) -> dict[str, Any]: + """Create a new page in Confluence. + + Use this tool when the user explicitly asks to create a new Confluence page. + + Args: + title: Title of the page. + content: Optional HTML/storage format content for the page body. + space_id: Optional Confluence space ID to create the page in. + + Returns: + Dictionary with status, page_id, and message. + + IMPORTANT: + - If status is "rejected", do NOT retry. + - If status is "insufficient_permissions", inform user to re-authenticate. + """ + logger.info(f"create_confluence_page called: title='{title}'") + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Confluence tool not properly configured.", + } + + try: + metadata_service = ConfluenceToolMetadataService(db_session) + context = await metadata_service.get_creation_context( + search_space_id, user_id + ) + + if "error" in context: + return {"status": "error", "message": context["error"]} + + accounts = context.get("accounts", []) + if accounts and all(a.get("auth_expired") for a in accounts): + return { + "status": "auth_error", + "message": "All connected Confluence accounts need re-authentication.", + "connector_type": "confluence", + } + + result = request_approval( + action_type="confluence_page_creation", + tool_name="create_confluence_page", + params={ + "title": title, + "content": content, + "space_id": space_id, + "connector_id": connector_id, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_title = result.params.get("title", title) + final_content = result.params.get("content", content) or "" + final_space_id = result.params.get("space_id", space_id) + final_connector_id = result.params.get("connector_id", connector_id) + + if not final_title or not final_title.strip(): + return {"status": "error", "message": "Page title cannot be empty."} + if not final_space_id: + return {"status": "error", "message": "A space must be selected."} + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + actual_connector_id = final_connector_id + if actual_connector_id is None: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.CONFLUENCE_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "No Confluence connector found.", + } + actual_connector_id = connector.id + else: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == actual_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.CONFLUENCE_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Confluence connector is invalid.", + } + + try: + client = ConfluenceHistoryConnector( + session=db_session, connector_id=actual_connector_id + ) + api_result = await client.create_page( + space_id=final_space_id, + title=final_title, + body=final_content, + ) + await client.close() + except Exception as api_err: + if ( + "http 403" in str(api_err).lower() + or "status code 403" in str(api_err).lower() + ): + try: + _conn = connector + _conn.config = {**_conn.config, "auth_expired": True} + flag_modified(_conn, "config") + await db_session.commit() + except Exception: + pass + return { + "status": "insufficient_permissions", + "connector_id": actual_connector_id, + "message": "This Confluence account needs additional permissions. Please re-authenticate in connector settings.", + } + raise + + page_id = str(api_result.get("id", "")) + page_links = ( + api_result.get("_links", {}) if isinstance(api_result, dict) else {} + ) + page_url = "" + if page_links.get("base") and page_links.get("webui"): + page_url = f"{page_links['base']}{page_links['webui']}" + + kb_message_suffix = "" + try: + from app.services.confluence import ConfluenceKBSyncService + + kb_service = ConfluenceKBSyncService(db_session) + kb_result = await kb_service.sync_after_create( + page_id=page_id, + page_title=final_title, + space_id=final_space_id, + body_content=final_content, + connector_id=actual_connector_id, + search_space_id=search_space_id, + user_id=user_id, + ) + if kb_result["status"] == "success": + kb_message_suffix = " Your knowledge base has also been updated." + else: + kb_message_suffix = " This page will be added to your knowledge base in the next scheduled sync." + except Exception as kb_err: + logger.warning(f"KB sync after create failed: {kb_err}") + kb_message_suffix = " This page will be added to your knowledge base in the next scheduled sync." + + return { + "status": "success", + "page_id": page_id, + "page_url": page_url, + "message": f"Confluence page '{final_title}' created successfully.{kb_message_suffix}", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error(f"Error creating Confluence page: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while creating the page.", + } + + return create_confluence_page diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/delete_page.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/delete_page.py new file mode 100644 index 000000000..7c03c2760 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/delete_page.py @@ -0,0 +1,189 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.attributes import flag_modified + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.confluence_history import ConfluenceHistoryConnector +from app.services.confluence import ConfluenceToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_delete_confluence_page_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + @tool + async def delete_confluence_page( + page_title_or_id: str, + delete_from_kb: bool = False, + ) -> dict[str, Any]: + """Delete a Confluence page. + + Use this tool when the user asks to delete or remove a Confluence page. + + Args: + page_title_or_id: The page title or ID to identify the page. + delete_from_kb: Whether to also remove from the knowledge base. + + Returns: + Dictionary with status, message, and deleted_from_kb. + + IMPORTANT: + - If status is "rejected", do NOT retry. + - If status is "not_found", relay the message to the user. + - If status is "insufficient_permissions", inform user to re-authenticate. + """ + logger.info( + f"delete_confluence_page called: page_title_or_id='{page_title_or_id}'" + ) + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Confluence tool not properly configured.", + } + + try: + metadata_service = ConfluenceToolMetadataService(db_session) + context = await metadata_service.get_deletion_context( + search_space_id, user_id, page_title_or_id + ) + + if "error" in context: + error_msg = context["error"] + if context.get("auth_expired"): + return { + "status": "auth_error", + "message": error_msg, + "connector_id": context.get("connector_id"), + "connector_type": "confluence", + } + if "not found" in error_msg.lower(): + return {"status": "not_found", "message": error_msg} + return {"status": "error", "message": error_msg} + + page_data = context["page"] + page_id = page_data["page_id"] + page_title = page_data.get("page_title", "") + document_id = page_data["document_id"] + connector_id_from_context = context.get("account", {}).get("id") + + result = request_approval( + action_type="confluence_page_deletion", + tool_name="delete_confluence_page", + params={ + "page_id": page_id, + "connector_id": connector_id_from_context, + "delete_from_kb": delete_from_kb, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_page_id = result.params.get("page_id", page_id) + final_connector_id = result.params.get( + "connector_id", connector_id_from_context + ) + final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb) + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + if not final_connector_id: + return { + "status": "error", + "message": "No connector found for this page.", + } + + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.CONFLUENCE_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Confluence connector is invalid.", + } + + try: + client = ConfluenceHistoryConnector( + session=db_session, connector_id=final_connector_id + ) + await client.delete_page(final_page_id) + await client.close() + except Exception as api_err: + if ( + "http 403" in str(api_err).lower() + or "status code 403" in str(api_err).lower() + ): + try: + connector.config = {**connector.config, "auth_expired": True} + flag_modified(connector, "config") + await db_session.commit() + except Exception: + pass + return { + "status": "insufficient_permissions", + "connector_id": final_connector_id, + "message": "This Confluence account needs additional permissions. Please re-authenticate in connector settings.", + } + raise + + deleted_from_kb = False + if final_delete_from_kb and document_id: + try: + from app.db import Document + + doc_result = await db_session.execute( + select(Document).filter(Document.id == document_id) + ) + document = doc_result.scalars().first() + if document: + await db_session.delete(document) + await db_session.commit() + deleted_from_kb = True + except Exception as e: + logger.error(f"Failed to delete document from KB: {e}") + await db_session.rollback() + + message = f"Confluence page '{page_title}' deleted successfully." + if deleted_from_kb: + message += " Also removed from the knowledge base." + + return { + "status": "success", + "page_id": final_page_id, + "deleted_from_kb": deleted_from_kb, + "message": message, + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error(f"Error deleting Confluence page: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while deleting the page.", + } + + return delete_confluence_page diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/index.py new file mode 100644 index 000000000..28c4ee6ee --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/index.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) + +from .create_page import create_create_confluence_page_tool +from .delete_page import create_delete_confluence_page_tool +from .update_page import create_update_confluence_page_tool + + +def load_tools( + *, dependencies: dict[str, Any] | None = None, **kwargs: Any +) -> ToolsPermissions: + resolved_dependencies = {**(dependencies or {}), **kwargs} + session_dependencies = { + "db_session": resolved_dependencies["db_session"], + "search_space_id": resolved_dependencies["search_space_id"], + "user_id": resolved_dependencies["user_id"], + "connector_id": resolved_dependencies.get("connector_id"), + } + create = create_create_confluence_page_tool(**session_dependencies) + update = create_update_confluence_page_tool(**session_dependencies) + delete = create_delete_confluence_page_tool(**session_dependencies) + return { + "allow": [], + "ask": [ + {"name": getattr(create, "name", "") or "", "tool": create}, + {"name": getattr(update, "name", "") or "", "tool": update}, + {"name": getattr(delete, "name", "") or "", "tool": delete}, + ], + } diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/update_page.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/update_page.py new file mode 100644 index 000000000..791d0d8c5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/update_page.py @@ -0,0 +1,218 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.attributes import flag_modified + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.confluence_history import ConfluenceHistoryConnector +from app.services.confluence import ConfluenceToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_update_confluence_page_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + @tool + async def update_confluence_page( + page_title_or_id: str, + new_title: str | None = None, + new_content: str | None = None, + ) -> dict[str, Any]: + """Update an existing Confluence page. + + Use this tool when the user asks to modify or edit a Confluence page. + + Args: + page_title_or_id: The page title or ID to identify the page. + new_title: Optional new title for the page. + new_content: Optional new HTML/storage format content. + + Returns: + Dictionary with status and message. + + IMPORTANT: + - If status is "rejected", do NOT retry. + - If status is "not_found", relay the message to the user. + - If status is "insufficient_permissions", inform user to re-authenticate. + """ + logger.info( + f"update_confluence_page called: page_title_or_id='{page_title_or_id}'" + ) + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Confluence tool not properly configured.", + } + + try: + metadata_service = ConfluenceToolMetadataService(db_session) + context = await metadata_service.get_update_context( + search_space_id, user_id, page_title_or_id + ) + + if "error" in context: + error_msg = context["error"] + if context.get("auth_expired"): + return { + "status": "auth_error", + "message": error_msg, + "connector_id": context.get("connector_id"), + "connector_type": "confluence", + } + if "not found" in error_msg.lower(): + return {"status": "not_found", "message": error_msg} + return {"status": "error", "message": error_msg} + + page_data = context["page"] + page_id = page_data["page_id"] + current_title = page_data["page_title"] + current_body = page_data.get("body", "") + current_version = page_data.get("version", 1) + document_id = page_data.get("document_id") + connector_id_from_context = context.get("account", {}).get("id") + + result = request_approval( + action_type="confluence_page_update", + tool_name="update_confluence_page", + params={ + "page_id": page_id, + "document_id": document_id, + "new_title": new_title, + "new_content": new_content, + "version": current_version, + "connector_id": connector_id_from_context, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_page_id = result.params.get("page_id", page_id) + final_title = result.params.get("new_title", new_title) or current_title + final_content = result.params.get("new_content", new_content) + if final_content is None: + final_content = current_body + final_version = result.params.get("version", current_version) + final_connector_id = result.params.get( + "connector_id", connector_id_from_context + ) + final_document_id = result.params.get("document_id", document_id) + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + if not final_connector_id: + return { + "status": "error", + "message": "No connector found for this page.", + } + + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.CONFLUENCE_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Confluence connector is invalid.", + } + + try: + client = ConfluenceHistoryConnector( + session=db_session, connector_id=final_connector_id + ) + api_result = await client.update_page( + page_id=final_page_id, + title=final_title, + body=final_content, + version_number=final_version + 1, + ) + await client.close() + except Exception as api_err: + if ( + "http 403" in str(api_err).lower() + or "status code 403" in str(api_err).lower() + ): + try: + connector.config = {**connector.config, "auth_expired": True} + flag_modified(connector, "config") + await db_session.commit() + except Exception: + pass + return { + "status": "insufficient_permissions", + "connector_id": final_connector_id, + "message": "This Confluence account needs additional permissions. Please re-authenticate in connector settings.", + } + raise + + page_links = ( + api_result.get("_links", {}) if isinstance(api_result, dict) else {} + ) + page_url = "" + if page_links.get("base") and page_links.get("webui"): + page_url = f"{page_links['base']}{page_links['webui']}" + + kb_message_suffix = "" + if final_document_id: + try: + from app.services.confluence import ConfluenceKBSyncService + + kb_service = ConfluenceKBSyncService(db_session) + kb_result = await kb_service.sync_after_update( + document_id=final_document_id, + page_id=final_page_id, + user_id=user_id, + search_space_id=search_space_id, + ) + if kb_result["status"] == "success": + kb_message_suffix = ( + " Your knowledge base has also been updated." + ) + else: + kb_message_suffix = ( + " The knowledge base will be updated in the next sync." + ) + except Exception as kb_err: + logger.warning(f"KB sync after update failed: {kb_err}") + kb_message_suffix = ( + " The knowledge base will be updated in the next sync." + ) + + return { + "status": "success", + "page_id": final_page_id, + "page_url": page_url, + "message": f"Confluence page '{final_title}' updated successfully.{kb_message_suffix}", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error(f"Error updating Confluence page: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while updating the page.", + } + + return update_confluence_page diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/agent.py new file mode 100644 index 000000000..feacecd78 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/agent.py @@ -0,0 +1,55 @@ +"""`discord` route: ``SubAgent`` spec for deepagents.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from deepagents import SubAgent +from langchain_core.language_models import BaseChatModel + +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, + middleware_gated_interrupt_on, +) +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( + pack_subagent, +) + +from .tools.index import load_tools + +NAME = "discord" + + +def build_subagent( + *, + dependencies: dict[str, Any], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, + extra_tools_bucket: ToolsPermissions | None = None, +) -> SubAgent: + buckets = load_tools(dependencies=dependencies) + merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket) + tools = [ + row["tool"] + for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) + if row.get("tool") is not None + ] + interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket) + description = read_md_file(__package__, "description").strip() + if not description: + description = "Handles discord tasks for this workspace." + system_prompt = read_md_file(__package__, "system_prompt").strip() + return pack_subagent( + name=NAME, + description=description, + system_prompt=system_prompt, + tools=tools, + interrupt_on=interrupt_on, + model=model, + extra_middleware=extra_middleware, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/description.md new file mode 100644 index 000000000..44065c10b --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/description.md @@ -0,0 +1 @@ +Use for Discord communication: read channel/thread messages, gather context, and send replies. diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/system_prompt.md new file mode 100644 index 000000000..40e9eb314 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/system_prompt.md @@ -0,0 +1,56 @@ +You are the Discord operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute Discord reads and sends accurately in the connected server/workspace. + + + +- `list_discord_channels` +- `read_discord_messages` +- `send_discord_message` + + + +- Use only tools in ``. +- Resolve channel/thread targets before reads/sends. +- If target is ambiguous, return `status=blocked` with candidate channels/threads. +- Never invent message content, sender identity, timestamps, or delivery results. + + + +- Do not perform non-Discord tasks. + + + +- Before send, verify destination and message intent match delegated instructions. +- Never claim send success without tool confirmation. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On unresolved destination ambiguity, return `status=blocked` with candidate options. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "channel_id": string | null, + "thread_id": string | null, + "message_id": string | null, + "matched_candidates": [ + { "channel_id": string, "thread_id": string | null, "label": string | null } + ] | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/__init__.py new file mode 100644 index 000000000..b4eaec1f0 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/__init__.py @@ -0,0 +1,15 @@ +from app.agents.new_chat.tools.discord.list_channels import ( + create_list_discord_channels_tool, +) +from app.agents.new_chat.tools.discord.read_messages import ( + create_read_discord_messages_tool, +) +from app.agents.new_chat.tools.discord.send_message import ( + create_send_discord_message_tool, +) + +__all__ = [ + "create_list_discord_channels_tool", + "create_read_discord_messages_tool", + "create_send_discord_message_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/_auth.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/_auth.py new file mode 100644 index 000000000..7636aff71 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/_auth.py @@ -0,0 +1,43 @@ +"""Builds Discord REST API auth headers for connector-backed tools.""" + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.config import config +from app.db import SearchSourceConnector, SearchSourceConnectorType +from app.utils.oauth_security import TokenEncryption + +DISCORD_API = "https://discord.com/api/v10" + + +async def get_discord_connector( + db_session: AsyncSession, + search_space_id: int, + user_id: str, +) -> SearchSourceConnector | None: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.DISCORD_CONNECTOR, + ) + ) + return result.scalars().first() + + +def get_bot_token(connector: SearchSourceConnector) -> str: + """Extract and decrypt the bot token from connector config.""" + cfg = dict(connector.config) + if cfg.get("_token_encrypted") and config.SECRET_KEY: + enc = TokenEncryption(config.SECRET_KEY) + if cfg.get("bot_token"): + cfg["bot_token"] = enc.decrypt_token(cfg["bot_token"]) + token = cfg.get("bot_token") + if not token: + raise ValueError("Discord bot token not found in connector config.") + return token + + +def get_guild_id(connector: SearchSourceConnector) -> str | None: + return connector.config.get("guild_id") diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/index.py new file mode 100644 index 000000000..c0a3bf3c9 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/index.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) + +from .list_channels import create_list_discord_channels_tool +from .read_messages import create_read_discord_messages_tool +from .send_message import create_send_discord_message_tool + + +def load_tools( + *, dependencies: dict[str, Any] | None = None, **kwargs: Any +) -> ToolsPermissions: + d = {**(dependencies or {}), **kwargs} + common = { + "db_session": d["db_session"], + "search_space_id": d["search_space_id"], + "user_id": d["user_id"], + } + list_ch = create_list_discord_channels_tool(**common) + read_msg = create_read_discord_messages_tool(**common) + send = create_send_discord_message_tool(**common) + return { + "allow": [ + {"name": getattr(list_ch, "name", "") or "", "tool": list_ch}, + {"name": getattr(read_msg, "name", "") or "", "tool": read_msg}, + ], + "ask": [{"name": getattr(send, "name", "") or "", "tool": send}], + } diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/list_channels.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/list_channels.py new file mode 100644 index 000000000..3cc99ac17 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/list_channels.py @@ -0,0 +1,87 @@ +import logging +from typing import Any + +import httpx +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from ._auth import DISCORD_API, get_bot_token, get_discord_connector, get_guild_id + +logger = logging.getLogger(__name__) + + +def create_list_discord_channels_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def list_discord_channels() -> dict[str, Any]: + """List text channels in the connected Discord server. + + Returns: + Dictionary with status and a list of channels (id, name). + """ + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Discord tool not properly configured.", + } + + try: + connector = await get_discord_connector( + db_session, search_space_id, user_id + ) + if not connector: + return {"status": "error", "message": "No Discord connector found."} + + guild_id = get_guild_id(connector) + if not guild_id: + return { + "status": "error", + "message": "No guild ID in Discord connector config.", + } + + token = get_bot_token(connector) + + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{DISCORD_API}/guilds/{guild_id}/channels", + headers={"Authorization": f"Bot {token}"}, + timeout=15.0, + ) + + if resp.status_code == 401: + return { + "status": "auth_error", + "message": "Discord bot token is invalid.", + "connector_type": "discord", + } + if resp.status_code != 200: + return { + "status": "error", + "message": f"Discord API error: {resp.status_code}", + } + + # Type 0 = text channel + channels = [ + {"id": ch["id"], "name": ch["name"]} + for ch in resp.json() + if ch.get("type") == 0 + ] + return { + "status": "success", + "guild_id": guild_id, + "channels": channels, + "total": len(channels), + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error("Error listing Discord channels: %s", e, exc_info=True) + return {"status": "error", "message": "Failed to list Discord channels."} + + return list_discord_channels diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/read_messages.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/read_messages.py new file mode 100644 index 000000000..d8bf989a1 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/read_messages.py @@ -0,0 +1,100 @@ +import logging +from typing import Any + +import httpx +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from ._auth import DISCORD_API, get_bot_token, get_discord_connector + +logger = logging.getLogger(__name__) + + +def create_read_discord_messages_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def read_discord_messages( + channel_id: str, + limit: int = 25, + ) -> dict[str, Any]: + """Read recent messages from a Discord text channel. + + Args: + channel_id: The Discord channel ID (from list_discord_channels). + limit: Number of messages to fetch (default 25, max 50). + + Returns: + Dictionary with status and a list of messages including + id, author, content, timestamp. + """ + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Discord tool not properly configured.", + } + + limit = min(limit, 50) + + try: + connector = await get_discord_connector( + db_session, search_space_id, user_id + ) + if not connector: + return {"status": "error", "message": "No Discord connector found."} + + token = get_bot_token(connector) + + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{DISCORD_API}/channels/{channel_id}/messages", + headers={"Authorization": f"Bot {token}"}, + params={"limit": limit}, + timeout=15.0, + ) + + if resp.status_code == 401: + return { + "status": "auth_error", + "message": "Discord bot token is invalid.", + "connector_type": "discord", + } + if resp.status_code == 403: + return { + "status": "error", + "message": "Bot lacks permission to read this channel.", + } + if resp.status_code != 200: + return { + "status": "error", + "message": f"Discord API error: {resp.status_code}", + } + + messages = [ + { + "id": m["id"], + "author": m.get("author", {}).get("username", "Unknown"), + "content": m.get("content", ""), + "timestamp": m.get("timestamp", ""), + } + for m in resp.json() + ] + + return { + "status": "success", + "channel_id": channel_id, + "messages": messages, + "total": len(messages), + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error("Error reading Discord messages: %s", e, exc_info=True) + return {"status": "error", "message": "Failed to read Discord messages."} + + return read_discord_messages diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/send_message.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/send_message.py new file mode 100644 index 000000000..236cd017a --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/send_message.py @@ -0,0 +1,117 @@ +import logging +from typing import Any + +import httpx +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval + +from ._auth import DISCORD_API, get_bot_token, get_discord_connector + +logger = logging.getLogger(__name__) + + +def create_send_discord_message_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def send_discord_message( + channel_id: str, + content: str, + ) -> dict[str, Any]: + """Send a message to a Discord text channel. + + Args: + channel_id: The Discord channel ID (from list_discord_channels). + content: The message text (max 2000 characters). + + Returns: + Dictionary with status, message_id on success. + + IMPORTANT: + - If status is "rejected", the user explicitly declined. Do NOT retry. + """ + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Discord tool not properly configured.", + } + + if len(content) > 2000: + return { + "status": "error", + "message": "Message exceeds Discord's 2000-character limit.", + } + + try: + connector = await get_discord_connector( + db_session, search_space_id, user_id + ) + if not connector: + return {"status": "error", "message": "No Discord connector found."} + + result = request_approval( + action_type="discord_send_message", + tool_name="send_discord_message", + params={"channel_id": channel_id, "content": content}, + context={"connector_id": connector.id}, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Message was not sent.", + } + + final_content = result.params.get("content", content) + final_channel = result.params.get("channel_id", channel_id) + + token = get_bot_token(connector) + + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{DISCORD_API}/channels/{final_channel}/messages", + headers={ + "Authorization": f"Bot {token}", + "Content-Type": "application/json", + }, + json={"content": final_content}, + timeout=15.0, + ) + + if resp.status_code == 401: + return { + "status": "auth_error", + "message": "Discord bot token is invalid.", + "connector_type": "discord", + } + if resp.status_code == 403: + return { + "status": "error", + "message": "Bot lacks permission to send messages in this channel.", + } + if resp.status_code not in (200, 201): + return { + "status": "error", + "message": f"Discord API error: {resp.status_code}", + } + + msg_data = resp.json() + return { + "status": "success", + "message_id": msg_data.get("id"), + "message": f"Message sent to channel {final_channel}.", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error("Error sending Discord message: %s", e, exc_info=True) + return {"status": "error", "message": "Failed to send Discord message."} + + return send_discord_message diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/agent.py new file mode 100644 index 000000000..9ff9bc1f3 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/agent.py @@ -0,0 +1,55 @@ +"""`dropbox` route: ``SubAgent`` spec for deepagents.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from deepagents import SubAgent +from langchain_core.language_models import BaseChatModel + +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, + middleware_gated_interrupt_on, +) +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( + pack_subagent, +) + +from .tools.index import load_tools + +NAME = "dropbox" + + +def build_subagent( + *, + dependencies: dict[str, Any], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, + extra_tools_bucket: ToolsPermissions | None = None, +) -> SubAgent: + buckets = load_tools(dependencies=dependencies) + merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket) + tools = [ + row["tool"] + for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) + if row.get("tool") is not None + ] + interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket) + description = read_md_file(__package__, "description").strip() + if not description: + description = "Handles dropbox tasks for this workspace." + system_prompt = read_md_file(__package__, "system_prompt").strip() + return pack_subagent( + name=NAME, + description=description, + system_prompt=system_prompt, + tools=tools, + interrupt_on=interrupt_on, + model=model, + extra_middleware=extra_middleware, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/description.md new file mode 100644 index 000000000..9c2575dd2 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/description.md @@ -0,0 +1 @@ +Use for Dropbox file storage tasks: browse folders, read files, and manage Dropbox file content. diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/system_prompt.md new file mode 100644 index 000000000..4b19be794 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/system_prompt.md @@ -0,0 +1,52 @@ +You are the Dropbox operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute Dropbox file create/delete actions accurately in the connected account. + + + +- `create_dropbox_file` +- `delete_dropbox_file` + + + +- Use only tools in ``. +- Ensure target path/file identity is explicit before mutate actions. +- If target is ambiguous, return `status=blocked` with candidate paths. +- Never invent file IDs/paths or mutation outcomes. + + + +- Do not perform non-Dropbox tasks. + + + +- Never claim file mutation success without tool confirmation. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On target ambiguity, return `status=blocked` with candidate paths. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "file_path": string | null, + "file_id": string | null, + "operation": "create" | "delete" | null, + "matched_candidates": string[] | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/__init__.py new file mode 100644 index 000000000..836b9ee41 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/__init__.py @@ -0,0 +1,11 @@ +from app.agents.new_chat.tools.dropbox.create_file import ( + create_create_dropbox_file_tool, +) +from app.agents.new_chat.tools.dropbox.trash_file import ( + create_delete_dropbox_file_tool, +) + +__all__ = [ + "create_create_dropbox_file_tool", + "create_delete_dropbox_file_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/create_file.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/create_file.py new file mode 100644 index 000000000..22d8a8a27 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/create_file.py @@ -0,0 +1,275 @@ +import logging +import os +import tempfile +from pathlib import Path +from typing import Any, Literal + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.dropbox.client import DropboxClient +from app.db import SearchSourceConnector, SearchSourceConnectorType + +logger = logging.getLogger(__name__) + +DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + +_FILE_TYPE_LABELS = { + "paper": "Dropbox Paper (.paper)", + "docx": "Word Document (.docx)", +} + +_SUPPORTED_TYPES = [ + {"value": "paper", "label": "Dropbox Paper (.paper)"}, + {"value": "docx", "label": "Word Document (.docx)"}, +] + + +def _ensure_extension(name: str, file_type: str) -> str: + """Strip any existing extension and append the correct one.""" + stem = Path(name).stem + ext = ".paper" if file_type == "paper" else ".docx" + return f"{stem}{ext}" + + +def _markdown_to_docx(markdown_text: str) -> bytes: + """Convert a markdown string to DOCX bytes using pypandoc.""" + import pypandoc + + fd, tmp_path = tempfile.mkstemp(suffix=".docx") + os.close(fd) + try: + pypandoc.convert_text( + markdown_text, + "docx", + format="gfm", + extra_args=["--standalone"], + outputfile=tmp_path, + ) + with open(tmp_path, "rb") as f: + return f.read() + finally: + os.unlink(tmp_path) + + +def create_create_dropbox_file_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def create_dropbox_file( + name: str, + file_type: Literal["paper", "docx"] = "paper", + content: str | None = None, + ) -> dict[str, Any]: + """Create a new document in Dropbox. + + Use this tool when the user explicitly asks to create a new document + in Dropbox. The user MUST specify a topic before you call this tool. + + Args: + name: The document title (without extension). + file_type: Either "paper" (Dropbox Paper, default) or "docx" (Word document). + content: Optional initial content as markdown. + + Returns: + Dictionary with status, file_id, name, web_url, and message. + """ + logger.info( + f"create_dropbox_file called: name='{name}', file_type='{file_type}'" + ) + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Dropbox tool not properly configured.", + } + + try: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.DROPBOX_CONNECTOR, + ) + ) + connectors = result.scalars().all() + + if not connectors: + return { + "status": "error", + "message": "No Dropbox connector found. Please connect Dropbox in your workspace settings.", + } + + accounts = [] + for c in connectors: + cfg = c.config or {} + accounts.append( + { + "id": c.id, + "name": c.name, + "user_email": cfg.get("user_email"), + "auth_expired": cfg.get("auth_expired", False), + } + ) + + if all(a.get("auth_expired") for a in accounts): + return { + "status": "auth_error", + "message": "All connected Dropbox accounts need re-authentication.", + "connector_type": "dropbox", + } + + parent_folders: dict[int, list[dict[str, str]]] = {} + for acc in accounts: + cid = acc["id"] + if acc.get("auth_expired"): + parent_folders[cid] = [] + continue + try: + client = DropboxClient(session=db_session, connector_id=cid) + items, err = await client.list_folder("") + if err: + logger.warning( + "Failed to list folders for connector %s: %s", cid, err + ) + parent_folders[cid] = [] + else: + parent_folders[cid] = [ + { + "folder_path": item.get("path_lower", ""), + "name": item["name"], + } + for item in items + if item.get(".tag") == "folder" and item.get("name") + ] + except Exception: + logger.warning( + "Error fetching folders for connector %s", cid, exc_info=True + ) + parent_folders[cid] = [] + + context: dict[str, Any] = { + "accounts": accounts, + "parent_folders": parent_folders, + "supported_types": _SUPPORTED_TYPES, + } + + result = request_approval( + action_type="dropbox_file_creation", + tool_name="create_dropbox_file", + params={ + "name": name, + "file_type": file_type, + "content": content, + "connector_id": None, + "parent_folder_path": None, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_name = result.params.get("name", name) + final_file_type = result.params.get("file_type", file_type) + final_content = result.params.get("content", content) + final_connector_id = result.params.get("connector_id") + final_parent_folder_path = result.params.get("parent_folder_path") + + if not final_name or not final_name.strip(): + return {"status": "error", "message": "File name cannot be empty."} + + final_name = _ensure_extension(final_name, final_file_type) + + if final_connector_id is not None: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.DROPBOX_CONNECTOR, + ) + ) + connector = result.scalars().first() + else: + connector = connectors[0] + + if not connector: + return { + "status": "error", + "message": "Selected Dropbox connector is invalid.", + } + + client = DropboxClient(session=db_session, connector_id=connector.id) + + parent_path = final_parent_folder_path or "" + file_path = ( + f"{parent_path}/{final_name}" if parent_path else f"/{final_name}" + ) + + if final_file_type == "paper": + created = await client.create_paper_doc(file_path, final_content or "") + file_id = created.get("file_id", "") + web_url = created.get("url", "") + else: + docx_bytes = _markdown_to_docx(final_content or "") + created = await client.upload_file( + file_path, docx_bytes, mode="add", autorename=True + ) + file_id = created.get("id", "") + web_url = "" + + logger.info(f"Dropbox file created: id={file_id}, name={final_name}") + + kb_message_suffix = "" + try: + from app.services.dropbox import DropboxKBSyncService + + kb_service = DropboxKBSyncService(db_session) + kb_result = await kb_service.sync_after_create( + file_id=file_id, + file_name=final_name, + file_path=file_path, + web_url=web_url, + content=final_content, + connector_id=connector.id, + search_space_id=search_space_id, + user_id=user_id, + ) + if kb_result["status"] == "success": + kb_message_suffix = " Your knowledge base has also been updated." + else: + kb_message_suffix = " This file will be added to your knowledge base in the next scheduled sync." + except Exception as kb_err: + logger.warning(f"KB sync after create failed: {kb_err}") + kb_message_suffix = " This file will be added to your knowledge base in the next scheduled sync." + + return { + "status": "success", + "file_id": file_id, + "name": final_name, + "web_url": web_url, + "message": f"Successfully created '{final_name}' in Dropbox.{kb_message_suffix}", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error(f"Error creating Dropbox file: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while creating the file. Please try again.", + } + + return create_dropbox_file diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/index.py new file mode 100644 index 000000000..5864ae972 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/index.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) + +from .create_file import create_create_dropbox_file_tool +from .trash_file import create_delete_dropbox_file_tool + + +def load_tools( + *, dependencies: dict[str, Any] | None = None, **kwargs: Any +) -> ToolsPermissions: + d = {**(dependencies or {}), **kwargs} + common = { + "db_session": d["db_session"], + "search_space_id": d["search_space_id"], + "user_id": d["user_id"], + } + create = create_create_dropbox_file_tool(**common) + delete = create_delete_dropbox_file_tool(**common) + return { + "allow": [], + "ask": [ + {"name": getattr(create, "name", "") or "", "tool": create}, + {"name": getattr(delete, "name", "") or "", "tool": delete}, + ], + } diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/trash_file.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/trash_file.py new file mode 100644 index 000000000..12559b57a --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/trash_file.py @@ -0,0 +1,277 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy import String, and_, cast, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.dropbox.client import DropboxClient +from app.db import ( + Document, + DocumentType, + SearchSourceConnector, + SearchSourceConnectorType, +) + +logger = logging.getLogger(__name__) + + +def create_delete_dropbox_file_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def delete_dropbox_file( + file_name: str, + delete_from_kb: bool = False, + ) -> dict[str, Any]: + """Delete a file from Dropbox. + + Use this tool when the user explicitly asks to delete, remove, or trash + a file in Dropbox. + + Args: + file_name: The exact name of the file to delete. + delete_from_kb: Whether to also remove the file from the knowledge base. + Default is False. + + Returns: + Dictionary with: + - status: "success", "rejected", "not_found", or "error" + - file_id: Dropbox file ID (if success) + - deleted_from_kb: whether the document was removed from the knowledge base + - message: Result message + + IMPORTANT: + - If status is "rejected", the user explicitly declined. Respond with a brief + acknowledgment and do NOT retry or suggest alternatives. + - If status is "not_found", relay the exact message to the user and ask them + to verify the file name or check if it has been indexed. + """ + logger.info( + f"delete_dropbox_file called: file_name='{file_name}', delete_from_kb={delete_from_kb}" + ) + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Dropbox tool not properly configured.", + } + + try: + doc_result = await db_session.execute( + select(Document) + .join( + SearchSourceConnector, + Document.connector_id == SearchSourceConnector.id, + ) + .filter( + and_( + Document.search_space_id == search_space_id, + Document.document_type == DocumentType.DROPBOX_FILE, + func.lower(Document.title) == func.lower(file_name), + SearchSourceConnector.user_id == user_id, + ) + ) + .order_by(Document.updated_at.desc().nullslast()) + .limit(1) + ) + document = doc_result.scalars().first() + + if not document: + doc_result = await db_session.execute( + select(Document) + .join( + SearchSourceConnector, + Document.connector_id == SearchSourceConnector.id, + ) + .filter( + and_( + Document.search_space_id == search_space_id, + Document.document_type == DocumentType.DROPBOX_FILE, + func.lower( + cast( + Document.document_metadata["dropbox_file_name"], + String, + ) + ) + == func.lower(file_name), + SearchSourceConnector.user_id == user_id, + ) + ) + .order_by(Document.updated_at.desc().nullslast()) + .limit(1) + ) + document = doc_result.scalars().first() + + if not document: + return { + "status": "not_found", + "message": ( + f"File '{file_name}' not found in your indexed Dropbox files. " + "This could mean: (1) the file doesn't exist, (2) it hasn't been indexed yet, " + "or (3) the file name is different." + ), + } + + if not document.connector_id: + return { + "status": "error", + "message": "Document has no associated connector.", + } + + meta = document.document_metadata or {} + file_path = meta.get("dropbox_path") + file_id = meta.get("dropbox_file_id") + document_id = document.id + + if not file_path: + return { + "status": "error", + "message": "File path is missing. Please re-index the file.", + } + + conn_result = await db_session.execute( + select(SearchSourceConnector).filter( + and_( + SearchSourceConnector.id == document.connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.DROPBOX_CONNECTOR, + ) + ) + ) + connector = conn_result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Dropbox connector not found or access denied.", + } + + cfg = connector.config or {} + if cfg.get("auth_expired"): + return { + "status": "auth_error", + "message": "Dropbox account needs re-authentication. Please re-authenticate in your connector settings.", + "connector_type": "dropbox", + } + + context = { + "file": { + "file_id": file_id, + "file_path": file_path, + "name": file_name, + "document_id": document_id, + }, + "account": { + "id": connector.id, + "name": connector.name, + "user_email": cfg.get("user_email"), + }, + } + + result = request_approval( + action_type="dropbox_file_trash", + tool_name="delete_dropbox_file", + params={ + "file_path": file_path, + "connector_id": connector.id, + "delete_from_kb": delete_from_kb, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_file_path = result.params.get("file_path", file_path) + final_connector_id = result.params.get("connector_id", connector.id) + final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb) + + if final_connector_id != connector.id: + result = await db_session.execute( + select(SearchSourceConnector).filter( + and_( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.DROPBOX_CONNECTOR, + ) + ) + ) + validated_connector = result.scalars().first() + if not validated_connector: + return { + "status": "error", + "message": "Selected Dropbox connector is invalid or has been disconnected.", + } + actual_connector_id = validated_connector.id + else: + actual_connector_id = connector.id + + logger.info( + f"Deleting Dropbox file: path='{final_file_path}', connector={actual_connector_id}" + ) + + client = DropboxClient(session=db_session, connector_id=actual_connector_id) + await client.delete_file(final_file_path) + + logger.info(f"Dropbox file deleted: path={final_file_path}") + + trash_result: dict[str, Any] = { + "status": "success", + "file_id": file_id, + "message": f"Successfully deleted '{file_name}' from Dropbox.", + } + + deleted_from_kb = False + if final_delete_from_kb and document_id: + try: + doc_result = await db_session.execute( + select(Document).filter(Document.id == document_id) + ) + doc = doc_result.scalars().first() + if doc: + await db_session.delete(doc) + await db_session.commit() + deleted_from_kb = True + logger.info( + f"Deleted document {document_id} from knowledge base" + ) + else: + logger.warning(f"Document {document_id} not found in KB") + except Exception as e: + logger.error(f"Failed to delete document from KB: {e}") + await db_session.rollback() + trash_result["warning"] = ( + f"File deleted, but failed to remove from knowledge base: {e!s}" + ) + + trash_result["deleted_from_kb"] = deleted_from_kb + if deleted_from_kb: + trash_result["message"] = ( + f"{trash_result.get('message', '')} (also removed from knowledge base)" + ) + + return trash_result + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error(f"Error deleting Dropbox file: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while deleting the file. Please try again.", + } + + return delete_dropbox_file diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/agent.py new file mode 100644 index 000000000..5edf37b85 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/agent.py @@ -0,0 +1,55 @@ +"""`gmail` route: ``SubAgent`` spec for deepagents.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from deepagents import SubAgent +from langchain_core.language_models import BaseChatModel + +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, + middleware_gated_interrupt_on, +) +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( + pack_subagent, +) + +from .tools.index import load_tools + +NAME = "gmail" + + +def build_subagent( + *, + dependencies: dict[str, Any], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, + extra_tools_bucket: ToolsPermissions | None = None, +) -> SubAgent: + buckets = load_tools(dependencies=dependencies) + merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket) + tools = [ + row["tool"] + for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) + if row.get("tool") is not None + ] + interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket) + description = read_md_file(__package__, "description").strip() + if not description: + description = "Handles gmail tasks for this workspace." + system_prompt = read_md_file(__package__, "system_prompt").strip() + return pack_subagent( + name=NAME, + description=description, + system_prompt=system_prompt, + tools=tools, + interrupt_on=interrupt_on, + model=model, + extra_middleware=extra_middleware, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/description.md new file mode 100644 index 000000000..db5614805 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/description.md @@ -0,0 +1 @@ +Use for Gmail inbox actions: search/read emails, draft or update replies, send messages, and trash emails. diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/system_prompt.md new file mode 100644 index 000000000..961100261 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/system_prompt.md @@ -0,0 +1,82 @@ +You are the Gmail operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute Gmail operations accurately: search/read emails, prepare drafts, send, and trash. + + + +- `search_gmail`: find candidate emails with query constraints. +- `read_gmail_email`: read one message in full detail. +- `create_gmail_draft`: create a new draft. +- `update_gmail_draft`: modify an existing draft. +- `send_gmail_email`: send an email. +- `trash_gmail_email`: move an email to trash. + + + +- Use only tools in ``. +- Build precise search queries using Gmail operators when possible (`from:`, `to:`, `subject:`, `after:`, `before:`, `has:attachment`, `is:unread`, `label:`). +- Resolve relative dates against runtime timestamp; prefer narrower interpretation. +- For reply requests, identify the target thread/email via search + read before drafting. +- If required fields are missing or target selection is ambiguous, return `status=blocked` with `missing_fields` and disambiguation candidates. +- Never invent IDs, recipients, timestamps, quoted text, or tool outcomes. + + + +- Do not perform non-Gmail work. +- Filing operations not represented in `` (archive/label/mark-read/move-folder) are unsupported here. + + + +- For send: verify draft `to`, `subject`, and `body` match delegated instructions. +- If any send-critical field was inferred, do not send; return `status=blocked` with inferred values in `assumptions`. +- For trash: ensure explicit target match before deletion. +- If a destructive action appears already completed this session, do not repeat; return prior evidence. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- If search has no strong match, return `status=blocked` with suggested tighter filters. +- If multiple strong candidates remain for risky actions, return `status=blocked` with top options. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "email_id": string | null, + "thread_id": string | null, + "subject": string | null, + "sender": string | null, + "recipients": string[] | null, + "received_at": string (ISO 8601 with timezone) | null, + "sent_message": { + "id": string, + "to": string[], + "subject": string | null, + "sent_at": string (ISO 8601 with timezone) | null + } | null, + "matched_candidates": [ + { + "email_id": string, + "subject": string | null, + "sender": string | null, + "received_at": string (ISO 8601 with timezone) | null + } + ] | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} + +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. +- For blocked ambiguity, include options in `evidence.matched_candidates`. +- For trash actions, `evidence.email_id` is the trashed message. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/__init__.py new file mode 100644 index 000000000..294840122 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/__init__.py @@ -0,0 +1,27 @@ +from app.agents.new_chat.tools.gmail.create_draft import ( + create_create_gmail_draft_tool, +) +from app.agents.new_chat.tools.gmail.read_email import ( + create_read_gmail_email_tool, +) +from app.agents.new_chat.tools.gmail.search_emails import ( + create_search_gmail_tool, +) +from app.agents.new_chat.tools.gmail.send_email import ( + create_send_gmail_email_tool, +) +from app.agents.new_chat.tools.gmail.trash_email import ( + create_trash_gmail_email_tool, +) +from app.agents.new_chat.tools.gmail.update_draft import ( + create_update_gmail_draft_tool, +) + +__all__ = [ + "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", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/create_draft.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/create_draft.py new file mode 100644 index 000000000..59e471097 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/create_draft.py @@ -0,0 +1,338 @@ +import asyncio +import base64 +import logging +from datetime import datetime +from email.mime.text import MIMEText +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval +from app.services.gmail import GmailToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_create_gmail_draft_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def create_gmail_draft( + to: str, + subject: str, + body: str, + cc: str | None = None, + bcc: str | None = None, + ) -> dict[str, Any]: + """Create a draft email in Gmail. + + Use when the user asks to draft, compose, or prepare an email without + sending it. + + Args: + to: Recipient email address. + subject: Email subject line. + body: Email body content. + cc: Optional CC recipient(s), comma-separated. + bcc: Optional BCC recipient(s), comma-separated. + + Returns: + Dictionary with: + - status: "success", "rejected", or "error" + - draft_id: Gmail draft ID (if success) + - message: Result message + + IMPORTANT: + - If status is "rejected", the user explicitly declined the action. + Respond with a brief acknowledgment and do NOT retry or suggest alternatives. + - If status is "insufficient_permissions", the connector lacks the required OAuth scope. + Inform the user they need to re-authenticate and do NOT retry the action. + + Examples: + - "Draft an email to alice@example.com about the meeting" + - "Compose a reply to Bob about the project update" + """ + logger.info(f"create_gmail_draft called: to='{to}', subject='{subject}'") + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Gmail tool not properly configured. Please contact support.", + } + + try: + metadata_service = GmailToolMetadataService(db_session) + context = await metadata_service.get_creation_context( + search_space_id, user_id + ) + + if "error" in context: + logger.error(f"Failed to fetch creation context: {context['error']}") + return {"status": "error", "message": context["error"]} + + accounts = context.get("accounts", []) + if accounts and all(a.get("auth_expired") for a in accounts): + logger.warning("All Gmail accounts have expired authentication") + return { + "status": "auth_error", + "message": "All connected Gmail accounts need re-authentication. Please re-authenticate in your connector settings.", + "connector_type": "gmail", + } + + logger.info( + f"Requesting approval for creating Gmail draft: to='{to}', subject='{subject}'" + ) + result = request_approval( + action_type="gmail_draft_creation", + tool_name="create_gmail_draft", + params={ + "to": to, + "subject": subject, + "body": body, + "cc": cc, + "bcc": bcc, + "connector_id": None, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. The draft was not created. Do not ask again or suggest alternatives.", + } + + final_to = result.params.get("to", to) + final_subject = result.params.get("subject", subject) + final_body = result.params.get("body", body) + final_cc = result.params.get("cc", cc) + final_bcc = result.params.get("bcc", bcc) + final_connector_id = result.params.get("connector_id") + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + _gmail_types = [ + SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR, + SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR, + ] + + if final_connector_id is not None: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type.in_(_gmail_types), + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Gmail connector is invalid or has been disconnected.", + } + actual_connector_id = connector.id + else: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type.in_(_gmail_types), + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "No Gmail connector found. Please connect Gmail in your workspace settings.", + } + actual_connector_id = connector.id + + logger.info( + f"Creating Gmail draft: to='{final_to}', subject='{final_subject}', connector={actual_connector_id}" + ) + + if ( + connector.connector_type + == SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR + ): + cca_id = connector.config.get("composio_connected_account_id") + if not cca_id: + return { + "status": "error", + "message": "Composio connected account ID not found for this Gmail connector.", + } + + from app.services.composio_service import ComposioService + + ( + draft_id, + draft_message_id, + draft_thread_id, + error, + ) = await ComposioService().create_gmail_draft( + connected_account_id=cca_id, + entity_id=f"surfsense_{user_id}", + to=final_to, + subject=final_subject, + body=final_body, + cc=final_cc, + bcc=final_bcc, + ) + if error: + return {"status": "error", "message": error} + created = { + "id": draft_id, + "message": { + "id": draft_message_id, + "threadId": draft_thread_id, + }, + } + logger.info(f"Gmail draft created via Composio: id={draft_id}") + else: + from google.oauth2.credentials import Credentials + + from app.config import config + from app.utils.oauth_security import TokenEncryption + + config_data = dict(connector.config) + token_encrypted = config_data.get("_token_encrypted", False) + if token_encrypted and config.SECRET_KEY: + token_encryption = TokenEncryption(config.SECRET_KEY) + if config_data.get("token"): + config_data["token"] = token_encryption.decrypt_token( + config_data["token"] + ) + if config_data.get("refresh_token"): + config_data["refresh_token"] = token_encryption.decrypt_token( + config_data["refresh_token"] + ) + if config_data.get("client_secret"): + config_data["client_secret"] = token_encryption.decrypt_token( + config_data["client_secret"] + ) + + exp = config_data.get("expiry", "") + if exp: + exp = exp.replace("Z", "") + + creds = Credentials( + token=config_data.get("token"), + refresh_token=config_data.get("refresh_token"), + token_uri=config_data.get("token_uri"), + client_id=config_data.get("client_id"), + client_secret=config_data.get("client_secret"), + scopes=config_data.get("scopes", []), + expiry=datetime.fromisoformat(exp) if exp else None, + ) + + from googleapiclient.discovery import build + + gmail_service = build("gmail", "v1", credentials=creds) + + message = MIMEText(final_body) + message["to"] = final_to + message["subject"] = final_subject + if final_cc: + message["cc"] = final_cc + if final_bcc: + message["bcc"] = final_bcc + raw = base64.urlsafe_b64encode(message.as_bytes()).decode() + + try: + created = await asyncio.get_event_loop().run_in_executor( + None, + lambda: ( + gmail_service.users() + .drafts() + .create(userId="me", body={"message": {"raw": raw}}) + .execute() + ), + ) + except Exception as api_err: + from googleapiclient.errors import HttpError + + if isinstance(api_err, HttpError) and api_err.resp.status == 403: + logger.warning( + f"Insufficient permissions for connector {actual_connector_id}: {api_err}" + ) + try: + from sqlalchemy.orm.attributes import flag_modified + + _res = await db_session.execute( + select(SearchSourceConnector).where( + SearchSourceConnector.id == actual_connector_id + ) + ) + _conn = _res.scalar_one_or_none() + if _conn and not _conn.config.get("auth_expired"): + _conn.config = {**_conn.config, "auth_expired": True} + flag_modified(_conn, "config") + await db_session.commit() + except Exception: + logger.warning( + "Failed to persist auth_expired for connector %s", + actual_connector_id, + exc_info=True, + ) + return { + "status": "insufficient_permissions", + "connector_id": actual_connector_id, + "message": "This Gmail account needs additional permissions. Please re-authenticate in connector settings.", + } + raise + + logger.info( + f"Gmail draft created via Google API: id={created.get('id')}" + ) + + kb_message_suffix = "" + try: + from app.services.gmail import GmailKBSyncService + + kb_service = GmailKBSyncService(db_session) + draft_message = created.get("message", {}) + kb_result = await kb_service.sync_after_create( + message_id=draft_message.get("id", ""), + thread_id=draft_message.get("threadId", ""), + subject=final_subject, + sender="me", + date_str=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + body_text=final_body, + connector_id=actual_connector_id, + search_space_id=search_space_id, + user_id=user_id, + draft_id=created.get("id"), + ) + if kb_result["status"] == "success": + kb_message_suffix = " Your knowledge base has also been updated." + else: + kb_message_suffix = " This draft will be added to your knowledge base in the next scheduled sync." + except Exception as kb_err: + logger.warning(f"KB sync after create failed: {kb_err}") + kb_message_suffix = " This draft will be added to your knowledge base in the next scheduled sync." + + return { + "status": "success", + "draft_id": created.get("id"), + "message": f"Successfully created Gmail draft with subject '{final_subject}'.{kb_message_suffix}", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error creating Gmail draft: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while creating the draft. Please try again.", + } + + return create_gmail_draft diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/index.py new file mode 100644 index 000000000..09082d091 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/index.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) + +from .create_draft import create_create_gmail_draft_tool +from .read_email import create_read_gmail_email_tool +from .search_emails import create_search_gmail_tool +from .send_email import create_send_gmail_email_tool +from .trash_email import create_trash_gmail_email_tool +from .update_draft import create_update_gmail_draft_tool + + +def load_tools( + *, dependencies: dict[str, Any] | None = None, **kwargs: Any +) -> ToolsPermissions: + d = {**(dependencies or {}), **kwargs} + common = { + "db_session": d["db_session"], + "search_space_id": d["search_space_id"], + "user_id": d["user_id"], + } + search = create_search_gmail_tool(**common) + read = create_read_gmail_email_tool(**common) + draft = create_create_gmail_draft_tool(**common) + send = create_send_gmail_email_tool(**common) + trash = create_trash_gmail_email_tool(**common) + updraft = create_update_gmail_draft_tool(**common) + return { + "allow": [ + {"name": getattr(search, "name", "") or "", "tool": search}, + {"name": getattr(read, "name", "") or "", "tool": read}, + ], + "ask": [ + {"name": getattr(draft, "name", "") or "", "tool": draft}, + {"name": getattr(send, "name", "") or "", "tool": send}, + {"name": getattr(trash, "name", "") or "", "tool": trash}, + {"name": getattr(updraft, "name", "") or "", "tool": updraft}, + ], + } diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/read_email.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/read_email.py new file mode 100644 index 000000000..39526f25e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/read_email.py @@ -0,0 +1,149 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.db import SearchSourceConnector, SearchSourceConnectorType + +logger = logging.getLogger(__name__) + +_GMAIL_TYPES = [ + SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR, + SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR, +] + + +def create_read_gmail_email_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def read_gmail_email(message_id: str) -> dict[str, Any]: + """Read the full content of a specific Gmail email by its message ID. + + Use after search_gmail to get the complete body of an email. + + Args: + message_id: The Gmail message ID (from search_gmail results). + + Returns: + Dictionary with status and the full email content formatted as markdown. + """ + if db_session is None or search_space_id is None or user_id is None: + return {"status": "error", "message": "Gmail tool not properly configured."} + + try: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type.in_(_GMAIL_TYPES), + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "No Gmail connector found. Please connect Gmail in your workspace settings.", + } + + if ( + connector.connector_type + == SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR + ): + cca_id = connector.config.get("composio_connected_account_id") + if not cca_id: + return { + "status": "error", + "message": "Composio connected account ID not found for this Gmail connector.", + } + + from app.agents.new_chat.tools.gmail.search_emails import ( + _format_gmail_summary, + ) + from app.services.composio_service import ComposioService + + detail, error = await ComposioService().get_gmail_message_detail( + connected_account_id=cca_id, + entity_id=f"surfsense_{user_id}", + message_id=message_id, + ) + if error: + return {"status": "error", "message": error} + if not detail: + return { + "status": "not_found", + "message": f"Email with ID '{message_id}' not found.", + } + + summary = _format_gmail_summary(detail) + content = ( + f"# {summary['subject']}\n\n" + f"**From:** {summary['from']}\n" + f"**To:** {summary['to']}\n" + f"**Date:** {summary['date']}\n\n" + f"## Message Content\n\n" + f"{detail.get('messageText') or detail.get('snippet') or ''}\n\n" + f"## Message Details\n\n" + f"- **Message ID:** {summary['message_id']}\n" + f"- **Thread ID:** {summary['thread_id']}\n" + ) + return { + "status": "success", + "message_id": summary["message_id"] or message_id, + "content": content, + } + + from app.agents.new_chat.tools.gmail.search_emails import ( + _build_credentials, + ) + + creds = _build_credentials(connector) + + from app.connectors.google_gmail_connector import GoogleGmailConnector + + gmail = GoogleGmailConnector( + credentials=creds, + session=db_session, + user_id=user_id, + connector_id=connector.id, + ) + + detail, error = await gmail.get_message_details(message_id) + if error: + if ( + "re-authenticate" in error.lower() + or "authentication failed" in error.lower() + ): + return { + "status": "auth_error", + "message": error, + "connector_type": "gmail", + } + return {"status": "error", "message": error} + + if not detail: + return { + "status": "not_found", + "message": f"Email with ID '{message_id}' not found.", + } + + content = gmail.format_message_to_markdown(detail) + + return {"status": "success", "message_id": message_id, "content": content} + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error("Error reading Gmail email: %s", e, exc_info=True) + return { + "status": "error", + "message": "Failed to read email. Please try again.", + } + + return read_gmail_email diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/search_emails.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/search_emails.py new file mode 100644 index 000000000..a9d7cdedf --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/search_emails.py @@ -0,0 +1,174 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.db import SearchSourceConnector, SearchSourceConnectorType + +logger = logging.getLogger(__name__) + +_GMAIL_TYPES = [ + SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR, + SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR, +] + + +def create_search_gmail_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def search_gmail( + query: str, + max_results: int = 10, + ) -> dict[str, Any]: + """Search emails in the user's Gmail inbox using Gmail search syntax. + + Args: + query: Gmail search query, same syntax as the Gmail search bar. + Examples: "from:alice@example.com", "subject:meeting", + "is:unread", "after:2024/01/01 before:2024/02/01", + "has:attachment", "in:sent". + max_results: Number of emails to return (default 10, max 20). + + Returns: + Dictionary with status and a list of email summaries including + message_id, subject, from, date, snippet. + """ + if db_session is None or search_space_id is None or user_id is None: + return {"status": "error", "message": "Gmail tool not properly configured."} + + max_results = min(max_results, 20) + + try: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type.in_(_GMAIL_TYPES), + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "No Gmail connector found. Please connect Gmail in your workspace settings.", + } + + if ( + connector.connector_type + == SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR + ): + cca_id = connector.config.get("composio_connected_account_id") + if not cca_id: + return { + "status": "error", + "message": "Composio connected account ID not found for this Gmail connector.", + } + + from app.agents.new_chat.tools.gmail.search_emails import ( + _format_gmail_summary, + ) + from app.services.composio_service import ComposioService + + ( + messages, + _next, + _estimate, + error, + ) = await ComposioService().get_gmail_messages( + connected_account_id=cca_id, + entity_id=f"surfsense_{user_id}", + query=query, + max_results=max_results, + ) + if error: + return {"status": "error", "message": error} + + emails = [_format_gmail_summary(m) for m in messages] + if not emails: + return { + "status": "success", + "emails": [], + "total": 0, + "message": "No emails found.", + } + return {"status": "success", "emails": emails, "total": len(emails)} + + from app.agents.new_chat.tools.gmail.search_emails import ( + _build_credentials, + ) + + creds = _build_credentials(connector) + + from app.connectors.google_gmail_connector import GoogleGmailConnector + + gmail = GoogleGmailConnector( + credentials=creds, + session=db_session, + user_id=user_id, + connector_id=connector.id, + ) + + messages_list, error = await gmail.get_messages_list( + max_results=max_results, query=query + ) + if error: + if ( + "re-authenticate" in error.lower() + or "authentication failed" in error.lower() + ): + return { + "status": "auth_error", + "message": error, + "connector_type": "gmail", + } + return {"status": "error", "message": error} + + if not messages_list: + return { + "status": "success", + "emails": [], + "total": 0, + "message": "No emails found.", + } + + emails = [] + for msg in messages_list: + detail, err = await gmail.get_message_details(msg["id"]) + if err: + continue + headers = { + h["name"].lower(): h["value"] + for h in detail.get("payload", {}).get("headers", []) + } + emails.append( + { + "message_id": detail.get("id"), + "thread_id": detail.get("threadId"), + "subject": headers.get("subject", "No Subject"), + "from": headers.get("from", "Unknown"), + "to": headers.get("to", ""), + "date": headers.get("date", ""), + "snippet": detail.get("snippet", ""), + "labels": detail.get("labelIds", []), + } + ) + + return {"status": "success", "emails": emails, "total": len(emails)} + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error("Error searching Gmail: %s", e, exc_info=True) + return { + "status": "error", + "message": "Failed to search Gmail. Please try again.", + } + + return search_gmail diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/send_email.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/send_email.py new file mode 100644 index 000000000..d5de24b62 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/send_email.py @@ -0,0 +1,330 @@ +import asyncio +import base64 +import logging +from datetime import datetime +from email.mime.text import MIMEText +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval +from app.services.gmail import GmailToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_send_gmail_email_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def send_gmail_email( + to: str, + subject: str, + body: str, + cc: str | None = None, + bcc: str | None = None, + ) -> dict[str, Any]: + """Send an email via Gmail. + + Use when the user explicitly asks to send an email. This sends the + email immediately - it cannot be unsent. + + Args: + to: Recipient email address. + subject: Email subject line. + body: Email body content. + cc: Optional CC recipient(s), comma-separated. + bcc: Optional BCC recipient(s), comma-separated. + + Returns: + Dictionary with: + - status: "success", "rejected", or "error" + - message_id: Gmail message ID (if success) + - thread_id: Gmail thread ID (if success) + - message: Result message + + IMPORTANT: + - If status is "rejected", the user explicitly declined the action. + Respond with a brief acknowledgment and do NOT retry or suggest alternatives. + - If status is "insufficient_permissions", the connector lacks the required OAuth scope. + Inform the user they need to re-authenticate and do NOT retry the action. + + Examples: + - "Send an email to alice@example.com about the meeting" + - "Email Bob the project update" + """ + logger.info(f"send_gmail_email called: to='{to}', subject='{subject}'") + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Gmail tool not properly configured. Please contact support.", + } + + try: + metadata_service = GmailToolMetadataService(db_session) + context = await metadata_service.get_creation_context( + search_space_id, user_id + ) + + if "error" in context: + logger.error(f"Failed to fetch creation context: {context['error']}") + return {"status": "error", "message": context["error"]} + + accounts = context.get("accounts", []) + if accounts and all(a.get("auth_expired") for a in accounts): + logger.warning("All Gmail accounts have expired authentication") + return { + "status": "auth_error", + "message": "All connected Gmail accounts need re-authentication. Please re-authenticate in your connector settings.", + "connector_type": "gmail", + } + + logger.info( + f"Requesting approval for sending Gmail email: to='{to}', subject='{subject}'" + ) + result = request_approval( + action_type="gmail_email_send", + tool_name="send_gmail_email", + params={ + "to": to, + "subject": subject, + "body": body, + "cc": cc, + "bcc": bcc, + "connector_id": None, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. The email was not sent. Do not ask again or suggest alternatives.", + } + + final_to = result.params.get("to", to) + final_subject = result.params.get("subject", subject) + final_body = result.params.get("body", body) + final_cc = result.params.get("cc", cc) + final_bcc = result.params.get("bcc", bcc) + final_connector_id = result.params.get("connector_id") + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + _gmail_types = [ + SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR, + SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR, + ] + + if final_connector_id is not None: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type.in_(_gmail_types), + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Gmail connector is invalid or has been disconnected.", + } + actual_connector_id = connector.id + else: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type.in_(_gmail_types), + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "No Gmail connector found. Please connect Gmail in your workspace settings.", + } + actual_connector_id = connector.id + + logger.info( + f"Sending Gmail email: to='{final_to}', subject='{final_subject}', connector={actual_connector_id}" + ) + + if ( + connector.connector_type + == SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR + ): + cca_id = connector.config.get("composio_connected_account_id") + if not cca_id: + return { + "status": "error", + "message": "Composio connected account ID not found for this Gmail connector.", + } + + from app.services.composio_service import ComposioService + + ( + sent_message_id, + sent_thread_id, + error, + ) = await ComposioService().send_gmail_email( + connected_account_id=cca_id, + entity_id=f"surfsense_{user_id}", + to=final_to, + subject=final_subject, + body=final_body, + cc=final_cc, + bcc=final_bcc, + ) + if error: + return {"status": "error", "message": error} + sent = {"id": sent_message_id, "threadId": sent_thread_id} + else: + from google.oauth2.credentials import Credentials + + from app.config import config + from app.utils.oauth_security import TokenEncryption + + config_data = dict(connector.config) + token_encrypted = config_data.get("_token_encrypted", False) + if token_encrypted and config.SECRET_KEY: + token_encryption = TokenEncryption(config.SECRET_KEY) + if config_data.get("token"): + config_data["token"] = token_encryption.decrypt_token( + config_data["token"] + ) + if config_data.get("refresh_token"): + config_data["refresh_token"] = token_encryption.decrypt_token( + config_data["refresh_token"] + ) + if config_data.get("client_secret"): + config_data["client_secret"] = token_encryption.decrypt_token( + config_data["client_secret"] + ) + + exp = config_data.get("expiry", "") + if exp: + exp = exp.replace("Z", "") + + creds = Credentials( + token=config_data.get("token"), + refresh_token=config_data.get("refresh_token"), + token_uri=config_data.get("token_uri"), + client_id=config_data.get("client_id"), + client_secret=config_data.get("client_secret"), + scopes=config_data.get("scopes", []), + expiry=datetime.fromisoformat(exp) if exp else None, + ) + + from googleapiclient.discovery import build + + gmail_service = build("gmail", "v1", credentials=creds) + + message = MIMEText(final_body) + message["to"] = final_to + message["subject"] = final_subject + if final_cc: + message["cc"] = final_cc + if final_bcc: + message["bcc"] = final_bcc + raw = base64.urlsafe_b64encode(message.as_bytes()).decode() + + try: + sent = await asyncio.get_event_loop().run_in_executor( + None, + lambda: ( + gmail_service.users() + .messages() + .send(userId="me", body={"raw": raw}) + .execute() + ), + ) + except Exception as api_err: + from googleapiclient.errors import HttpError + + if isinstance(api_err, HttpError) and api_err.resp.status == 403: + logger.warning( + f"Insufficient permissions for connector {actual_connector_id}: {api_err}" + ) + try: + from sqlalchemy.orm.attributes import flag_modified + + _res = await db_session.execute( + select(SearchSourceConnector).where( + SearchSourceConnector.id == actual_connector_id + ) + ) + _conn = _res.scalar_one_or_none() + if _conn and not _conn.config.get("auth_expired"): + _conn.config = {**_conn.config, "auth_expired": True} + flag_modified(_conn, "config") + await db_session.commit() + except Exception: + logger.warning( + "Failed to persist auth_expired for connector %s", + actual_connector_id, + exc_info=True, + ) + return { + "status": "insufficient_permissions", + "connector_id": actual_connector_id, + "message": "This Gmail account needs additional permissions. Please re-authenticate in connector settings.", + } + raise + + logger.info( + f"Gmail email sent: id={sent.get('id')}, threadId={sent.get('threadId')}" + ) + + kb_message_suffix = "" + try: + from app.services.gmail import GmailKBSyncService + + kb_service = GmailKBSyncService(db_session) + kb_result = await kb_service.sync_after_create( + message_id=sent.get("id", ""), + thread_id=sent.get("threadId", ""), + subject=final_subject, + sender="me", + date_str=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + body_text=final_body, + connector_id=actual_connector_id, + search_space_id=search_space_id, + user_id=user_id, + ) + if kb_result["status"] == "success": + kb_message_suffix = " Your knowledge base has also been updated." + else: + kb_message_suffix = " This email will be added to your knowledge base in the next scheduled sync." + except Exception as kb_err: + logger.warning(f"KB sync after send failed: {kb_err}") + kb_message_suffix = " This email will be added to your knowledge base in the next scheduled sync." + + return { + "status": "success", + "message_id": sent.get("id"), + "thread_id": sent.get("threadId"), + "message": f"Successfully sent email to '{final_to}' with subject '{final_subject}'.{kb_message_suffix}", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error sending Gmail email: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while sending the email. Please try again.", + } + + return send_gmail_email diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/trash_email.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/trash_email.py new file mode 100644 index 000000000..b78f88934 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/trash_email.py @@ -0,0 +1,315 @@ +import asyncio +import logging +from datetime import datetime +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval +from app.services.gmail import GmailToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_trash_gmail_email_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def trash_gmail_email( + email_subject_or_id: str, + delete_from_kb: bool = False, + ) -> dict[str, Any]: + """Move an email or draft to trash in Gmail. + + Use when the user asks to delete, remove, or trash an email or draft. + + Args: + email_subject_or_id: The exact subject line or message ID of the + email to trash (as it appears in the inbox). + delete_from_kb: Whether to also remove the email from the knowledge base. + Default is False. + Set to True to remove from both Gmail and knowledge base. + + Returns: + Dictionary with: + - status: "success", "rejected", "not_found", or "error" + - message_id: Gmail message ID (if success) + - deleted_from_kb: whether the document was removed from the knowledge base + - message: Result message + + IMPORTANT: + - If status is "rejected", the user explicitly declined. Respond with a brief + acknowledgment and do NOT retry or suggest alternatives. + - If status is "not_found", relay the exact message to the user and ask them + to verify the email subject or check if it has been indexed. + - If status is "insufficient_permissions", the connector lacks the required OAuth scope. + Inform the user they need to re-authenticate and do NOT retry this tool. + Examples: + - "Delete the email about 'Meeting Cancelled'" + - "Trash the email from Bob about the project" + """ + logger.info( + f"trash_gmail_email called: email_subject_or_id='{email_subject_or_id}', delete_from_kb={delete_from_kb}" + ) + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Gmail tool not properly configured. Please contact support.", + } + + try: + metadata_service = GmailToolMetadataService(db_session) + context = await metadata_service.get_trash_context( + search_space_id, user_id, email_subject_or_id + ) + + if "error" in context: + error_msg = context["error"] + if "not found" in error_msg.lower(): + logger.warning(f"Email not found: {error_msg}") + return {"status": "not_found", "message": error_msg} + logger.error(f"Failed to fetch trash context: {error_msg}") + return {"status": "error", "message": error_msg} + + account = context.get("account", {}) + if account.get("auth_expired"): + logger.warning( + "Gmail account %s has expired authentication", + account.get("id"), + ) + return { + "status": "auth_error", + "message": "The Gmail account for this email needs re-authentication. Please re-authenticate in your connector settings.", + "connector_type": "gmail", + } + + email = context["email"] + message_id = email["message_id"] + document_id = email.get("document_id") + connector_id_from_context = context["account"]["id"] + + if not message_id: + return { + "status": "error", + "message": "Message ID is missing from the indexed document. Please re-index the email and try again.", + } + + logger.info( + f"Requesting approval for trashing Gmail email: '{email_subject_or_id}' (message_id={message_id}, delete_from_kb={delete_from_kb})" + ) + result = request_approval( + action_type="gmail_email_trash", + tool_name="trash_gmail_email", + params={ + "message_id": message_id, + "connector_id": connector_id_from_context, + "delete_from_kb": delete_from_kb, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. The email was not trashed. Do not ask again or suggest alternatives.", + } + + final_message_id = result.params.get("message_id", message_id) + final_connector_id = result.params.get( + "connector_id", connector_id_from_context + ) + final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb) + + if not final_connector_id: + return { + "status": "error", + "message": "No connector found for this email.", + } + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + _gmail_types = [ + SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR, + SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR, + ] + + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type.in_(_gmail_types), + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Gmail connector is invalid or has been disconnected.", + } + + logger.info( + f"Trashing Gmail email: message_id='{final_message_id}', connector={final_connector_id}" + ) + + if ( + connector.connector_type + == SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR + ): + cca_id = connector.config.get("composio_connected_account_id") + if not cca_id: + return { + "status": "error", + "message": "Composio connected account ID not found for this Gmail connector.", + } + + from app.services.composio_service import ComposioService + + error = await ComposioService().trash_gmail_message( + connected_account_id=cca_id, + entity_id=f"surfsense_{user_id}", + message_id=final_message_id, + ) + if error: + return {"status": "error", "message": error} + else: + from google.oauth2.credentials import Credentials + + from app.config import config + from app.utils.oauth_security import TokenEncryption + + config_data = dict(connector.config) + token_encrypted = config_data.get("_token_encrypted", False) + if token_encrypted and config.SECRET_KEY: + token_encryption = TokenEncryption(config.SECRET_KEY) + if config_data.get("token"): + config_data["token"] = token_encryption.decrypt_token( + config_data["token"] + ) + if config_data.get("refresh_token"): + config_data["refresh_token"] = token_encryption.decrypt_token( + config_data["refresh_token"] + ) + if config_data.get("client_secret"): + config_data["client_secret"] = token_encryption.decrypt_token( + config_data["client_secret"] + ) + + exp = config_data.get("expiry", "") + if exp: + exp = exp.replace("Z", "") + + creds = Credentials( + token=config_data.get("token"), + refresh_token=config_data.get("refresh_token"), + token_uri=config_data.get("token_uri"), + client_id=config_data.get("client_id"), + client_secret=config_data.get("client_secret"), + scopes=config_data.get("scopes", []), + expiry=datetime.fromisoformat(exp) if exp else None, + ) + + from googleapiclient.discovery import build + + gmail_service = build("gmail", "v1", credentials=creds) + + try: + await asyncio.get_event_loop().run_in_executor( + None, + lambda: ( + gmail_service.users() + .messages() + .trash(userId="me", id=final_message_id) + .execute() + ), + ) + except Exception as api_err: + from googleapiclient.errors import HttpError + + if isinstance(api_err, HttpError) and api_err.resp.status == 403: + logger.warning( + f"Insufficient permissions for connector {connector.id}: {api_err}" + ) + try: + from sqlalchemy.orm.attributes import flag_modified + + if not connector.config.get("auth_expired"): + connector.config = { + **connector.config, + "auth_expired": True, + } + flag_modified(connector, "config") + await db_session.commit() + except Exception: + logger.warning( + "Failed to persist auth_expired for connector %s", + connector.id, + exc_info=True, + ) + return { + "status": "insufficient_permissions", + "connector_id": connector.id, + "message": "This Gmail account needs additional permissions. Please re-authenticate in connector settings.", + } + raise + + logger.info(f"Gmail email trashed: message_id={final_message_id}") + + trash_result: dict[str, Any] = { + "status": "success", + "message_id": final_message_id, + "message": f"Successfully moved email '{email.get('subject', email_subject_or_id)}' to trash.", + } + + deleted_from_kb = False + if final_delete_from_kb and document_id: + try: + from app.db import Document + + doc_result = await db_session.execute( + select(Document).filter(Document.id == document_id) + ) + document = doc_result.scalars().first() + if document: + await db_session.delete(document) + await db_session.commit() + deleted_from_kb = True + logger.info( + f"Deleted document {document_id} from knowledge base" + ) + else: + logger.warning(f"Document {document_id} not found in KB") + except Exception as e: + logger.error(f"Failed to delete document from KB: {e}") + await db_session.rollback() + trash_result["warning"] = ( + f"Email trashed, but failed to remove from knowledge base: {e!s}" + ) + + trash_result["deleted_from_kb"] = deleted_from_kb + if deleted_from_kb: + trash_result["message"] = ( + f"{trash_result.get('message', '')} (also removed from knowledge base)" + ) + + return trash_result + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error trashing Gmail email: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while trashing the email. Please try again.", + } + + return trash_gmail_email diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/update_draft.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/update_draft.py new file mode 100644 index 000000000..b6688ac53 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/update_draft.py @@ -0,0 +1,447 @@ +import asyncio +import base64 +import logging +from datetime import datetime +from email.mime.text import MIMEText +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval +from app.services.gmail import GmailToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_update_gmail_draft_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def update_gmail_draft( + draft_subject_or_id: str, + body: str, + to: str | None = None, + subject: str | None = None, + cc: str | None = None, + bcc: str | None = None, + ) -> dict[str, Any]: + """Update an existing Gmail draft. + + Use when the user asks to modify, edit, or add content to an existing + email draft. This replaces the draft content with the new version. + The user will be able to review and edit the content before it is applied. + + If the user simply wants to "edit" a draft without specifying exact changes, + generate the body yourself using your best understanding of the conversation + context. The user will review and can freely edit the content in the approval + card before confirming. + + IMPORTANT: This tool is ONLY for modifying Gmail draft content, NOT for + deleting/trashing drafts (use trash_gmail_email instead), Notion pages, + calendar events, or any other content type. + + Args: + draft_subject_or_id: The exact subject line of the draft to update + (as it appears in Gmail drafts). + body: The full updated body content for the draft. Generate this + yourself based on the user's request and conversation context. + to: Optional new recipient email address (keeps original if omitted). + subject: Optional new subject line (keeps original if omitted). + cc: Optional CC recipient(s), comma-separated. + bcc: Optional BCC recipient(s), comma-separated. + + Returns: + Dictionary with: + - status: "success", "rejected", "not_found", or "error" + - draft_id: Gmail draft ID (if success) + - message: Result message + + IMPORTANT: + - If status is "rejected", the user explicitly declined the action. + Respond with a brief acknowledgment and do NOT retry or suggest alternatives. + - If status is "not_found", relay the exact message to the user and ask them + to verify the draft subject or check if it has been indexed. + - If status is "insufficient_permissions", the connector lacks the required OAuth scope. + Inform the user they need to re-authenticate and do NOT retry the action. + + Examples: + - "Update the Kurseong Plan draft with the new itinerary details" + - "Edit my draft about the project proposal and change the recipient" + - "Let me edit the meeting notes draft" (call with current body content so user can edit in the approval card) + """ + logger.info( + f"update_gmail_draft called: draft_subject_or_id='{draft_subject_or_id}'" + ) + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Gmail tool not properly configured. Please contact support.", + } + + try: + metadata_service = GmailToolMetadataService(db_session) + context = await metadata_service.get_update_context( + search_space_id, user_id, draft_subject_or_id + ) + + if "error" in context: + error_msg = context["error"] + if "not found" in error_msg.lower(): + logger.warning(f"Draft not found: {error_msg}") + return {"status": "not_found", "message": error_msg} + logger.error(f"Failed to fetch update context: {error_msg}") + return {"status": "error", "message": error_msg} + + account = context.get("account", {}) + if account.get("auth_expired"): + logger.warning( + "Gmail account %s has expired authentication", + account.get("id"), + ) + return { + "status": "auth_error", + "message": "The Gmail account for this draft needs re-authentication. Please re-authenticate in your connector settings.", + "connector_type": "gmail", + } + + email = context["email"] + message_id = email["message_id"] + document_id = email.get("document_id") + connector_id_from_context = account["id"] + draft_id_from_context = context.get("draft_id") + + original_subject = email.get("subject", draft_subject_or_id) + final_subject_default = subject if subject else original_subject + final_to_default = to if to else "" + + logger.info( + f"Requesting approval for updating Gmail draft: '{original_subject}' " + f"(message_id={message_id}, draft_id={draft_id_from_context})" + ) + result = request_approval( + action_type="gmail_draft_update", + tool_name="update_gmail_draft", + params={ + "message_id": message_id, + "draft_id": draft_id_from_context, + "to": final_to_default, + "subject": final_subject_default, + "body": body, + "cc": cc, + "bcc": bcc, + "connector_id": connector_id_from_context, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. The draft was not updated. Do not ask again or suggest alternatives.", + } + + final_to = result.params.get("to", final_to_default) + final_subject = result.params.get("subject", final_subject_default) + final_body = result.params.get("body", body) + final_cc = result.params.get("cc", cc) + final_bcc = result.params.get("bcc", bcc) + final_connector_id = result.params.get( + "connector_id", connector_id_from_context + ) + final_draft_id = result.params.get("draft_id", draft_id_from_context) + + if not final_connector_id: + return { + "status": "error", + "message": "No connector found for this draft.", + } + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + _gmail_types = [ + SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR, + SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR, + ] + + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type.in_(_gmail_types), + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Gmail connector is invalid or has been disconnected.", + } + + logger.info( + f"Updating Gmail draft: subject='{final_subject}', connector={final_connector_id}" + ) + + if ( + connector.connector_type + == SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR + ): + cca_id = connector.config.get("composio_connected_account_id") + if not cca_id: + return { + "status": "error", + "message": "Composio connected account ID not found for this Gmail connector.", + } + + if not final_draft_id: + return { + "status": "error", + "message": ( + "Could not find this draft in Gmail. " + "It may have already been sent or deleted." + ), + } + + from app.services.composio_service import ComposioService + + ( + new_draft_id, + new_message_id, + error, + ) = await ComposioService().update_gmail_draft( + connected_account_id=cca_id, + entity_id=f"surfsense_{user_id}", + draft_id=final_draft_id, + to=final_to or None, + subject=final_subject, + body=final_body, + cc=final_cc, + bcc=final_bcc, + ) + if error: + if "not found" in error.lower() or "no longer" in error.lower(): + return { + "status": "error", + "message": "Draft no longer exists in Gmail. It may have been sent or deleted.", + } + return {"status": "error", "message": error} + + updated = { + "id": new_draft_id or final_draft_id, + "message": {"id": new_message_id} if new_message_id else {}, + } + logger.info(f"Gmail draft updated via Composio: id={updated.get('id')}") + else: + from google.oauth2.credentials import Credentials + + from app.config import config + from app.utils.oauth_security import TokenEncryption + + config_data = dict(connector.config) + token_encrypted = config_data.get("_token_encrypted", False) + if token_encrypted and config.SECRET_KEY: + token_encryption = TokenEncryption(config.SECRET_KEY) + if config_data.get("token"): + config_data["token"] = token_encryption.decrypt_token( + config_data["token"] + ) + if config_data.get("refresh_token"): + config_data["refresh_token"] = token_encryption.decrypt_token( + config_data["refresh_token"] + ) + if config_data.get("client_secret"): + config_data["client_secret"] = token_encryption.decrypt_token( + config_data["client_secret"] + ) + + exp = config_data.get("expiry", "") + if exp: + exp = exp.replace("Z", "") + + creds = Credentials( + token=config_data.get("token"), + refresh_token=config_data.get("refresh_token"), + token_uri=config_data.get("token_uri"), + client_id=config_data.get("client_id"), + client_secret=config_data.get("client_secret"), + scopes=config_data.get("scopes", []), + expiry=datetime.fromisoformat(exp) if exp else None, + ) + + from googleapiclient.discovery import build + + gmail_service = build("gmail", "v1", credentials=creds) + + # Resolve draft_id if not already available + if not final_draft_id: + logger.info( + f"draft_id not in metadata, looking up via drafts.list for message_id={message_id}" + ) + final_draft_id = await _find_draft_id_by_message( + gmail_service, message_id + ) + + if not final_draft_id: + return { + "status": "error", + "message": ( + "Could not find this draft in Gmail. " + "It may have already been sent or deleted." + ), + } + + message = MIMEText(final_body) + if final_to: + message["to"] = final_to + message["subject"] = final_subject + if final_cc: + message["cc"] = final_cc + if final_bcc: + message["bcc"] = final_bcc + raw = base64.urlsafe_b64encode(message.as_bytes()).decode() + + try: + updated = await asyncio.get_event_loop().run_in_executor( + None, + lambda: ( + gmail_service.users() + .drafts() + .update( + userId="me", + id=final_draft_id, + body={"message": {"raw": raw}}, + ) + .execute() + ), + ) + except Exception as api_err: + from googleapiclient.errors import HttpError + + if isinstance(api_err, HttpError) and api_err.resp.status == 403: + logger.warning( + f"Insufficient permissions for connector {connector.id}: {api_err}" + ) + try: + from sqlalchemy.orm.attributes import flag_modified + + if not connector.config.get("auth_expired"): + connector.config = { + **connector.config, + "auth_expired": True, + } + flag_modified(connector, "config") + await db_session.commit() + except Exception: + logger.warning( + "Failed to persist auth_expired for connector %s", + connector.id, + exc_info=True, + ) + return { + "status": "insufficient_permissions", + "connector_id": connector.id, + "message": "This Gmail account needs additional permissions. Please re-authenticate in connector settings.", + } + if isinstance(api_err, HttpError) and api_err.resp.status == 404: + return { + "status": "error", + "message": "Draft no longer exists in Gmail. It may have been sent or deleted.", + } + raise + + logger.info( + f"Gmail draft updated via Google API: id={updated.get('id')}" + ) + + kb_message_suffix = "" + if document_id: + try: + from sqlalchemy.future import select as sa_select + from sqlalchemy.orm.attributes import flag_modified + + from app.db import Document + + doc_result = await db_session.execute( + sa_select(Document).filter(Document.id == document_id) + ) + document = doc_result.scalars().first() + if document: + document.source_markdown = final_body + document.title = final_subject + meta = dict(document.document_metadata or {}) + meta["subject"] = final_subject + meta["draft_id"] = updated.get("id", final_draft_id) + updated_msg = updated.get("message", {}) + if updated_msg.get("id"): + meta["message_id"] = updated_msg["id"] + document.document_metadata = meta + flag_modified(document, "document_metadata") + await db_session.commit() + kb_message_suffix = ( + " Your knowledge base has also been updated." + ) + logger.info( + f"KB document {document_id} updated for draft {final_draft_id}" + ) + else: + kb_message_suffix = " This draft will be fully updated in your knowledge base in the next scheduled sync." + except Exception as kb_err: + logger.warning(f"KB update after draft edit failed: {kb_err}") + await db_session.rollback() + kb_message_suffix = " This draft will be fully updated in your knowledge base in the next scheduled sync." + + return { + "status": "success", + "draft_id": updated.get("id"), + "message": f"Successfully updated Gmail draft with subject '{final_subject}'.{kb_message_suffix}", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error updating Gmail draft: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while updating the draft. Please try again.", + } + + return update_gmail_draft + + +async def _find_draft_id_by_message(gmail_service: Any, message_id: str) -> str | None: + """Look up a draft's ID by its message ID via the Gmail API.""" + try: + page_token = None + while True: + kwargs: dict[str, Any] = {"userId": "me", "maxResults": 100} + if page_token: + kwargs["pageToken"] = page_token + + response = await asyncio.get_event_loop().run_in_executor( + None, + lambda kwargs=kwargs: ( + gmail_service.users().drafts().list(**kwargs).execute() + ), + ) + + for draft in response.get("drafts", []): + if draft.get("message", {}).get("id") == message_id: + return draft["id"] + + page_token = response.get("nextPageToken") + if not page_token: + break + + return None + except Exception as e: + logger.warning(f"Failed to look up draft by message_id: {e}") + return None diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/agent.py new file mode 100644 index 000000000..4b4269e2b --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/agent.py @@ -0,0 +1,55 @@ +"""`google_drive` route: ``SubAgent`` spec for deepagents.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from deepagents import SubAgent +from langchain_core.language_models import BaseChatModel + +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, + middleware_gated_interrupt_on, +) +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( + pack_subagent, +) + +from .tools.index import load_tools + +NAME = "google_drive" + + +def build_subagent( + *, + dependencies: dict[str, Any], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, + extra_tools_bucket: ToolsPermissions | None = None, +) -> SubAgent: + buckets = load_tools(dependencies=dependencies) + merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket) + tools = [ + row["tool"] + for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) + if row.get("tool") is not None + ] + interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket) + description = read_md_file(__package__, "description").strip() + if not description: + description = "Handles google drive tasks for this workspace." + system_prompt = read_md_file(__package__, "system_prompt").strip() + return pack_subagent( + name=NAME, + description=description, + system_prompt=system_prompt, + tools=tools, + interrupt_on=interrupt_on, + model=model, + extra_middleware=extra_middleware, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/description.md new file mode 100644 index 000000000..3f54ef8f7 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/description.md @@ -0,0 +1 @@ +Use for Google Drive document/file tasks: locate files, inspect content, and manage Drive files or folders. diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/system_prompt.md new file mode 100644 index 000000000..09dc0caa2 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/system_prompt.md @@ -0,0 +1,54 @@ +You are the Google Drive operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute Google Drive file operations accurately in the connected account. + + + +- `create_google_drive_file` +- `delete_google_drive_file` + + + +- Use only tools in ``. +- Ensure target file identity/path is explicit before mutate actions. +- If target is ambiguous, return `status=blocked` with candidate files. +- Never invent file IDs/names or mutation outcomes. + + + +- Do not perform non-Google-Drive tasks. + + + +- Never claim file mutation success without tool confirmation. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On target ambiguity, return `status=blocked` with candidate files. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "file_id": string | null, + "file_name": string | null, + "operation": "create" | "delete" | null, + "matched_candidates": [ + { "file_id": string, "file_name": string | null } + ] | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/__init__.py new file mode 100644 index 000000000..9c63bceb1 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/__init__.py @@ -0,0 +1,11 @@ +from app.agents.new_chat.tools.google_drive.create_file import ( + create_create_google_drive_file_tool, +) +from app.agents.new_chat.tools.google_drive.trash_file import ( + create_delete_google_drive_file_tool, +) + +__all__ = [ + "create_create_google_drive_file_tool", + "create_delete_google_drive_file_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/create_file.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/create_file.py new file mode 100644 index 000000000..9e9a30429 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/create_file.py @@ -0,0 +1,320 @@ +import logging +from typing import Any, Literal + +from googleapiclient.errors import HttpError +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.google_drive.client import GoogleDriveClient +from app.connectors.google_drive.file_types import GOOGLE_DOC, GOOGLE_SHEET +from app.services.google_drive import GoogleDriveToolMetadataService + +logger = logging.getLogger(__name__) + +_MIME_MAP: dict[str, str] = { + "google_doc": GOOGLE_DOC, + "google_sheet": GOOGLE_SHEET, +} + + +def create_create_google_drive_file_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def create_google_drive_file( + name: str, + file_type: Literal["google_doc", "google_sheet"], + content: str | None = None, + ) -> dict[str, Any]: + """Create a new Google Doc or Google Sheet in Google Drive. + + Use this tool when the user explicitly asks to create a new document + or spreadsheet in Google Drive. The user MUST specify a topic before + you call this tool. If the request does not contain a topic (e.g. + "create a drive doc" or "make a Google Sheet"), ask what the file + should be about. Never call this tool without a clear topic from the user. + + Args: + name: The file name (without extension). + file_type: Either "google_doc" or "google_sheet". + content: Optional initial content. Generate from the user's topic. + For google_doc, provide markdown text. For google_sheet, provide CSV-formatted text. + + Returns: + Dictionary with: + - status: "success", "rejected", or "error" + - file_id: Google Drive file ID (if success) + - name: File name (if success) + - web_view_link: URL to open the file (if success) + - message: Result message + + IMPORTANT: + - If status is "rejected", the user explicitly declined the action. + Respond with a brief acknowledgment and do NOT retry or suggest alternatives. + - If status is "insufficient_permissions", the connector lacks the required OAuth scope. + Inform the user they need to re-authenticate and do NOT retry the action. + + Examples: + - "Create a Google Doc with today's meeting notes" + - "Create a spreadsheet for the 2026 budget" + """ + logger.info( + f"create_google_drive_file called: name='{name}', type='{file_type}'" + ) + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Google Drive tool not properly configured. Please contact support.", + } + + if file_type not in _MIME_MAP: + return { + "status": "error", + "message": f"Unsupported file type '{file_type}'. Use 'google_doc' or 'google_sheet'.", + } + + try: + metadata_service = GoogleDriveToolMetadataService(db_session) + context = await metadata_service.get_creation_context( + search_space_id, user_id + ) + + if "error" in context: + logger.error(f"Failed to fetch creation context: {context['error']}") + return {"status": "error", "message": context["error"]} + + accounts = context.get("accounts", []) + if accounts and all(a.get("auth_expired") for a in accounts): + logger.warning("All Google Drive accounts have expired authentication") + return { + "status": "auth_error", + "message": "All connected Google Drive accounts need re-authentication. Please re-authenticate in your connector settings.", + "connector_type": "google_drive", + } + + logger.info( + f"Requesting approval for creating Google Drive file: name='{name}', type='{file_type}'" + ) + result = request_approval( + action_type="google_drive_file_creation", + tool_name="create_google_drive_file", + params={ + "name": name, + "file_type": file_type, + "content": content, + "connector_id": None, + "parent_folder_id": None, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. The file was not created. Do not ask again or suggest alternatives.", + } + + final_name = result.params.get("name", name) + final_file_type = result.params.get("file_type", file_type) + final_content = result.params.get("content", content) + final_connector_id = result.params.get("connector_id") + final_parent_folder_id = result.params.get("parent_folder_id") + + if not final_name or not final_name.strip(): + return {"status": "error", "message": "File name cannot be empty."} + + mime_type = _MIME_MAP.get(final_file_type) + if not mime_type: + return { + "status": "error", + "message": f"Unsupported file type '{final_file_type}'.", + } + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + _drive_types = [ + SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR, + SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR, + ] + + if final_connector_id is not None: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type.in_(_drive_types), + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Google Drive connector is invalid or has been disconnected.", + } + actual_connector_id = connector.id + else: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type.in_(_drive_types), + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "No Google Drive connector found. Please connect Google Drive in your workspace settings.", + } + actual_connector_id = connector.id + + logger.info( + f"Creating Google Drive file: name='{final_name}', type='{final_file_type}', connector={actual_connector_id}" + ) + + async def _flag_auth_expired() -> None: + try: + from sqlalchemy.orm.attributes import flag_modified + + _res = await db_session.execute( + select(SearchSourceConnector).where( + SearchSourceConnector.id == actual_connector_id + ) + ) + _conn = _res.scalar_one_or_none() + if _conn and not _conn.config.get("auth_expired"): + _conn.config = {**_conn.config, "auth_expired": True} + flag_modified(_conn, "config") + await db_session.commit() + except Exception: + logger.warning( + "Failed to persist auth_expired for connector %s", + actual_connector_id, + exc_info=True, + ) + + if ( + connector.connector_type + == SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR + ): + cca_id = connector.config.get("composio_connected_account_id") + if not cca_id: + return { + "status": "error", + "message": "Composio connected account ID not found for this Google Drive connector.", + } + + from app.services.composio_service import ComposioService + + created, error = await ComposioService().create_drive_file_from_text( + connected_account_id=cca_id, + entity_id=f"surfsense_{user_id}", + name=final_name, + mime_type=mime_type, + content=final_content, + parent_id=final_parent_folder_id, + ) + + if error or not created: + err_lower = (error or "").lower() + if ( + "insufficient" in err_lower + or "permission" in err_lower + or "403" in err_lower + ): + logger.warning( + f"Insufficient permissions for Composio Drive connector {actual_connector_id}: {error}" + ) + await _flag_auth_expired() + return { + "status": "insufficient_permissions", + "connector_id": actual_connector_id, + "message": "This Google Drive account needs additional permissions. Please re-authenticate in connector settings.", + } + logger.error( + f"Composio Drive create_file failed for connector {actual_connector_id}: {error}" + ) + return { + "status": "error", + "message": "Something went wrong while creating the file. Please try again.", + } + else: + client = GoogleDriveClient( + session=db_session, + connector_id=actual_connector_id, + ) + try: + created = await client.create_file( + name=final_name, + mime_type=mime_type, + parent_folder_id=final_parent_folder_id, + content=final_content, + ) + except HttpError as http_err: + if http_err.resp.status == 403: + logger.warning( + f"Insufficient permissions for connector {actual_connector_id}: {http_err}" + ) + await _flag_auth_expired() + return { + "status": "insufficient_permissions", + "connector_id": actual_connector_id, + "message": "This Google Drive account needs additional permissions. Please re-authenticate in connector settings.", + } + raise + + logger.info( + f"Google Drive file created: id={created.get('id')}, name={created.get('name')}" + ) + + kb_message_suffix = "" + try: + from app.services.google_drive import GoogleDriveKBSyncService + + kb_service = GoogleDriveKBSyncService(db_session) + kb_result = await kb_service.sync_after_create( + file_id=created.get("id"), + file_name=created.get("name", final_name), + mime_type=mime_type, + web_view_link=created.get("webViewLink"), + content=final_content, + connector_id=actual_connector_id, + search_space_id=search_space_id, + user_id=user_id, + ) + if kb_result["status"] == "success": + kb_message_suffix = " Your knowledge base has also been updated." + else: + kb_message_suffix = " This file will be added to your knowledge base in the next scheduled sync." + except Exception as kb_err: + logger.warning(f"KB sync after create failed: {kb_err}") + kb_message_suffix = " This file will be added to your knowledge base in the next scheduled sync." + + return { + "status": "success", + "file_id": created.get("id"), + "name": created.get("name"), + "web_view_link": created.get("webViewLink"), + "message": f"Successfully created '{created.get('name')}' in Google Drive.{kb_message_suffix}", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error creating Google Drive file: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while creating the file. Please try again.", + } + + return create_google_drive_file diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/index.py new file mode 100644 index 000000000..7dbee87a0 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/index.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) + +from .create_file import create_create_google_drive_file_tool +from .trash_file import create_delete_google_drive_file_tool + + +def load_tools( + *, dependencies: dict[str, Any] | None = None, **kwargs: Any +) -> ToolsPermissions: + d = {**(dependencies or {}), **kwargs} + common = { + "db_session": d["db_session"], + "search_space_id": d["search_space_id"], + "user_id": d["user_id"], + } + create = create_create_google_drive_file_tool(**common) + delete = create_delete_google_drive_file_tool(**common) + return { + "allow": [], + "ask": [ + {"name": getattr(create, "name", "") or "", "tool": create}, + {"name": getattr(delete, "name", "") or "", "tool": delete}, + ], + } diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/trash_file.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/trash_file.py new file mode 100644 index 000000000..f7531cf3d --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/trash_file.py @@ -0,0 +1,295 @@ +import logging +from typing import Any + +from googleapiclient.errors import HttpError +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.google_drive.client import GoogleDriveClient +from app.services.google_drive import GoogleDriveToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_delete_google_drive_file_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def delete_google_drive_file( + file_name: str, + delete_from_kb: bool = False, + ) -> dict[str, Any]: + """Move a Google Drive file to trash. + + Use this tool when the user explicitly asks to delete, remove, or trash + a file in Google Drive. + + Args: + file_name: The exact name of the file to trash (as it appears in Drive). + delete_from_kb: Whether to also remove the file from the knowledge base. + Default is False. + Set to True to remove from both Google Drive and knowledge base. + + Returns: + Dictionary with: + - status: "success", "rejected", "not_found", or "error" + - file_id: Google Drive file ID (if success) + - deleted_from_kb: whether the document was removed from the knowledge base + - message: Result message + + IMPORTANT: + - If status is "rejected", the user explicitly declined. Respond with a brief + acknowledgment and do NOT retry or suggest alternatives. + - If status is "not_found", relay the exact message to the user and ask them + to verify the file name or check if it has been indexed. + - If status is "insufficient_permissions", the connector lacks the required OAuth scope. + Inform the user they need to re-authenticate and do NOT retry this tool. + Examples: + - "Delete the 'Meeting Notes' file from Google Drive" + - "Trash the 'Old Budget' spreadsheet" + """ + logger.info( + f"delete_google_drive_file called: file_name='{file_name}', delete_from_kb={delete_from_kb}" + ) + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Google Drive tool not properly configured. Please contact support.", + } + + try: + metadata_service = GoogleDriveToolMetadataService(db_session) + context = await metadata_service.get_trash_context( + search_space_id, user_id, file_name + ) + + if "error" in context: + error_msg = context["error"] + if "not found" in error_msg.lower(): + logger.warning(f"File not found: {error_msg}") + return {"status": "not_found", "message": error_msg} + logger.error(f"Failed to fetch trash context: {error_msg}") + return {"status": "error", "message": error_msg} + + account = context.get("account", {}) + if account.get("auth_expired"): + logger.warning( + "Google Drive account %s has expired authentication", + account.get("id"), + ) + return { + "status": "auth_error", + "message": "The Google Drive account for this file needs re-authentication. Please re-authenticate in your connector settings.", + "connector_type": "google_drive", + } + + file = context["file"] + file_id = file["file_id"] + document_id = file.get("document_id") + connector_id_from_context = context["account"]["id"] + + if not file_id: + return { + "status": "error", + "message": "File ID is missing from the indexed document. Please re-index the file and try again.", + } + + logger.info( + f"Requesting approval for deleting Google Drive file: '{file_name}' (file_id={file_id}, delete_from_kb={delete_from_kb})" + ) + result = request_approval( + action_type="google_drive_file_trash", + tool_name="delete_google_drive_file", + params={ + "file_id": file_id, + "connector_id": connector_id_from_context, + "delete_from_kb": delete_from_kb, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. The file was not trashed. Do not ask again or suggest alternatives.", + } + + final_file_id = result.params.get("file_id", file_id) + final_connector_id = result.params.get( + "connector_id", connector_id_from_context + ) + final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb) + + if not final_connector_id: + return { + "status": "error", + "message": "No connector found for this file.", + } + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + _drive_types = [ + SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR, + SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR, + ] + + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type.in_(_drive_types), + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Google Drive connector is invalid or has been disconnected.", + } + + logger.info( + f"Deleting Google Drive file: file_id='{final_file_id}', connector={final_connector_id}" + ) + + async def _flag_auth_expired() -> None: + try: + from sqlalchemy.orm.attributes import flag_modified + + if not connector.config.get("auth_expired"): + connector.config = { + **connector.config, + "auth_expired": True, + } + flag_modified(connector, "config") + await db_session.commit() + except Exception: + logger.warning( + "Failed to persist auth_expired for connector %s", + connector.id, + exc_info=True, + ) + + if ( + connector.connector_type + == SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR + ): + cca_id = connector.config.get("composio_connected_account_id") + if not cca_id: + return { + "status": "error", + "message": "Composio connected account ID not found for this Google Drive connector.", + } + + from app.services.composio_service import ComposioService + + error = await ComposioService().trash_drive_file( + connected_account_id=cca_id, + entity_id=f"surfsense_{user_id}", + file_id=final_file_id, + ) + if error: + err_lower = error.lower() + if ( + "insufficient" in err_lower + or "permission" in err_lower + or "403" in err_lower + ): + logger.warning( + f"Insufficient permissions for Composio Drive connector {connector.id}: {error}" + ) + await _flag_auth_expired() + return { + "status": "insufficient_permissions", + "connector_id": connector.id, + "message": "This Google Drive account needs additional permissions. Please re-authenticate in connector settings.", + } + logger.error( + f"Composio Drive trash_file failed for connector {connector.id}: {error}" + ) + return { + "status": "error", + "message": "Something went wrong while trashing the file. Please try again.", + } + else: + client = GoogleDriveClient( + session=db_session, + connector_id=connector.id, + ) + try: + await client.trash_file(file_id=final_file_id) + except HttpError as http_err: + if http_err.resp.status == 403: + logger.warning( + f"Insufficient permissions for connector {connector.id}: {http_err}" + ) + await _flag_auth_expired() + return { + "status": "insufficient_permissions", + "connector_id": connector.id, + "message": "This Google Drive account needs additional permissions. Please re-authenticate in connector settings.", + } + raise + + logger.info( + f"Google Drive file deleted (moved to trash): file_id={final_file_id}" + ) + + trash_result: dict[str, Any] = { + "status": "success", + "file_id": final_file_id, + "message": f"Successfully moved '{file['name']}' to trash.", + } + + deleted_from_kb = False + if final_delete_from_kb and document_id: + try: + from app.db import Document + + doc_result = await db_session.execute( + select(Document).filter(Document.id == document_id) + ) + document = doc_result.scalars().first() + if document: + await db_session.delete(document) + await db_session.commit() + deleted_from_kb = True + logger.info( + f"Deleted document {document_id} from knowledge base" + ) + else: + logger.warning(f"Document {document_id} not found in KB") + except Exception as e: + logger.error(f"Failed to delete document from KB: {e}") + await db_session.rollback() + trash_result["warning"] = ( + f"File moved to trash, but failed to remove from knowledge base: {e!s}" + ) + + trash_result["deleted_from_kb"] = deleted_from_kb + if deleted_from_kb: + trash_result["message"] = ( + f"{trash_result.get('message', '')} (also removed from knowledge base)" + ) + + return trash_result + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error deleting Google Drive file: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while trashing the file. Please try again.", + } + + return delete_google_drive_file diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/agent.py new file mode 100644 index 000000000..b381c6bcf --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/agent.py @@ -0,0 +1,55 @@ +"""`jira` route: ``SubAgent`` spec for deepagents.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from deepagents import SubAgent +from langchain_core.language_models import BaseChatModel + +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, + middleware_gated_interrupt_on, +) +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( + pack_subagent, +) + +from .tools.index import load_tools + +NAME = "jira" + + +def build_subagent( + *, + dependencies: dict[str, Any], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, + extra_tools_bucket: ToolsPermissions | None = None, +) -> SubAgent: + buckets = load_tools(dependencies=dependencies) + merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket) + tools = [ + row["tool"] + for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) + if row.get("tool") is not None + ] + interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket) + description = read_md_file(__package__, "description").strip() + if not description: + description = "Handles jira tasks for this workspace." + system_prompt = read_md_file(__package__, "system_prompt").strip() + return pack_subagent( + name=NAME, + description=description, + system_prompt=system_prompt, + tools=tools, + interrupt_on=interrupt_on, + model=model, + extra_middleware=extra_middleware, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/description.md new file mode 100644 index 000000000..2cd7e082a --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/description.md @@ -0,0 +1 @@ +Use for Jira issue/project workflows: search issues, inspect fields, update tickets, and move work through workflow states. diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/system_prompt.md new file mode 100644 index 000000000..4f4ae8a66 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/system_prompt.md @@ -0,0 +1,46 @@ +You are the Jira MCP operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute Jira MCP operations accurately, including discovery and issue mutation flows. + + + +- Runtime-provided Jira MCP tools for site/project discovery, issue search, create, and update. + + + +- Respect discovery dependencies (site/project/issue-type) before mutate calls. +- If required fields are missing or targets are ambiguous, return `status=blocked` with `missing_fields`. +- Do not guess keys/IDs. +- Never claim create/update success without tool confirmation. + + + +- Do not execute non-Jira tasks. + + + +- Never perform destructive/mutating actions without explicit target resolution. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On unresolved ambiguity, return `status=blocked` with candidates or missing fields. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { "items": object | null }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/__init__.py new file mode 100644 index 000000000..768738118 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/__init__.py @@ -0,0 +1,11 @@ +"""Jira tools for creating, updating, and deleting issues.""" + +from .create_issue import create_create_jira_issue_tool +from .delete_issue import create_delete_jira_issue_tool +from .update_issue import create_update_jira_issue_tool + +__all__ = [ + "create_create_jira_issue_tool", + "create_delete_jira_issue_tool", + "create_update_jira_issue_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/create_issue.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/create_issue.py new file mode 100644 index 000000000..8b40dde65 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/create_issue.py @@ -0,0 +1,216 @@ +import asyncio +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.attributes import flag_modified + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.jira_history import JiraHistoryConnector +from app.services.jira import JiraToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_create_jira_issue_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + @tool + async def create_jira_issue( + project_key: str, + summary: str, + issue_type: str = "Task", + description: str | None = None, + priority: str | None = None, + ) -> dict[str, Any]: + """Create a new issue in Jira. + + Use this tool when the user explicitly asks to create a new Jira issue/ticket. + + Args: + project_key: The Jira project key (e.g. "PROJ", "ENG"). + summary: Short, descriptive issue title. + issue_type: Issue type (default "Task"). Others: "Bug", "Story", "Epic". + description: Optional description body for the issue. + priority: Optional priority name (e.g. "High", "Medium", "Low"). + + Returns: + Dictionary with status, issue_key, and message. + + IMPORTANT: + - If status is "rejected", the user declined. Do NOT retry. + - If status is "insufficient_permissions", inform user to re-authenticate. + """ + logger.info( + f"create_jira_issue called: project_key='{project_key}', summary='{summary}'" + ) + + if db_session is None or search_space_id is None or user_id is None: + return {"status": "error", "message": "Jira tool not properly configured."} + + try: + metadata_service = JiraToolMetadataService(db_session) + context = await metadata_service.get_creation_context( + search_space_id, user_id + ) + + if "error" in context: + return {"status": "error", "message": context["error"]} + + accounts = context.get("accounts", []) + if accounts and all(a.get("auth_expired") for a in accounts): + return { + "status": "auth_error", + "message": "All connected Jira accounts need re-authentication.", + "connector_type": "jira", + } + + result = request_approval( + action_type="jira_issue_creation", + tool_name="create_jira_issue", + params={ + "project_key": project_key, + "summary": summary, + "issue_type": issue_type, + "description": description, + "priority": priority, + "connector_id": connector_id, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_project_key = result.params.get("project_key", project_key) + final_summary = result.params.get("summary", summary) + final_issue_type = result.params.get("issue_type", issue_type) + final_description = result.params.get("description", description) + final_priority = result.params.get("priority", priority) + final_connector_id = result.params.get("connector_id", connector_id) + + if not final_summary or not final_summary.strip(): + return {"status": "error", "message": "Issue summary cannot be empty."} + if not final_project_key: + return {"status": "error", "message": "A project must be selected."} + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + actual_connector_id = final_connector_id + if actual_connector_id is None: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.JIRA_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + return {"status": "error", "message": "No Jira connector found."} + actual_connector_id = connector.id + else: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == actual_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.JIRA_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Jira connector is invalid.", + } + + try: + jira_history = JiraHistoryConnector( + session=db_session, connector_id=actual_connector_id + ) + jira_client = await jira_history._get_jira_client() + api_result = await asyncio.to_thread( + jira_client.create_issue, + project_key=final_project_key, + summary=final_summary, + issue_type=final_issue_type, + description=final_description, + priority=final_priority, + ) + except Exception as api_err: + if "status code 403" in str(api_err).lower(): + try: + _conn = connector + _conn.config = {**_conn.config, "auth_expired": True} + flag_modified(_conn, "config") + await db_session.commit() + except Exception: + pass + return { + "status": "insufficient_permissions", + "connector_id": actual_connector_id, + "message": "This Jira account needs additional permissions. Please re-authenticate in connector settings.", + } + raise + + issue_key = api_result.get("key", "") + issue_url = ( + f"{jira_history._base_url}/browse/{issue_key}" + if jira_history._base_url and issue_key + else "" + ) + + kb_message_suffix = "" + try: + from app.services.jira import JiraKBSyncService + + kb_service = JiraKBSyncService(db_session) + kb_result = await kb_service.sync_after_create( + issue_id=issue_key, + issue_identifier=issue_key, + issue_title=final_summary, + description=final_description, + state="To Do", + connector_id=actual_connector_id, + search_space_id=search_space_id, + user_id=user_id, + ) + if kb_result["status"] == "success": + kb_message_suffix = " Your knowledge base has also been updated." + else: + kb_message_suffix = " This issue will be added to your knowledge base in the next scheduled sync." + except Exception as kb_err: + logger.warning(f"KB sync after create failed: {kb_err}") + kb_message_suffix = " This issue will be added to your knowledge base in the next scheduled sync." + + return { + "status": "success", + "issue_key": issue_key, + "issue_url": issue_url, + "message": f"Jira issue {issue_key} created successfully.{kb_message_suffix}", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error(f"Error creating Jira issue: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while creating the issue.", + } + + return create_jira_issue diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/delete_issue.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/delete_issue.py new file mode 100644 index 000000000..6466c80ea --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/delete_issue.py @@ -0,0 +1,183 @@ +import asyncio +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.attributes import flag_modified + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.jira_history import JiraHistoryConnector +from app.services.jira import JiraToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_delete_jira_issue_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + @tool + async def delete_jira_issue( + issue_title_or_key: str, + delete_from_kb: bool = False, + ) -> dict[str, Any]: + """Delete a Jira issue. + + Use this tool when the user asks to delete or remove a Jira issue. + + Args: + issue_title_or_key: The issue key (e.g. "PROJ-42") or title. + delete_from_kb: Whether to also remove from the knowledge base. + + Returns: + Dictionary with status, message, and deleted_from_kb. + + IMPORTANT: + - If status is "rejected", do NOT retry. + - If status is "not_found", relay the message to the user. + - If status is "insufficient_permissions", inform user to re-authenticate. + """ + logger.info( + f"delete_jira_issue called: issue_title_or_key='{issue_title_or_key}'" + ) + + if db_session is None or search_space_id is None or user_id is None: + return {"status": "error", "message": "Jira tool not properly configured."} + + try: + metadata_service = JiraToolMetadataService(db_session) + context = await metadata_service.get_deletion_context( + search_space_id, user_id, issue_title_or_key + ) + + if "error" in context: + error_msg = context["error"] + if context.get("auth_expired"): + return { + "status": "auth_error", + "message": error_msg, + "connector_id": context.get("connector_id"), + "connector_type": "jira", + } + if "not found" in error_msg.lower(): + return {"status": "not_found", "message": error_msg} + return {"status": "error", "message": error_msg} + + issue_data = context["issue"] + issue_key = issue_data["issue_id"] + document_id = issue_data["document_id"] + connector_id_from_context = context.get("account", {}).get("id") + + result = request_approval( + action_type="jira_issue_deletion", + tool_name="delete_jira_issue", + params={ + "issue_key": issue_key, + "connector_id": connector_id_from_context, + "delete_from_kb": delete_from_kb, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_issue_key = result.params.get("issue_key", issue_key) + final_connector_id = result.params.get( + "connector_id", connector_id_from_context + ) + final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb) + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + if not final_connector_id: + return { + "status": "error", + "message": "No connector found for this issue.", + } + + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.JIRA_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Jira connector is invalid.", + } + + try: + jira_history = JiraHistoryConnector( + session=db_session, connector_id=final_connector_id + ) + jira_client = await jira_history._get_jira_client() + await asyncio.to_thread(jira_client.delete_issue, final_issue_key) + except Exception as api_err: + if "status code 403" in str(api_err).lower(): + try: + connector.config = {**connector.config, "auth_expired": True} + flag_modified(connector, "config") + await db_session.commit() + except Exception: + pass + return { + "status": "insufficient_permissions", + "connector_id": final_connector_id, + "message": "This Jira account needs additional permissions. Please re-authenticate in connector settings.", + } + raise + + deleted_from_kb = False + if final_delete_from_kb and document_id: + try: + from app.db import Document + + doc_result = await db_session.execute( + select(Document).filter(Document.id == document_id) + ) + document = doc_result.scalars().first() + if document: + await db_session.delete(document) + await db_session.commit() + deleted_from_kb = True + except Exception as e: + logger.error(f"Failed to delete document from KB: {e}") + await db_session.rollback() + + message = f"Jira issue {final_issue_key} deleted successfully." + if deleted_from_kb: + message += " Also removed from the knowledge base." + + return { + "status": "success", + "issue_key": final_issue_key, + "deleted_from_kb": deleted_from_kb, + "message": message, + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error(f"Error deleting Jira issue: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while deleting the issue.", + } + + return delete_jira_issue diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/index.py new file mode 100644 index 000000000..342f120be --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/index.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) + +from .create_issue import create_create_jira_issue_tool +from .delete_issue import create_delete_jira_issue_tool +from .update_issue import create_update_jira_issue_tool + + +def load_tools( + *, dependencies: dict[str, Any] | None = None, **kwargs: Any +) -> ToolsPermissions: + d = {**(dependencies or {}), **kwargs} + common = { + "db_session": d["db_session"], + "search_space_id": d["search_space_id"], + "user_id": d["user_id"], + "connector_id": d.get("connector_id"), + } + create = create_create_jira_issue_tool(**common) + update = create_update_jira_issue_tool(**common) + delete = create_delete_jira_issue_tool(**common) + return { + "allow": [], + "ask": [ + {"name": getattr(create, "name", "") or "", "tool": create}, + {"name": getattr(update, "name", "") or "", "tool": update}, + {"name": getattr(delete, "name", "") or "", "tool": delete}, + ], + } diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/update_issue.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/update_issue.py new file mode 100644 index 000000000..f6e586a2e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/update_issue.py @@ -0,0 +1,226 @@ +import asyncio +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.attributes import flag_modified + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.jira_history import JiraHistoryConnector +from app.services.jira import JiraToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_update_jira_issue_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + @tool + async def update_jira_issue( + issue_title_or_key: str, + new_summary: str | None = None, + new_description: str | None = None, + new_priority: str | None = None, + ) -> dict[str, Any]: + """Update an existing Jira issue. + + Use this tool when the user asks to modify, edit, or update a Jira issue. + + Args: + issue_title_or_key: The issue key (e.g. "PROJ-42") or title to identify the issue. + new_summary: Optional new title/summary for the issue. + new_description: Optional new description. + new_priority: Optional new priority name. + + Returns: + Dictionary with status and message. + + IMPORTANT: + - If status is "rejected", do NOT retry. + - If status is "not_found", relay the message and ask user to verify. + - If status is "insufficient_permissions", inform user to re-authenticate. + """ + logger.info( + f"update_jira_issue called: issue_title_or_key='{issue_title_or_key}'" + ) + + if db_session is None or search_space_id is None or user_id is None: + return {"status": "error", "message": "Jira tool not properly configured."} + + try: + metadata_service = JiraToolMetadataService(db_session) + context = await metadata_service.get_update_context( + search_space_id, user_id, issue_title_or_key + ) + + if "error" in context: + error_msg = context["error"] + if context.get("auth_expired"): + return { + "status": "auth_error", + "message": error_msg, + "connector_id": context.get("connector_id"), + "connector_type": "jira", + } + if "not found" in error_msg.lower(): + return {"status": "not_found", "message": error_msg} + return {"status": "error", "message": error_msg} + + issue_data = context["issue"] + issue_key = issue_data["issue_id"] + document_id = issue_data.get("document_id") + connector_id_from_context = context.get("account", {}).get("id") + + result = request_approval( + action_type="jira_issue_update", + tool_name="update_jira_issue", + params={ + "issue_key": issue_key, + "document_id": document_id, + "new_summary": new_summary, + "new_description": new_description, + "new_priority": new_priority, + "connector_id": connector_id_from_context, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_issue_key = result.params.get("issue_key", issue_key) + final_summary = result.params.get("new_summary", new_summary) + final_description = result.params.get("new_description", new_description) + final_priority = result.params.get("new_priority", new_priority) + final_connector_id = result.params.get( + "connector_id", connector_id_from_context + ) + final_document_id = result.params.get("document_id", document_id) + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + if not final_connector_id: + return { + "status": "error", + "message": "No connector found for this issue.", + } + + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.JIRA_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Jira connector is invalid.", + } + + fields: dict[str, Any] = {} + if final_summary: + fields["summary"] = final_summary + if final_description is not None: + fields["description"] = { + "type": "doc", + "version": 1, + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": final_description}], + } + ], + } + if final_priority: + fields["priority"] = {"name": final_priority} + + if not fields: + return {"status": "error", "message": "No changes specified."} + + try: + jira_history = JiraHistoryConnector( + session=db_session, connector_id=final_connector_id + ) + jira_client = await jira_history._get_jira_client() + await asyncio.to_thread( + jira_client.update_issue, final_issue_key, fields + ) + except Exception as api_err: + if "status code 403" in str(api_err).lower(): + try: + connector.config = {**connector.config, "auth_expired": True} + flag_modified(connector, "config") + await db_session.commit() + except Exception: + pass + return { + "status": "insufficient_permissions", + "connector_id": final_connector_id, + "message": "This Jira account needs additional permissions. Please re-authenticate in connector settings.", + } + raise + + issue_url = ( + f"{jira_history._base_url}/browse/{final_issue_key}" + if jira_history._base_url and final_issue_key + else "" + ) + + kb_message_suffix = "" + if final_document_id: + try: + from app.services.jira import JiraKBSyncService + + kb_service = JiraKBSyncService(db_session) + kb_result = await kb_service.sync_after_update( + document_id=final_document_id, + issue_id=final_issue_key, + user_id=user_id, + search_space_id=search_space_id, + ) + if kb_result["status"] == "success": + kb_message_suffix = ( + " Your knowledge base has also been updated." + ) + else: + kb_message_suffix = ( + " The knowledge base will be updated in the next sync." + ) + except Exception as kb_err: + logger.warning(f"KB sync after update failed: {kb_err}") + kb_message_suffix = ( + " The knowledge base will be updated in the next sync." + ) + + return { + "status": "success", + "issue_key": final_issue_key, + "issue_url": issue_url, + "message": f"Jira issue {final_issue_key} updated successfully.{kb_message_suffix}", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error(f"Error updating Jira issue: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while updating the issue.", + } + + return update_jira_issue diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/agent.py new file mode 100644 index 000000000..4c3d1d3a5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/agent.py @@ -0,0 +1,55 @@ +"""`linear` route: ``SubAgent`` spec for deepagents.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from deepagents import SubAgent +from langchain_core.language_models import BaseChatModel + +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, + middleware_gated_interrupt_on, +) +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( + pack_subagent, +) + +from .tools.index import load_tools + +NAME = "linear" + + +def build_subagent( + *, + dependencies: dict[str, Any], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, + extra_tools_bucket: ToolsPermissions | None = None, +) -> SubAgent: + buckets = load_tools(dependencies=dependencies) + merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket) + tools = [ + row["tool"] + for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) + if row.get("tool") is not None + ] + interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket) + description = read_md_file(__package__, "description").strip() + if not description: + description = "Handles linear tasks for this workspace." + system_prompt = read_md_file(__package__, "system_prompt").strip() + return pack_subagent( + name=NAME, + description=description, + system_prompt=system_prompt, + tools=tools, + interrupt_on=interrupt_on, + model=model, + extra_middleware=extra_middleware, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/description.md new file mode 100644 index 000000000..6ad02c788 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/description.md @@ -0,0 +1 @@ +Use for Linear issue/project work: find/create issues, update status/assignees, review project progress, and inspect cycles. diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/system_prompt.md new file mode 100644 index 000000000..ce91cc49f --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/system_prompt.md @@ -0,0 +1,45 @@ +You are the Linear MCP operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute Linear MCP operations accurately using only available runtime tools. + + + +- Runtime-provided Linear MCP tools for issues/projects/teams/workflows. + + + +- Follow tool descriptions exactly; do not assume unsupported endpoints. +- If required identifiers or context are missing, return `status=blocked` with `missing_fields` and supervisor `next_step`. +- Never invent IDs, statuses, or mutation outcomes. + + + +- Do not execute non-Linear tasks. + + + +- Never claim mutation success without tool confirmation. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On unresolved ambiguity, return `status=blocked` with candidates. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { "items": object | null }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/__init__.py new file mode 100644 index 000000000..31acf1e2a --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/__init__.py @@ -0,0 +1,11 @@ +"""Linear tools for creating, updating, and deleting issues.""" + +from .create_issue import create_create_linear_issue_tool +from .delete_issue import create_delete_linear_issue_tool +from .update_issue import create_update_linear_issue_tool + +__all__ = [ + "create_create_linear_issue_tool", + "create_delete_linear_issue_tool", + "create_update_linear_issue_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/create_issue.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/create_issue.py new file mode 100644 index 000000000..ff254e133 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/create_issue.py @@ -0,0 +1,248 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.linear_connector import LinearAPIError, LinearConnector +from app.services.linear import LinearToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_create_linear_issue_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + """ + Factory function to create the create_linear_issue tool. + + Args: + db_session: Database session for accessing the Linear connector + search_space_id: Search space ID to find the Linear connector + user_id: User ID for fetching user-specific context + connector_id: Optional specific connector ID (if known) + + Returns: + Configured create_linear_issue tool + """ + + @tool + async def create_linear_issue( + title: str, + description: str | None = None, + ) -> dict[str, Any]: + """Create a new issue in Linear. + + Use this tool when the user explicitly asks to create, add, or file + a new issue / ticket / task in Linear. The user MUST describe the issue + before you call this tool. If the request is vague, ask what the issue + should be about. Never call this tool without a clear topic from the user. + + Args: + title: Short, descriptive issue title. Infer from the user's request. + description: Optional markdown body for the issue. Generate from context. + + Returns: + Dictionary with: + - status: "success", "rejected", or "error" + - issue_id: Linear issue UUID (if success) + - identifier: Human-readable ID like "ENG-42" (if success) + - url: URL to the created issue (if success) + - message: Result message + + IMPORTANT: If status is "rejected", the user explicitly declined the action. + Respond with a brief acknowledgment (e.g., "Understood, I won't create the issue.") + and move on. Do NOT retry, troubleshoot, or suggest alternatives. + + Examples: + - "Create a Linear issue for the login bug" + - "File a ticket about the payment timeout problem" + - "Add an issue for the broken search feature" + """ + logger.info(f"create_linear_issue called: title='{title}'") + + if db_session is None or search_space_id is None or user_id is None: + logger.error( + "Linear tool not properly configured - missing required parameters" + ) + return { + "status": "error", + "message": "Linear tool not properly configured. Please contact support.", + } + + try: + metadata_service = LinearToolMetadataService(db_session) + context = await metadata_service.get_creation_context( + search_space_id, user_id + ) + + if "error" in context: + logger.error(f"Failed to fetch creation context: {context['error']}") + return {"status": "error", "message": context["error"]} + + workspaces = context.get("workspaces", []) + if workspaces and all(w.get("auth_expired") for w in workspaces): + logger.warning("All Linear accounts have expired authentication") + return { + "status": "auth_error", + "message": "All connected Linear accounts need re-authentication. Please re-authenticate in your connector settings.", + "connector_type": "linear", + } + + logger.info(f"Requesting approval for creating Linear issue: '{title}'") + result = request_approval( + action_type="linear_issue_creation", + tool_name="create_linear_issue", + params={ + "title": title, + "description": description, + "team_id": None, + "state_id": None, + "assignee_id": None, + "priority": None, + "label_ids": [], + "connector_id": connector_id, + }, + context=context, + ) + + if result.rejected: + logger.info("Linear issue creation rejected by user") + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_title = result.params.get("title", title) + final_description = result.params.get("description", description) + final_team_id = result.params.get("team_id") + final_state_id = result.params.get("state_id") + final_assignee_id = result.params.get("assignee_id") + final_priority = result.params.get("priority") + final_label_ids = result.params.get("label_ids") or [] + final_connector_id = result.params.get("connector_id", connector_id) + + if not final_title or not final_title.strip(): + logger.error("Title is empty or contains only whitespace") + return {"status": "error", "message": "Issue title cannot be empty."} + if not final_team_id: + return { + "status": "error", + "message": "A team must be selected to create an issue.", + } + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + actual_connector_id = final_connector_id + if actual_connector_id is None: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.LINEAR_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "No Linear connector found. Please connect Linear in your workspace settings.", + } + actual_connector_id = connector.id + logger.info(f"Found Linear connector: id={actual_connector_id}") + else: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == actual_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.LINEAR_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Linear connector is invalid or has been disconnected.", + } + logger.info(f"Validated Linear connector: id={actual_connector_id}") + + logger.info( + f"Creating Linear issue with final params: title='{final_title}'" + ) + linear_client = LinearConnector( + session=db_session, connector_id=actual_connector_id + ) + result = await linear_client.create_issue( + team_id=final_team_id, + title=final_title, + description=final_description, + state_id=final_state_id, + assignee_id=final_assignee_id, + priority=final_priority, + label_ids=final_label_ids if final_label_ids else None, + ) + + if result.get("status") == "error": + logger.error(f"Failed to create Linear issue: {result.get('message')}") + return {"status": "error", "message": result.get("message")} + + logger.info( + f"Linear issue created: {result.get('identifier')} - {result.get('title')}" + ) + + kb_message_suffix = "" + try: + from app.services.linear import LinearKBSyncService + + kb_service = LinearKBSyncService(db_session) + kb_result = await kb_service.sync_after_create( + issue_id=result.get("id"), + issue_identifier=result.get("identifier", ""), + issue_title=result.get("title", final_title), + issue_url=result.get("url"), + description=final_description, + connector_id=actual_connector_id, + search_space_id=search_space_id, + user_id=user_id, + ) + if kb_result["status"] == "success": + kb_message_suffix = " Your knowledge base has also been updated." + else: + kb_message_suffix = " This issue will be added to your knowledge base in the next scheduled sync." + except Exception as kb_err: + logger.warning(f"KB sync after create failed: {kb_err}") + kb_message_suffix = " This issue will be added to your knowledge base in the next scheduled sync." + + return { + "status": "success", + "issue_id": result.get("id"), + "identifier": result.get("identifier"), + "url": result.get("url"), + "message": (result.get("message", "") + kb_message_suffix), + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error creating Linear issue: {e}", exc_info=True) + if isinstance(e, ValueError | LinearAPIError): + message = str(e) + else: + message = ( + "Something went wrong while creating the issue. Please try again." + ) + return {"status": "error", "message": message} + + return create_linear_issue diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/delete_issue.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/delete_issue.py new file mode 100644 index 000000000..29ef0cdf2 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/delete_issue.py @@ -0,0 +1,245 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.linear_connector import LinearAPIError, LinearConnector +from app.services.linear import LinearToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_delete_linear_issue_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + """ + Factory function to create the delete_linear_issue tool. + + Args: + db_session: Database session for accessing the Linear connector + search_space_id: Search space ID to find the Linear connector + user_id: User ID for finding the correct Linear connector + connector_id: Optional specific connector ID (if known) + + Returns: + Configured delete_linear_issue tool + """ + + @tool + async def delete_linear_issue( + issue_ref: str, + delete_from_kb: bool = False, + ) -> dict[str, Any]: + """Archive (delete) a Linear issue. + + Use this tool when the user asks to delete, remove, or archive a Linear issue. + Note that Linear archives issues rather than permanently deleting them + (they can be restored from the archive). + + + Args: + issue_ref: The issue to delete. Can be the issue title (e.g. "Fix login bug"), + the identifier (e.g. "ENG-42"), or the full document title + (e.g. "ENG-42: Fix login bug"). + delete_from_kb: Whether to also remove the issue from the knowledge base. + Default is False. Set to True to remove from both Linear + and the knowledge base. + + Returns: + Dictionary with: + - status: "success", "rejected", "not_found", or "error" + - identifier: Human-readable ID like "ENG-42" (if success) + - message: Success or error message + - deleted_from_kb: Whether the issue was also removed from the knowledge base (if success) + + IMPORTANT: + - If status is "rejected", the user explicitly declined the action. + Respond with a brief acknowledgment (e.g., "Understood, I won't delete the issue.") + and move on. Do NOT ask for alternatives or troubleshoot. + - If status is "not_found", inform the user conversationally using the exact message + provided. Do NOT treat this as an error. Simply relay the message and ask the user + to verify the issue title or identifier, or check if it has been indexed. + Examples: + - "Delete the 'Fix login bug' Linear issue" + - "Archive ENG-42" + - "Remove the 'Old payment flow' issue from Linear" + """ + logger.info( + f"delete_linear_issue called: issue_ref='{issue_ref}', delete_from_kb={delete_from_kb}" + ) + + if db_session is None or search_space_id is None or user_id is None: + logger.error( + "Linear tool not properly configured - missing required parameters" + ) + return { + "status": "error", + "message": "Linear tool not properly configured. Please contact support.", + } + + try: + metadata_service = LinearToolMetadataService(db_session) + context = await metadata_service.get_delete_context( + search_space_id, user_id, issue_ref + ) + + if "error" in context: + error_msg = context["error"] + if context.get("auth_expired"): + logger.warning(f"Auth expired for delete context: {error_msg}") + return { + "status": "auth_error", + "message": error_msg, + "connector_id": context.get("connector_id"), + "connector_type": "linear", + } + if "not found" in error_msg.lower(): + logger.warning(f"Issue not found: {error_msg}") + return {"status": "not_found", "message": error_msg} + else: + logger.error(f"Failed to fetch delete context: {error_msg}") + return {"status": "error", "message": error_msg} + + issue_id = context["issue"]["id"] + issue_identifier = context["issue"].get("identifier", "") + document_id = context["issue"]["document_id"] + connector_id_from_context = context.get("workspace", {}).get("id") + + logger.info( + f"Requesting approval for deleting Linear issue: '{issue_ref}' " + f"(id={issue_id}, delete_from_kb={delete_from_kb})" + ) + result = request_approval( + action_type="linear_issue_deletion", + tool_name="delete_linear_issue", + params={ + "issue_id": issue_id, + "connector_id": connector_id_from_context, + "delete_from_kb": delete_from_kb, + }, + context=context, + ) + + if result.rejected: + logger.info("Linear issue deletion rejected by user") + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_issue_id = result.params.get("issue_id", issue_id) + final_connector_id = result.params.get( + "connector_id", connector_id_from_context + ) + final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb) + + logger.info( + f"Deleting Linear issue with final params: issue_id={final_issue_id}, " + f"connector_id={final_connector_id}, delete_from_kb={final_delete_from_kb}" + ) + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + if final_connector_id: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.LINEAR_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + logger.error( + f"Invalid connector_id={final_connector_id} for search_space_id={search_space_id}" + ) + return { + "status": "error", + "message": "Selected Linear connector is invalid or has been disconnected.", + } + actual_connector_id = connector.id + logger.info(f"Validated Linear connector: id={actual_connector_id}") + else: + logger.error("No connector found for this issue") + return { + "status": "error", + "message": "No connector found for this issue.", + } + + linear_client = LinearConnector( + session=db_session, connector_id=actual_connector_id + ) + + result = await linear_client.archive_issue(issue_id=final_issue_id) + + logger.info( + f"archive_issue result: {result.get('status')} - {result.get('message', '')}" + ) + + deleted_from_kb = False + if ( + result.get("status") == "success" + and final_delete_from_kb + and document_id + ): + try: + from app.db import Document + + doc_result = await db_session.execute( + select(Document).filter(Document.id == document_id) + ) + document = doc_result.scalars().first() + if document: + await db_session.delete(document) + await db_session.commit() + deleted_from_kb = True + logger.info( + f"Deleted document {document_id} from knowledge base" + ) + else: + logger.warning(f"Document {document_id} not found in KB") + except Exception as e: + logger.error(f"Failed to delete document from KB: {e}") + await db_session.rollback() + result["warning"] = ( + f"Issue archived in Linear, but failed to remove from knowledge base: {e!s}" + ) + + if result.get("status") == "success": + result["deleted_from_kb"] = deleted_from_kb + if issue_identifier: + result["message"] = ( + f"Issue {issue_identifier} archived successfully." + ) + if deleted_from_kb: + result["message"] = ( + f"{result.get('message', '')} Also removed from the knowledge base." + ) + + return result + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error deleting Linear issue: {e}", exc_info=True) + if isinstance(e, ValueError | LinearAPIError): + message = str(e) + else: + message = ( + "Something went wrong while deleting the issue. Please try again." + ) + return {"status": "error", "message": message} + + return delete_linear_issue diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/index.py new file mode 100644 index 000000000..f1ee49964 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/index.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) + +from .create_issue import create_create_linear_issue_tool +from .delete_issue import create_delete_linear_issue_tool +from .update_issue import create_update_linear_issue_tool + + +def load_tools( + *, dependencies: dict[str, Any] | None = None, **kwargs: Any +) -> ToolsPermissions: + d = {**(dependencies or {}), **kwargs} + common = { + "db_session": d["db_session"], + "search_space_id": d["search_space_id"], + "user_id": d["user_id"], + "connector_id": d.get("connector_id"), + } + create = create_create_linear_issue_tool(**common) + update = create_update_linear_issue_tool(**common) + delete = create_delete_linear_issue_tool(**common) + return { + "allow": [], + "ask": [ + {"name": getattr(create, "name", "") or "", "tool": create}, + {"name": getattr(update, "name", "") or "", "tool": update}, + {"name": getattr(delete, "name", "") or "", "tool": delete}, + ], + } diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/update_issue.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/update_issue.py new file mode 100644 index 000000000..f35d0dddd --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/update_issue.py @@ -0,0 +1,318 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.linear_connector import LinearAPIError, LinearConnector +from app.services.linear import LinearKBSyncService, LinearToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_update_linear_issue_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + """ + Factory function to create the update_linear_issue tool. + + Args: + db_session: Database session for accessing the Linear connector + search_space_id: Search space ID to find the Linear connector + user_id: User ID for fetching user-specific context + connector_id: Optional specific connector ID (if known) + + Returns: + Configured update_linear_issue tool + """ + + @tool + async def update_linear_issue( + issue_ref: str, + new_title: str | None = None, + new_description: str | None = None, + new_state_name: str | None = None, + new_assignee_email: str | None = None, + new_priority: int | None = None, + new_label_names: list[str] | None = None, + ) -> dict[str, Any]: + """Update an existing Linear issue that has been indexed in the knowledge base. + + Use this tool when the user asks to modify, change, or update a Linear issue — + for example, changing its status, reassigning it, updating its title or description, + adjusting its priority, or changing its labels. + + Only issues already indexed in the knowledge base can be updated. + + Args: + issue_ref: The issue to update. Can be the issue title (e.g. "Fix login bug"), + the identifier (e.g. "ENG-42"), or the full document title + (e.g. "ENG-42: Fix login bug"). Matched case-insensitively. + new_title: New title for the issue (optional). + new_description: New markdown body for the issue (optional). + new_state_name: New workflow state name (e.g. "In Progress", "Done"). + Matched case-insensitively against the team's states. + new_assignee_email: Email address of the new assignee. + Matched case-insensitively against the team's members. + new_priority: New priority (0 = No Priority, 1 = Urgent, 2 = High, + 3 = Medium, 4 = Low). + new_label_names: New set of label names to apply. + Matched case-insensitively against the team's labels. + Unrecognised names are silently skipped. + + Returns: + Dictionary with: + - status: "success", "rejected", "not_found", or "error" + - identifier: Human-readable ID like "ENG-42" (if success) + - url: URL to the updated issue (if success) + - message: Result message + + IMPORTANT: + - If status is "rejected", the user explicitly declined the action. + Respond with a brief acknowledgment (e.g., "Understood, I didn't update the issue.") + and move on. Do NOT ask for alternatives or troubleshoot. + - If status is "not_found", inform the user conversationally using the exact message + provided. Do NOT treat this as an error. Simply relay the message and ask the user + to verify the issue title or identifier, or check if it has been indexed. + + Examples: + - "Mark the 'Fix login bug' issue as done" + - "Assign ENG-42 to john@company.com" + - "Change the priority of 'Payment timeout' to urgent" + """ + logger.info(f"update_linear_issue called: issue_ref='{issue_ref}'") + + if db_session is None or search_space_id is None or user_id is None: + logger.error( + "Linear tool not properly configured - missing required parameters" + ) + return { + "status": "error", + "message": "Linear tool not properly configured. Please contact support.", + } + + try: + metadata_service = LinearToolMetadataService(db_session) + context = await metadata_service.get_update_context( + search_space_id, user_id, issue_ref + ) + + if "error" in context: + error_msg = context["error"] + if context.get("auth_expired"): + logger.warning(f"Auth expired for update context: {error_msg}") + return { + "status": "auth_error", + "message": error_msg, + "connector_id": context.get("connector_id"), + "connector_type": "linear", + } + if "not found" in error_msg.lower(): + logger.warning(f"Issue not found: {error_msg}") + return {"status": "not_found", "message": error_msg} + else: + logger.error(f"Failed to fetch update context: {error_msg}") + return {"status": "error", "message": error_msg} + + issue_id = context["issue"]["id"] + document_id = context["issue"]["document_id"] + connector_id_from_context = context.get("workspace", {}).get("id") + + team = context.get("team", {}) + new_state_id = _resolve_state(team, new_state_name) + new_assignee_id = _resolve_assignee(team, new_assignee_email) + new_label_ids = _resolve_labels(team, new_label_names) + + logger.info( + f"Requesting approval for updating Linear issue: '{issue_ref}' (id={issue_id})" + ) + result = request_approval( + action_type="linear_issue_update", + tool_name="update_linear_issue", + params={ + "issue_id": issue_id, + "document_id": document_id, + "new_title": new_title, + "new_description": new_description, + "new_state_id": new_state_id, + "new_assignee_id": new_assignee_id, + "new_priority": new_priority, + "new_label_ids": new_label_ids, + "connector_id": connector_id_from_context, + }, + context=context, + ) + + if result.rejected: + logger.info("Linear issue update rejected by user") + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_issue_id = result.params.get("issue_id", issue_id) + final_document_id = result.params.get("document_id", document_id) + final_new_title = result.params.get("new_title", new_title) + final_new_description = result.params.get( + "new_description", new_description + ) + final_new_state_id = result.params.get("new_state_id", new_state_id) + final_new_assignee_id = result.params.get( + "new_assignee_id", new_assignee_id + ) + final_new_priority = result.params.get("new_priority", new_priority) + final_new_label_ids: list[str] | None = result.params.get( + "new_label_ids", new_label_ids + ) + final_connector_id = result.params.get( + "connector_id", connector_id_from_context + ) + + if not final_connector_id: + logger.error("No connector found for this issue") + return { + "status": "error", + "message": "No connector found for this issue.", + } + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.LINEAR_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + logger.error( + f"Invalid connector_id={final_connector_id} for search_space_id={search_space_id}" + ) + return { + "status": "error", + "message": "Selected Linear connector is invalid or has been disconnected.", + } + logger.info(f"Validated Linear connector: id={final_connector_id}") + + logger.info( + f"Updating Linear issue with final params: issue_id={final_issue_id}" + ) + linear_client = LinearConnector( + session=db_session, connector_id=final_connector_id + ) + updated_issue = await linear_client.update_issue( + issue_id=final_issue_id, + title=final_new_title, + description=final_new_description, + state_id=final_new_state_id, + assignee_id=final_new_assignee_id, + priority=final_new_priority, + label_ids=final_new_label_ids, + ) + + if updated_issue.get("status") == "error": + logger.error( + f"Failed to update Linear issue: {updated_issue.get('message')}" + ) + return { + "status": "error", + "message": updated_issue.get("message"), + } + + logger.info( + f"update_issue result: {updated_issue.get('identifier')} - {updated_issue.get('title')}" + ) + + if final_document_id is not None: + logger.info( + f"Updating knowledge base for document {final_document_id}..." + ) + kb_service = LinearKBSyncService(db_session) + kb_result = await kb_service.sync_after_update( + document_id=final_document_id, + issue_id=final_issue_id, + user_id=user_id, + search_space_id=search_space_id, + ) + if kb_result["status"] == "success": + logger.info( + f"Knowledge base successfully updated for issue {final_issue_id}" + ) + kb_message = " Your knowledge base has also been updated." + elif kb_result["status"] == "not_indexed": + kb_message = " This issue will be added to your knowledge base in the next scheduled sync." + else: + logger.warning( + f"KB update failed for issue {final_issue_id}: {kb_result.get('message')}" + ) + kb_message = " Your knowledge base will be updated in the next scheduled sync." + else: + kb_message = "" + + identifier = updated_issue.get("identifier") + default_msg = f"Issue {identifier} updated successfully." + return { + "status": "success", + "identifier": identifier, + "url": updated_issue.get("url"), + "message": f"{updated_issue.get('message', default_msg)}{kb_message}", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error updating Linear issue: {e}", exc_info=True) + if isinstance(e, ValueError | LinearAPIError): + message = str(e) + else: + message = ( + "Something went wrong while updating the issue. Please try again." + ) + return {"status": "error", "message": message} + + return update_linear_issue + + +def _resolve_state(team: dict, state_name: str | None) -> str | None: + if not state_name: + return None + name_lower = state_name.lower() + for state in team.get("states", []): + if state.get("name", "").lower() == name_lower: + return state["id"] + return None + + +def _resolve_assignee(team: dict, assignee_email: str | None) -> str | None: + if not assignee_email: + return None + email_lower = assignee_email.lower() + for member in team.get("members", []): + if member.get("email", "").lower() == email_lower: + return member["id"] + return None + + +def _resolve_labels(team: dict, label_names: list[str] | None) -> list[str] | None: + if label_names is None: + return None + if not label_names: + return [] + name_set = {n.lower() for n in label_names} + return [ + label["id"] + for label in team.get("labels", []) + if label.get("name", "").lower() in name_set + ] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/agent.py new file mode 100644 index 000000000..343874c33 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/agent.py @@ -0,0 +1,55 @@ +"""`luma` route: ``SubAgent`` spec for deepagents.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from deepagents import SubAgent +from langchain_core.language_models import BaseChatModel + +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, + middleware_gated_interrupt_on, +) +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( + pack_subagent, +) + +from .tools.index import load_tools + +NAME = "luma" + + +def build_subagent( + *, + dependencies: dict[str, Any], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, + extra_tools_bucket: ToolsPermissions | None = None, +) -> SubAgent: + buckets = load_tools(dependencies=dependencies) + merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket) + tools = [ + row["tool"] + for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) + if row.get("tool") is not None + ] + interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket) + description = read_md_file(__package__, "description").strip() + if not description: + description = "Handles luma tasks for this workspace." + system_prompt = read_md_file(__package__, "system_prompt").strip() + return pack_subagent( + name=NAME, + description=description, + system_prompt=system_prompt, + tools=tools, + interrupt_on=interrupt_on, + model=model, + extra_middleware=extra_middleware, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/description.md new file mode 100644 index 000000000..9eaae4ac5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/description.md @@ -0,0 +1 @@ +Use for Luma event operations: list events, inspect event details, and create new events. diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/system_prompt.md new file mode 100644 index 000000000..a2b4b7391 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/system_prompt.md @@ -0,0 +1,55 @@ +You are the Luma operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute Luma event listing, reads, and creation accurately. + + + +- `list_luma_events` +- `read_luma_event` +- `create_luma_event` + + + +- Use only tools in ``. +- Resolve relative dates against runtime timestamp. +- If required event fields are missing, return `status=blocked` with `missing_fields`. +- Never invent event IDs/times or creation outcomes. + + + +- Do not perform non-Luma tasks. + + + +- Never claim event creation success without tool confirmation. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On missing required fields, return `status=blocked` with `missing_fields`. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "event_id": string | null, + "title": string | null, + "start_at": string (ISO 8601 with timezone) | null, + "matched_candidates": [ + { "event_id": string, "title": string | null, "start_at": string | null } + ] | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/__init__.py new file mode 100644 index 000000000..255119bee --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/__init__.py @@ -0,0 +1,15 @@ +from app.agents.new_chat.tools.luma.create_event import ( + create_create_luma_event_tool, +) +from app.agents.new_chat.tools.luma.list_events import ( + create_list_luma_events_tool, +) +from app.agents.new_chat.tools.luma.read_event import ( + create_read_luma_event_tool, +) + +__all__ = [ + "create_create_luma_event_tool", + "create_list_luma_events_tool", + "create_read_luma_event_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/_auth.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/_auth.py new file mode 100644 index 000000000..c6d1cd148 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/_auth.py @@ -0,0 +1,39 @@ +"""Builds Luma API auth for connector-backed event tools.""" + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.db import SearchSourceConnector, SearchSourceConnectorType + +LUMA_API = "https://public-api.luma.com/v1" + + +async def get_luma_connector( + db_session: AsyncSession, + search_space_id: int, + user_id: str, +) -> SearchSourceConnector | None: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.LUMA_CONNECTOR, + ) + ) + return result.scalars().first() + + +def get_api_key(connector: SearchSourceConnector) -> str: + """Extract the API key from connector config (handles both key names).""" + key = connector.config.get("api_key") or connector.config.get("LUMA_API_KEY") + if not key: + raise ValueError("Luma API key not found in connector config.") + return key + + +def luma_headers(api_key: str) -> dict[str, str]: + return { + "Content-Type": "application/json", + "x-luma-api-key": api_key, + } diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/create_event.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/create_event.py new file mode 100644 index 000000000..0a24a988f --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/create_event.py @@ -0,0 +1,129 @@ +import logging +from typing import Any + +import httpx +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval + +from ._auth import LUMA_API, get_api_key, get_luma_connector, luma_headers + +logger = logging.getLogger(__name__) + + +def create_create_luma_event_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def create_luma_event( + name: str, + start_at: str, + end_at: str, + description: str | None = None, + timezone: str = "UTC", + ) -> dict[str, Any]: + """Create a new event on Luma. + + Args: + name: The event title. + start_at: Start time in ISO 8601 format (e.g. "2026-05-01T18:00:00"). + end_at: End time in ISO 8601 format (e.g. "2026-05-01T20:00:00"). + description: Optional event description (markdown supported). + timezone: Timezone string (default "UTC", e.g. "America/New_York"). + + Returns: + Dictionary with status, event_id on success. + + IMPORTANT: + - If status is "rejected", the user explicitly declined. Do NOT retry. + """ + if db_session is None or search_space_id is None or user_id is None: + return {"status": "error", "message": "Luma tool not properly configured."} + + try: + connector = await get_luma_connector(db_session, search_space_id, user_id) + if not connector: + return {"status": "error", "message": "No Luma connector found."} + + result = request_approval( + action_type="luma_create_event", + tool_name="create_luma_event", + params={ + "name": name, + "start_at": start_at, + "end_at": end_at, + "description": description, + "timezone": timezone, + }, + context={"connector_id": connector.id}, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Event was not created.", + } + + final_name = result.params.get("name", name) + final_start = result.params.get("start_at", start_at) + final_end = result.params.get("end_at", end_at) + final_desc = result.params.get("description", description) + final_tz = result.params.get("timezone", timezone) + + api_key = get_api_key(connector) + headers = luma_headers(api_key) + + body: dict[str, Any] = { + "name": final_name, + "start_at": final_start, + "end_at": final_end, + "timezone": final_tz, + } + if final_desc: + body["description_md"] = final_desc + + async with httpx.AsyncClient(timeout=20.0) as client: + resp = await client.post( + f"{LUMA_API}/event/create", + headers=headers, + json=body, + ) + + if resp.status_code == 401: + return { + "status": "auth_error", + "message": "Luma API key is invalid.", + "connector_type": "luma", + } + if resp.status_code == 403: + return { + "status": "error", + "message": "Luma Plus subscription required to create events via API.", + } + if resp.status_code not in (200, 201): + return { + "status": "error", + "message": f"Luma API error: {resp.status_code} — {resp.text[:200]}", + } + + data = resp.json() + event_id = data.get("api_id") or data.get("event", {}).get("api_id") + + return { + "status": "success", + "event_id": event_id, + "message": f"Event '{final_name}' created on Luma.", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error("Error creating Luma event: %s", e, exc_info=True) + return {"status": "error", "message": "Failed to create Luma event."} + + return create_luma_event diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/index.py new file mode 100644 index 000000000..47b303295 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/index.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) + +from .create_event import create_create_luma_event_tool +from .list_events import create_list_luma_events_tool +from .read_event import create_read_luma_event_tool + + +def load_tools( + *, dependencies: dict[str, Any] | None = None, **kwargs: Any +) -> ToolsPermissions: + d = {**(dependencies or {}), **kwargs} + common = { + "db_session": d["db_session"], + "search_space_id": d["search_space_id"], + "user_id": d["user_id"], + } + list_ev = create_list_luma_events_tool(**common) + read_ev = create_read_luma_event_tool(**common) + create = create_create_luma_event_tool(**common) + return { + "allow": [ + {"name": getattr(list_ev, "name", "") or "", "tool": list_ev}, + {"name": getattr(read_ev, "name", "") or "", "tool": read_ev}, + ], + "ask": [{"name": getattr(create, "name", "") or "", "tool": create}], + } diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/list_events.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/list_events.py new file mode 100644 index 000000000..aec5ad220 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/list_events.py @@ -0,0 +1,111 @@ +import logging +from typing import Any + +import httpx +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from ._auth import LUMA_API, get_api_key, get_luma_connector, luma_headers + +logger = logging.getLogger(__name__) + + +def create_list_luma_events_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def list_luma_events( + max_results: int = 25, + ) -> dict[str, Any]: + """List upcoming and recent Luma events. + + Args: + max_results: Maximum events to return (default 25, max 50). + + Returns: + Dictionary with status and a list of events including + event_id, name, start_at, end_at, location, url. + """ + if db_session is None or search_space_id is None or user_id is None: + return {"status": "error", "message": "Luma tool not properly configured."} + + max_results = min(max_results, 50) + + try: + connector = await get_luma_connector(db_session, search_space_id, user_id) + if not connector: + return {"status": "error", "message": "No Luma connector found."} + + api_key = get_api_key(connector) + headers = luma_headers(api_key) + + all_entries: list[dict] = [] + cursor = None + + async with httpx.AsyncClient(timeout=20.0) as client: + while len(all_entries) < max_results: + params: dict[str, Any] = { + "limit": min(100, max_results - len(all_entries)) + } + if cursor: + params["cursor"] = cursor + + resp = await client.get( + f"{LUMA_API}/calendar/list-events", + headers=headers, + params=params, + ) + + if resp.status_code == 401: + return { + "status": "auth_error", + "message": "Luma API key is invalid.", + "connector_type": "luma", + } + if resp.status_code != 200: + return { + "status": "error", + "message": f"Luma API error: {resp.status_code}", + } + + data = resp.json() + entries = data.get("entries", []) + if not entries: + break + all_entries.extend(entries) + + next_cursor = data.get("next_cursor") + if not next_cursor: + break + cursor = next_cursor + + events = [] + for entry in all_entries[:max_results]: + ev = entry.get("event", {}) + geo = ev.get("geo_info", {}) + events.append( + { + "event_id": entry.get("api_id"), + "name": ev.get("name", "Untitled"), + "start_at": ev.get("start_at", ""), + "end_at": ev.get("end_at", ""), + "timezone": ev.get("timezone", ""), + "location": geo.get("name", ""), + "url": ev.get("url", ""), + "visibility": ev.get("visibility", ""), + } + ) + + return {"status": "success", "events": events, "total": len(events)} + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error("Error listing Luma events: %s", e, exc_info=True) + return {"status": "error", "message": "Failed to list Luma events."} + + return list_luma_events diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/read_event.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/read_event.py new file mode 100644 index 000000000..b37a9d617 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/read_event.py @@ -0,0 +1,92 @@ +import logging +from typing import Any + +import httpx +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from ._auth import LUMA_API, get_api_key, get_luma_connector, luma_headers + +logger = logging.getLogger(__name__) + + +def create_read_luma_event_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def read_luma_event(event_id: str) -> dict[str, Any]: + """Read detailed information about a specific Luma event. + + Args: + event_id: The Luma event API ID (from list_luma_events). + + Returns: + Dictionary with status and full event details including + description, attendees count, meeting URL. + """ + if db_session is None or search_space_id is None or user_id is None: + return {"status": "error", "message": "Luma tool not properly configured."} + + try: + connector = await get_luma_connector(db_session, search_space_id, user_id) + if not connector: + return {"status": "error", "message": "No Luma connector found."} + + api_key = get_api_key(connector) + headers = luma_headers(api_key) + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get( + f"{LUMA_API}/events/{event_id}", + headers=headers, + ) + + if resp.status_code == 401: + return { + "status": "auth_error", + "message": "Luma API key is invalid.", + "connector_type": "luma", + } + if resp.status_code == 404: + return { + "status": "not_found", + "message": f"Event '{event_id}' not found.", + } + if resp.status_code != 200: + return { + "status": "error", + "message": f"Luma API error: {resp.status_code}", + } + + data = resp.json() + ev = data.get("event", data) + geo = ev.get("geo_info", {}) + + event_detail = { + "event_id": event_id, + "name": ev.get("name", ""), + "description": ev.get("description", ""), + "start_at": ev.get("start_at", ""), + "end_at": ev.get("end_at", ""), + "timezone": ev.get("timezone", ""), + "location_name": geo.get("name", ""), + "address": geo.get("address", ""), + "url": ev.get("url", ""), + "meeting_url": ev.get("meeting_url", ""), + "visibility": ev.get("visibility", ""), + "cover_url": ev.get("cover_url", ""), + } + + return {"status": "success", "event": event_detail} + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error("Error reading Luma event: %s", e, exc_info=True) + return {"status": "error", "message": "Failed to read Luma event."} + + return read_luma_event diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/agent.py new file mode 100644 index 000000000..8c8a80ab5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/agent.py @@ -0,0 +1,55 @@ +"""`notion` route: ``SubAgent`` spec for deepagents.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from deepagents import SubAgent +from langchain_core.language_models import BaseChatModel + +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, + middleware_gated_interrupt_on, +) +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( + pack_subagent, +) + +from .tools.index import load_tools + +NAME = "notion" + + +def build_subagent( + *, + dependencies: dict[str, Any], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, + extra_tools_bucket: ToolsPermissions | None = None, +) -> SubAgent: + buckets = load_tools(dependencies=dependencies) + merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket) + tools = [ + row["tool"] + for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) + if row.get("tool") is not None + ] + interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket) + description = read_md_file(__package__, "description").strip() + if not description: + description = "Handles notion tasks for this workspace." + system_prompt = read_md_file(__package__, "system_prompt").strip() + return pack_subagent( + name=NAME, + description=description, + system_prompt=system_prompt, + tools=tools, + interrupt_on=interrupt_on, + model=model, + extra_middleware=extra_middleware, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/description.md new file mode 100644 index 000000000..f1d51c18a --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/description.md @@ -0,0 +1 @@ +Use for Notion workspace pages: create pages, update page content, and delete pages. diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/system_prompt.md new file mode 100644 index 000000000..a40e9f4d0 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/system_prompt.md @@ -0,0 +1,56 @@ +You are the Notion operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute Notion page operations accurately in the connected workspace. + + + +- `create_notion_page` +- `update_notion_page` +- `delete_notion_page` + + + +- Use only tools in ``. +- If target page context is unclear, do not ask the user directly; return `status=blocked` with candidate options and supervisor `next_step`. +- Never invent page IDs, titles, or mutation outcomes. + + + +- Do not perform non-Notion tasks. + + + +- Before update/delete, ensure the target page match is explicit. +- Never claim mutation success without tool confirmation. + + + +- On tool failure, return `status=error` with concise retry/recovery `next_step`. +- On ambiguous target, return `status=blocked` with candidate options. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "page_id": string | null, + "page_title": string | null, + "matched_candidates": [ + { "page_id": string, "page_title": string | null } + ] | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} + +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. +- On ambiguity, include candidate options in `evidence.matched_candidates`. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/__init__.py new file mode 100644 index 000000000..6ce825dca --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/__init__.py @@ -0,0 +1,11 @@ +"""Notion tools for creating, updating, and deleting pages.""" + +from .create_page import create_create_notion_page_tool +from .delete_page import create_delete_notion_page_tool +from .update_page import create_update_notion_page_tool + +__all__ = [ + "create_create_notion_page_tool", + "create_delete_notion_page_tool", + "create_update_notion_page_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/create_page.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/create_page.py new file mode 100644 index 000000000..6efffe960 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/create_page.py @@ -0,0 +1,244 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector +from app.services.notion import NotionToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_create_notion_page_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + """ + Factory function to create the create_notion_page tool. + + Args: + db_session: Database session for accessing Notion connector + search_space_id: Search space ID to find the Notion connector + user_id: User ID for fetching user-specific context + connector_id: Optional specific connector ID (if known) + + Returns: + Configured create_notion_page tool + """ + + @tool + async def create_notion_page( + title: str, + content: str | None = None, + ) -> dict[str, Any]: + """Create a new page in Notion with the given title and content. + + Use this tool when the user asks you to create, save, or publish + something to Notion. The page will be created in the user's + configured Notion workspace. The user MUST specify a topic before you + call this tool. If the request does not contain a topic (e.g. "create a + notion page"), ask what the page should be about. Never call this tool + without a clear topic from the user. + + Args: + title: The title of the Notion page. + content: Optional markdown content for the page body (supports headings, lists, paragraphs). + Generate this yourself based on the user's topic. + + Returns: + Dictionary with: + - status: "success", "rejected", or "error" + - page_id: Created page ID (if success) + - url: URL to the created page (if success) + - title: Page title (if success) + - message: Result message + + IMPORTANT: If status is "rejected", the user explicitly declined the action. + Respond with a brief acknowledgment (e.g., "Understood, I didn't create the page.") + and move on. Do NOT troubleshoot or suggest alternatives. + + Examples: + - "Create a Notion page about our Q2 roadmap" + - "Save a summary of today's discussion to Notion" + """ + logger.info(f"create_notion_page called: title='{title}'") + + if db_session is None or search_space_id is None or user_id is None: + logger.error( + "Notion tool not properly configured - missing required parameters" + ) + return { + "status": "error", + "message": "Notion tool not properly configured. Please contact support.", + } + + try: + metadata_service = NotionToolMetadataService(db_session) + context = await metadata_service.get_creation_context( + search_space_id, user_id + ) + + if "error" in context: + logger.error(f"Failed to fetch creation context: {context['error']}") + return { + "status": "error", + "message": context["error"], + } + + accounts = context.get("accounts", []) + if accounts and all(a.get("auth_expired") for a in accounts): + logger.warning("All Notion accounts have expired authentication") + return { + "status": "auth_error", + "message": "All connected Notion accounts need re-authentication. Please re-authenticate in your connector settings.", + "connector_type": "notion", + } + + logger.info(f"Requesting approval for creating Notion page: '{title}'") + result = request_approval( + action_type="notion_page_creation", + tool_name="create_notion_page", + params={ + "title": title, + "content": content, + "parent_page_id": None, + "connector_id": connector_id, + }, + context=context, + ) + + if result.rejected: + logger.info("Notion page creation rejected by user") + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_title = result.params.get("title", title) + final_content = result.params.get("content", content) + final_parent_page_id = result.params.get("parent_page_id") + final_connector_id = result.params.get("connector_id", connector_id) + + if not final_title or not final_title.strip(): + logger.error("Title is empty or contains only whitespace") + return { + "status": "error", + "message": "Page title cannot be empty. Please provide a valid title.", + } + + logger.info( + f"Creating Notion page with final params: title='{final_title}'" + ) + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + actual_connector_id = final_connector_id + if actual_connector_id is None: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.NOTION_CONNECTOR, + ) + ) + connector = result.scalars().first() + + if not connector: + logger.warning( + f"No Notion connector found for search_space_id={search_space_id}" + ) + return { + "status": "error", + "message": "No Notion connector found. Please connect Notion in your workspace settings.", + } + + actual_connector_id = connector.id + logger.info(f"Found Notion connector: id={actual_connector_id}") + else: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == actual_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.NOTION_CONNECTOR, + ) + ) + connector = result.scalars().first() + + if not connector: + logger.error( + f"Invalid connector_id={actual_connector_id} for search_space_id={search_space_id}" + ) + return { + "status": "error", + "message": "Selected Notion account is invalid or has been disconnected. Please select a valid account.", + } + logger.info(f"Validated Notion connector: id={actual_connector_id}") + + notion_connector = NotionHistoryConnector( + session=db_session, + connector_id=actual_connector_id, + ) + + result = await notion_connector.create_page( + title=final_title, + content=final_content, + parent_page_id=final_parent_page_id, + ) + logger.info( + f"create_page result: {result.get('status')} - {result.get('message', '')}" + ) + + if result.get("status") == "success": + kb_message_suffix = "" + try: + from app.services.notion import NotionKBSyncService + + kb_service = NotionKBSyncService(db_session) + kb_result = await kb_service.sync_after_create( + page_id=result.get("page_id"), + page_title=result.get("title", final_title), + page_url=result.get("url"), + content=final_content, + connector_id=actual_connector_id, + search_space_id=search_space_id, + user_id=user_id, + ) + if kb_result["status"] == "success": + kb_message_suffix = ( + " Your knowledge base has also been updated." + ) + else: + kb_message_suffix = " This page will be added to your knowledge base in the next scheduled sync." + except Exception as kb_err: + logger.warning(f"KB sync after create failed: {kb_err}") + kb_message_suffix = " This page will be added to your knowledge base in the next scheduled sync." + + result["message"] = result.get("message", "") + kb_message_suffix + + return result + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error creating Notion page: {e}", exc_info=True) + if isinstance(e, ValueError | NotionAPIError): + message = str(e) + else: + message = ( + "Something went wrong while creating the page. Please try again." + ) + return {"status": "error", "message": message} + + return create_notion_page diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/delete_page.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/delete_page.py new file mode 100644 index 000000000..07f7583d2 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/delete_page.py @@ -0,0 +1,262 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector +from app.services.notion.tool_metadata_service import NotionToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_delete_notion_page_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + """ + Factory function to create the delete_notion_page tool. + + Args: + db_session: Database session for accessing Notion connector + search_space_id: Search space ID to find the Notion connector + user_id: User ID for finding the correct Notion connector + connector_id: Optional specific connector ID (if known) + + Returns: + Configured delete_notion_page tool + """ + + @tool + async def delete_notion_page( + page_title: str, + delete_from_kb: bool = False, + ) -> dict[str, Any]: + """Delete (archive) a Notion page. + + Use this tool when the user asks you to delete, remove, or archive + a Notion page. Note that Notion doesn't permanently delete pages, + it archives them (they can be restored from trash). + + Args: + page_title: The title of the Notion page to delete. + delete_from_kb: Whether to also remove the page from the knowledge base. + Default is False. + Set to True to permanently remove from both Notion and knowledge base. + + Returns: + Dictionary with: + - status: "success", "rejected", "not_found", or "error" + - page_id: Deleted page ID (if success) + - message: Success or error message + - deleted_from_kb: Whether the page was also removed from knowledge base (if success) + + Examples: + - "Delete the 'Meeting Notes' Notion page" + - "Remove the 'Old Project Plan' Notion page" + - "Archive the 'Draft Ideas' Notion page" + """ + logger.info( + f"delete_notion_page called: page_title='{page_title}', delete_from_kb={delete_from_kb}" + ) + + if db_session is None or search_space_id is None or user_id is None: + logger.error( + "Notion tool not properly configured - missing required parameters" + ) + return { + "status": "error", + "message": "Notion tool not properly configured. Please contact support.", + } + + try: + # Get page context (page_id, account, title) from indexed data + metadata_service = NotionToolMetadataService(db_session) + context = await metadata_service.get_delete_context( + search_space_id, user_id, page_title + ) + + if "error" in context: + error_msg = context["error"] + # Check if it's a "not found" error (softer handling for LLM) + if "not found" in error_msg.lower(): + logger.warning(f"Page not found: {error_msg}") + return { + "status": "not_found", + "message": error_msg, + } + else: + logger.error(f"Failed to fetch delete context: {error_msg}") + return { + "status": "error", + "message": error_msg, + } + + account = context.get("account", {}) + if account.get("auth_expired"): + logger.warning( + "Notion account %s has expired authentication", + account.get("id"), + ) + return { + "status": "auth_error", + "message": "The Notion account for this page needs re-authentication. Please re-authenticate in your connector settings.", + } + + page_id = context.get("page_id") + connector_id_from_context = account.get("id") + document_id = context.get("document_id") + + logger.info( + f"Requesting approval for deleting Notion page: '{page_title}' (page_id={page_id}, delete_from_kb={delete_from_kb})" + ) + + result = request_approval( + action_type="notion_page_deletion", + tool_name="delete_notion_page", + params={ + "page_id": page_id, + "connector_id": connector_id_from_context, + "delete_from_kb": delete_from_kb, + }, + context=context, + ) + + if result.rejected: + logger.info("Notion page deletion rejected by user") + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_page_id = result.params.get("page_id", page_id) + final_connector_id = result.params.get( + "connector_id", connector_id_from_context + ) + final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb) + + logger.info( + f"Deleting Notion page with final params: page_id={final_page_id}, connector_id={final_connector_id}, delete_from_kb={final_delete_from_kb}" + ) + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + # Validate the connector + if final_connector_id: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.NOTION_CONNECTOR, + ) + ) + connector = result.scalars().first() + + if not connector: + logger.error( + f"Invalid connector_id={final_connector_id} for search_space_id={search_space_id}" + ) + return { + "status": "error", + "message": "Selected Notion account is invalid or has been disconnected. Please select a valid account.", + } + actual_connector_id = connector.id + logger.info(f"Validated Notion connector: id={actual_connector_id}") + else: + logger.error("No connector found for this page") + return { + "status": "error", + "message": "No connector found for this page.", + } + + # Create connector instance + notion_connector = NotionHistoryConnector( + session=db_session, + connector_id=actual_connector_id, + ) + + # Delete the page from Notion + result = await notion_connector.delete_page(page_id=final_page_id) + logger.info( + f"delete_page result: {result.get('status')} - {result.get('message', '')}" + ) + + # If deletion was successful and user wants to delete from KB + deleted_from_kb = False + if ( + result.get("status") == "success" + and final_delete_from_kb + and document_id + ): + try: + from sqlalchemy.future import select + + from app.db import Document + + # Get the document + doc_result = await db_session.execute( + select(Document).filter(Document.id == document_id) + ) + document = doc_result.scalars().first() + + if document: + await db_session.delete(document) + await db_session.commit() + deleted_from_kb = True + logger.info( + f"Deleted document {document_id} from knowledge base" + ) + else: + logger.warning(f"Document {document_id} not found in KB") + except Exception as e: + logger.error(f"Failed to delete document from KB: {e}") + await db_session.rollback() + result["warning"] = ( + f"Page deleted from Notion, but failed to remove from knowledge base: {e!s}" + ) + + # Update result with KB deletion status + if result.get("status") == "success": + result["deleted_from_kb"] = deleted_from_kb + if deleted_from_kb: + result["message"] = ( + f"{result.get('message', '')} (also removed from knowledge base)" + ) + + return result + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error deleting Notion page: {e}", exc_info=True) + error_str = str(e).lower() + if isinstance(e, NotionAPIError) and ( + "401" in error_str or "unauthorized" in error_str + ): + return { + "status": "auth_error", + "message": str(e), + "connector_id": connector_id_from_context + if "connector_id_from_context" in dir() + else None, + "connector_type": "notion", + } + if isinstance(e, ValueError | NotionAPIError): + message = str(e) + else: + message = ( + "Something went wrong while deleting the page. Please try again." + ) + return {"status": "error", "message": message} + + return delete_notion_page diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/index.py new file mode 100644 index 000000000..c78f630a1 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/index.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) + +from .create_page import create_create_notion_page_tool +from .delete_page import create_delete_notion_page_tool +from .update_page import create_update_notion_page_tool + + +def load_tools( + *, dependencies: dict[str, Any] | None = None, **kwargs: Any +) -> ToolsPermissions: + d = {**(dependencies or {}), **kwargs} + common = { + "db_session": d["db_session"], + "search_space_id": d["search_space_id"], + "user_id": d["user_id"], + } + create = create_create_notion_page_tool(**common) + update = create_update_notion_page_tool(**common) + delete = create_delete_notion_page_tool(**common) + return { + "allow": [], + "ask": [ + {"name": getattr(create, "name", "") or "", "tool": create}, + {"name": getattr(update, "name", "") or "", "tool": update}, + {"name": getattr(delete, "name", "") or "", "tool": delete}, + ], + } diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/update_page.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/update_page.py new file mode 100644 index 000000000..85c08177c --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/update_page.py @@ -0,0 +1,265 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector +from app.services.notion import NotionToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_update_notion_page_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + """ + Factory function to create the update_notion_page tool. + + Args: + db_session: Database session for accessing Notion connector + search_space_id: Search space ID to find the Notion connector + user_id: User ID for fetching user-specific context + connector_id: Optional specific connector ID (if known) + + Returns: + Configured update_notion_page tool + """ + + @tool + async def update_notion_page( + page_title: str, + content: str | None = None, + ) -> dict[str, Any]: + """Update an existing Notion page by appending new content. + + Use this tool when the user asks you to add content to, modify, or update + a Notion page. The new content will be appended to the existing page content. + The user MUST specify what to add before you call this tool. If the + request is vague, ask what content they want added. + + Args: + page_title: The title of the Notion page to update. + content: Optional markdown content to append to the page body (supports headings, lists, paragraphs). + Generate this yourself based on the user's request. + + Returns: + Dictionary with: + - status: "success", "rejected", "not_found", or "error" + - page_id: Updated page ID (if success) + - url: URL to the updated page (if success) + - title: Current page title (if success) + - message: Result message + + IMPORTANT: + - If status is "rejected", the user explicitly declined the action. + Respond with a brief acknowledgment (e.g., "Understood, I didn't update the page.") + and move on. Do NOT ask for alternatives or troubleshoot. + - If status is "not_found", inform the user conversationally using the exact message provided. + Example: "I couldn't find the page '[page_title]' in your indexed Notion pages. [message details]" + Do NOT treat this as an error. Do NOT invent information. Simply relay the message and + ask the user to verify the page title or check if it's been indexed. + Examples: + - "Add today's meeting notes to the 'Meeting Notes' Notion page" + - "Update the 'Project Plan' page with a status update on phase 1" + """ + logger.info( + f"update_notion_page called: page_title='{page_title}', content_length={len(content) if content else 0}" + ) + + if db_session is None or search_space_id is None or user_id is None: + logger.error( + "Notion tool not properly configured - missing required parameters" + ) + return { + "status": "error", + "message": "Notion tool not properly configured. Please contact support.", + } + + if not content or not content.strip(): + logger.error(f"Empty content provided for page '{page_title}'") + return { + "status": "error", + "message": "Content is required to update the page. Please provide the actual content you want to add.", + } + + try: + metadata_service = NotionToolMetadataService(db_session) + context = await metadata_service.get_update_context( + search_space_id, user_id, page_title + ) + + if "error" in context: + error_msg = context["error"] + # Check if it's a "not found" error (softer handling for LLM) + if "not found" in error_msg.lower(): + logger.warning(f"Page not found: {error_msg}") + return { + "status": "not_found", + "message": error_msg, + } + else: + logger.error(f"Failed to fetch update context: {error_msg}") + return { + "status": "error", + "message": error_msg, + } + + account = context.get("account", {}) + if account.get("auth_expired"): + logger.warning( + "Notion account %s has expired authentication", + account.get("id"), + ) + return { + "status": "auth_error", + "message": "The Notion account for this page needs re-authentication. Please re-authenticate in your connector settings.", + } + + page_id = context.get("page_id") + document_id = context.get("document_id") + connector_id_from_context = context.get("account", {}).get("id") + + logger.info( + f"Requesting approval for updating Notion page: '{page_title}' (page_id={page_id})" + ) + result = request_approval( + action_type="notion_page_update", + tool_name="update_notion_page", + params={ + "page_id": page_id, + "content": content, + "connector_id": connector_id_from_context, + }, + context=context, + ) + + if result.rejected: + logger.info("Notion page update rejected by user") + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_page_id = result.params.get("page_id", page_id) + final_content = result.params.get("content", content) + final_connector_id = result.params.get( + "connector_id", connector_id_from_context + ) + + logger.info( + f"Updating Notion page with final params: page_id={final_page_id}, has_content={final_content is not None}" + ) + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + if final_connector_id: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.NOTION_CONNECTOR, + ) + ) + connector = result.scalars().first() + + if not connector: + logger.error( + f"Invalid connector_id={final_connector_id} for search_space_id={search_space_id}" + ) + return { + "status": "error", + "message": "Selected Notion account is invalid or has been disconnected. Please select a valid account.", + } + actual_connector_id = connector.id + logger.info(f"Validated Notion connector: id={actual_connector_id}") + else: + logger.error("No connector found for this page") + return { + "status": "error", + "message": "No connector found for this page.", + } + + notion_connector = NotionHistoryConnector( + session=db_session, + connector_id=actual_connector_id, + ) + + result = await notion_connector.update_page( + page_id=final_page_id, + content=final_content, + ) + logger.info( + f"update_page result: {result.get('status')} - {result.get('message', '')}" + ) + + if result.get("status") == "success" and document_id is not None: + from app.services.notion import NotionKBSyncService + + logger.info(f"Updating knowledge base for document {document_id}...") + kb_service = NotionKBSyncService(db_session) + kb_result = await kb_service.sync_after_update( + document_id=document_id, + appended_content=final_content, + user_id=user_id, + search_space_id=search_space_id, + appended_block_ids=result.get("appended_block_ids"), + ) + + if kb_result["status"] == "success": + result["message"] = ( + f"{result['message']}. Your knowledge base has also been updated." + ) + logger.info( + f"Knowledge base successfully updated for page {final_page_id}" + ) + elif kb_result["status"] == "not_indexed": + result["message"] = ( + f"{result['message']}. This page will be added to your knowledge base in the next scheduled sync." + ) + else: + result["message"] = ( + f"{result['message']}. Your knowledge base will be updated in the next scheduled sync." + ) + logger.warning( + f"KB update failed for page {final_page_id}: {kb_result['message']}" + ) + + return result + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error updating Notion page: {e}", exc_info=True) + error_str = str(e).lower() + if isinstance(e, NotionAPIError) and ( + "401" in error_str or "unauthorized" in error_str + ): + return { + "status": "auth_error", + "message": str(e), + "connector_id": connector_id_from_context + if "connector_id_from_context" in dir() + else None, + "connector_type": "notion", + } + if isinstance(e, ValueError | NotionAPIError): + message = str(e) + else: + message = ( + "Something went wrong while updating the page. Please try again." + ) + return {"status": "error", "message": message} + + return update_notion_page diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/agent.py new file mode 100644 index 000000000..551388d34 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/agent.py @@ -0,0 +1,55 @@ +"""`onedrive` route: ``SubAgent`` spec for deepagents.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from deepagents import SubAgent +from langchain_core.language_models import BaseChatModel + +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, + middleware_gated_interrupt_on, +) +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( + pack_subagent, +) + +from .tools.index import load_tools + +NAME = "onedrive" + + +def build_subagent( + *, + dependencies: dict[str, Any], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, + extra_tools_bucket: ToolsPermissions | None = None, +) -> SubAgent: + buckets = load_tools(dependencies=dependencies) + merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket) + tools = [ + row["tool"] + for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) + if row.get("tool") is not None + ] + interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket) + description = read_md_file(__package__, "description").strip() + if not description: + description = "Handles onedrive tasks for this workspace." + system_prompt = read_md_file(__package__, "system_prompt").strip() + return pack_subagent( + name=NAME, + description=description, + system_prompt=system_prompt, + tools=tools, + interrupt_on=interrupt_on, + model=model, + extra_middleware=extra_middleware, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/description.md new file mode 100644 index 000000000..31ea14624 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/description.md @@ -0,0 +1 @@ +Use for OneDrive file storage tasks: browse folders, read files, and manage OneDrive file content. diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/system_prompt.md new file mode 100644 index 000000000..a2f3617ba --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/system_prompt.md @@ -0,0 +1,52 @@ +You are the Microsoft OneDrive operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute OneDrive file create/delete actions accurately in the connected account. + + + +- `create_onedrive_file` +- `delete_onedrive_file` + + + +- Use only tools in ``. +- Ensure file identity/path is explicit before mutate actions. +- If ambiguous, return `status=blocked` with candidate paths and supervisor next step. +- Never invent IDs/paths or mutation results. + + + +- Do not perform non-OneDrive tasks. + + + +- Never claim file mutation success without tool confirmation. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On ambiguous targets, return `status=blocked` with candidate paths. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "file_id": string | null, + "file_path": string | null, + "operation": "create" | "delete" | null, + "matched_candidates": string[] | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/__init__.py new file mode 100644 index 000000000..8edb4857e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/__init__.py @@ -0,0 +1,11 @@ +from app.agents.new_chat.tools.onedrive.create_file import ( + create_create_onedrive_file_tool, +) +from app.agents.new_chat.tools.onedrive.trash_file import ( + create_delete_onedrive_file_tool, +) + +__all__ = [ + "create_create_onedrive_file_tool", + "create_delete_onedrive_file_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/create_file.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/create_file.py new file mode 100644 index 000000000..21272e01d --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/create_file.py @@ -0,0 +1,252 @@ +import logging +import os +import tempfile +from pathlib import Path +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.onedrive.client import OneDriveClient +from app.db import SearchSourceConnector, SearchSourceConnectorType + +logger = logging.getLogger(__name__) + +DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + + +def _ensure_docx_extension(name: str) -> str: + """Strip any existing extension and append .docx.""" + stem = Path(name).stem + return f"{stem}.docx" + + +def _markdown_to_docx(markdown_text: str) -> bytes: + """Convert a markdown string to DOCX bytes using pypandoc.""" + import pypandoc + + fd, tmp_path = tempfile.mkstemp(suffix=".docx") + os.close(fd) + try: + pypandoc.convert_text( + markdown_text, + "docx", + format="gfm", + extra_args=["--standalone"], + outputfile=tmp_path, + ) + with open(tmp_path, "rb") as f: + return f.read() + finally: + os.unlink(tmp_path) + + +def create_create_onedrive_file_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def create_onedrive_file( + name: str, + content: str | None = None, + ) -> dict[str, Any]: + """Create a new Word document (.docx) in Microsoft OneDrive. + + Use this tool when the user explicitly asks to create a new document + in OneDrive. The user MUST specify a topic before you call this tool. + + The file is always saved as a .docx Word document. Provide content as + markdown and it will be automatically converted to a formatted Word file. + + Args: + name: The document title (without extension). Extension will be set to .docx automatically. + content: Optional initial content as markdown. Will be converted to a formatted Word document. + + Returns: + Dictionary with status, file_id, name, web_url, and message. + """ + logger.info(f"create_onedrive_file called: name='{name}'") + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "OneDrive tool not properly configured.", + } + + try: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.ONEDRIVE_CONNECTOR, + ) + ) + connectors = result.scalars().all() + + if not connectors: + return { + "status": "error", + "message": "No OneDrive connector found. Please connect OneDrive in your workspace settings.", + } + + accounts = [] + for c in connectors: + cfg = c.config or {} + accounts.append( + { + "id": c.id, + "name": c.name, + "user_email": cfg.get("user_email"), + "auth_expired": cfg.get("auth_expired", False), + } + ) + + if all(a.get("auth_expired") for a in accounts): + return { + "status": "auth_error", + "message": "All connected OneDrive accounts need re-authentication.", + "connector_type": "onedrive", + } + + parent_folders: dict[int, list[dict[str, str]]] = {} + for acc in accounts: + cid = acc["id"] + if acc.get("auth_expired"): + parent_folders[cid] = [] + continue + try: + client = OneDriveClient(session=db_session, connector_id=cid) + items, err = await client.list_children("root") + if err: + logger.warning( + "Failed to list folders for connector %s: %s", cid, err + ) + parent_folders[cid] = [] + else: + parent_folders[cid] = [ + {"folder_id": item["id"], "name": item["name"]} + for item in items + if item.get("folder") is not None + and item.get("id") + and item.get("name") + ] + except Exception: + logger.warning( + "Error fetching folders for connector %s", cid, exc_info=True + ) + parent_folders[cid] = [] + + context: dict[str, Any] = { + "accounts": accounts, + "parent_folders": parent_folders, + } + + result = request_approval( + action_type="onedrive_file_creation", + tool_name="create_onedrive_file", + params={ + "name": name, + "content": content, + "connector_id": None, + "parent_folder_id": None, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_name = result.params.get("name", name) + final_content = result.params.get("content", content) + final_connector_id = result.params.get("connector_id") + final_parent_folder_id = result.params.get("parent_folder_id") + + if not final_name or not final_name.strip(): + return {"status": "error", "message": "File name cannot be empty."} + + final_name = _ensure_docx_extension(final_name) + + if final_connector_id is not None: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.ONEDRIVE_CONNECTOR, + ) + ) + connector = result.scalars().first() + else: + connector = connectors[0] + + if not connector: + return { + "status": "error", + "message": "Selected OneDrive connector is invalid.", + } + + docx_bytes = _markdown_to_docx(final_content or "") + + client = OneDriveClient(session=db_session, connector_id=connector.id) + created = await client.create_file( + name=final_name, + parent_id=final_parent_folder_id, + content=docx_bytes, + mime_type=DOCX_MIME, + ) + + logger.info( + f"OneDrive file created: id={created.get('id')}, name={created.get('name')}" + ) + + kb_message_suffix = "" + try: + from app.services.onedrive import OneDriveKBSyncService + + kb_service = OneDriveKBSyncService(db_session) + kb_result = await kb_service.sync_after_create( + file_id=created.get("id"), + file_name=created.get("name", final_name), + mime_type=DOCX_MIME, + web_url=created.get("webUrl"), + content=final_content, + connector_id=connector.id, + search_space_id=search_space_id, + user_id=user_id, + ) + if kb_result["status"] == "success": + kb_message_suffix = " Your knowledge base has also been updated." + else: + kb_message_suffix = " This file will be added to your knowledge base in the next scheduled sync." + except Exception as kb_err: + logger.warning(f"KB sync after create failed: {kb_err}") + kb_message_suffix = " This file will be added to your knowledge base in the next scheduled sync." + + return { + "status": "success", + "file_id": created.get("id"), + "name": created.get("name"), + "web_url": created.get("webUrl"), + "message": f"Successfully created '{created.get('name')}' in OneDrive.{kb_message_suffix}", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error(f"Error creating OneDrive file: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while creating the file. Please try again.", + } + + return create_onedrive_file diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/index.py new file mode 100644 index 000000000..9a2dadd36 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/index.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) + +from .create_file import create_create_onedrive_file_tool +from .trash_file import create_delete_onedrive_file_tool + + +def load_tools( + *, dependencies: dict[str, Any] | None = None, **kwargs: Any +) -> ToolsPermissions: + d = {**(dependencies or {}), **kwargs} + common = { + "db_session": d["db_session"], + "search_space_id": d["search_space_id"], + "user_id": d["user_id"], + } + create = create_create_onedrive_file_tool(**common) + delete = create_delete_onedrive_file_tool(**common) + return { + "allow": [], + "ask": [ + {"name": getattr(create, "name", "") or "", "tool": create}, + {"name": getattr(delete, "name", "") or "", "tool": delete}, + ], + } diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/trash_file.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/trash_file.py new file mode 100644 index 000000000..a7f13b5df --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/trash_file.py @@ -0,0 +1,281 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy import String, and_, cast, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.onedrive.client import OneDriveClient +from app.db import ( + Document, + DocumentType, + SearchSourceConnector, + SearchSourceConnectorType, +) + +logger = logging.getLogger(__name__) + + +def create_delete_onedrive_file_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def delete_onedrive_file( + file_name: str, + delete_from_kb: bool = False, + ) -> dict[str, Any]: + """Move a OneDrive file to the recycle bin. + + Use this tool when the user explicitly asks to delete, remove, or trash + a file in OneDrive. + + Args: + file_name: The exact name of the file to trash. + delete_from_kb: Whether to also remove the file from the knowledge base. + Default is False. + Set to True to remove from both OneDrive and knowledge base. + + Returns: + Dictionary with: + - status: "success", "rejected", "not_found", or "error" + - file_id: OneDrive file ID (if success) + - deleted_from_kb: whether the document was removed from the knowledge base + - message: Result message + + IMPORTANT: + - If status is "rejected", the user explicitly declined. Respond with a brief + acknowledgment and do NOT retry or suggest alternatives. + - If status is "not_found", relay the exact message to the user and ask them + to verify the file name or check if it has been indexed. + """ + logger.info( + f"delete_onedrive_file called: file_name='{file_name}', delete_from_kb={delete_from_kb}" + ) + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "OneDrive tool not properly configured.", + } + + try: + doc_result = await db_session.execute( + select(Document) + .join( + SearchSourceConnector, + Document.connector_id == SearchSourceConnector.id, + ) + .filter( + and_( + Document.search_space_id == search_space_id, + Document.document_type == DocumentType.ONEDRIVE_FILE, + func.lower(Document.title) == func.lower(file_name), + SearchSourceConnector.user_id == user_id, + ) + ) + .order_by(Document.updated_at.desc().nullslast()) + .limit(1) + ) + document = doc_result.scalars().first() + + if not document: + doc_result = await db_session.execute( + select(Document) + .join( + SearchSourceConnector, + Document.connector_id == SearchSourceConnector.id, + ) + .filter( + and_( + Document.search_space_id == search_space_id, + Document.document_type == DocumentType.ONEDRIVE_FILE, + func.lower( + cast( + Document.document_metadata["onedrive_file_name"], + String, + ) + ) + == func.lower(file_name), + SearchSourceConnector.user_id == user_id, + ) + ) + .order_by(Document.updated_at.desc().nullslast()) + .limit(1) + ) + document = doc_result.scalars().first() + + if not document: + return { + "status": "not_found", + "message": ( + f"File '{file_name}' not found in your indexed OneDrive files. " + "This could mean: (1) the file doesn't exist, (2) it hasn't been indexed yet, " + "or (3) the file name is different." + ), + } + + if not document.connector_id: + return { + "status": "error", + "message": "Document has no associated connector.", + } + + meta = document.document_metadata or {} + file_id = meta.get("onedrive_file_id") + document_id = document.id + + if not file_id: + return { + "status": "error", + "message": "File ID is missing. Please re-index the file.", + } + + conn_result = await db_session.execute( + select(SearchSourceConnector).filter( + and_( + SearchSourceConnector.id == document.connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.ONEDRIVE_CONNECTOR, + ) + ) + ) + connector = conn_result.scalars().first() + if not connector: + return { + "status": "error", + "message": "OneDrive connector not found or access denied.", + } + + cfg = connector.config or {} + if cfg.get("auth_expired"): + return { + "status": "auth_error", + "message": "OneDrive account needs re-authentication. Please re-authenticate in your connector settings.", + "connector_type": "onedrive", + } + + context = { + "file": { + "file_id": file_id, + "name": file_name, + "document_id": document_id, + "web_url": meta.get("web_url"), + }, + "account": { + "id": connector.id, + "name": connector.name, + "user_email": cfg.get("user_email"), + }, + } + + result = request_approval( + action_type="onedrive_file_trash", + tool_name="delete_onedrive_file", + params={ + "file_id": file_id, + "connector_id": connector.id, + "delete_from_kb": delete_from_kb, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_file_id = result.params.get("file_id", file_id) + final_connector_id = result.params.get("connector_id", connector.id) + final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb) + + if final_connector_id != connector.id: + result = await db_session.execute( + select(SearchSourceConnector).filter( + and_( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.ONEDRIVE_CONNECTOR, + ) + ) + ) + validated_connector = result.scalars().first() + if not validated_connector: + return { + "status": "error", + "message": "Selected OneDrive connector is invalid or has been disconnected.", + } + actual_connector_id = validated_connector.id + else: + actual_connector_id = connector.id + + logger.info( + f"Deleting OneDrive file: file_id='{final_file_id}', connector={actual_connector_id}" + ) + + client = OneDriveClient( + session=db_session, connector_id=actual_connector_id + ) + await client.trash_file(final_file_id) + + logger.info( + f"OneDrive file deleted (moved to recycle bin): file_id={final_file_id}" + ) + + trash_result: dict[str, Any] = { + "status": "success", + "file_id": final_file_id, + "message": f"Successfully moved '{file_name}' to the recycle bin.", + } + + deleted_from_kb = False + if final_delete_from_kb and document_id: + try: + doc_result = await db_session.execute( + select(Document).filter(Document.id == document_id) + ) + doc = doc_result.scalars().first() + if doc: + await db_session.delete(doc) + await db_session.commit() + deleted_from_kb = True + logger.info( + f"Deleted document {document_id} from knowledge base" + ) + else: + logger.warning(f"Document {document_id} not found in KB") + except Exception as e: + logger.error(f"Failed to delete document from KB: {e}") + await db_session.rollback() + trash_result["warning"] = ( + f"File moved to recycle bin, but failed to remove from knowledge base: {e!s}" + ) + + trash_result["deleted_from_kb"] = deleted_from_kb + if deleted_from_kb: + trash_result["message"] = ( + f"{trash_result.get('message', '')} (also removed from knowledge base)" + ) + + return trash_result + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error(f"Error deleting OneDrive file: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while trashing the file. Please try again.", + } + + return delete_onedrive_file diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/agent.py new file mode 100644 index 000000000..b72f82dab --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/agent.py @@ -0,0 +1,55 @@ +"""`slack` route: ``SubAgent`` spec for deepagents.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from deepagents import SubAgent +from langchain_core.language_models import BaseChatModel + +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, + middleware_gated_interrupt_on, +) +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( + pack_subagent, +) + +from .tools.index import load_tools + +NAME = "slack" + + +def build_subagent( + *, + dependencies: dict[str, Any], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, + extra_tools_bucket: ToolsPermissions | None = None, +) -> SubAgent: + buckets = load_tools(dependencies=dependencies) + merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket) + tools = [ + row["tool"] + for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) + if row.get("tool") is not None + ] + interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket) + description = read_md_file(__package__, "description").strip() + if not description: + description = "Handles slack tasks for this workspace." + system_prompt = read_md_file(__package__, "system_prompt").strip() + return pack_subagent( + name=NAME, + description=description, + system_prompt=system_prompt, + tools=tools, + interrupt_on=interrupt_on, + model=model, + extra_middleware=extra_middleware, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/description.md new file mode 100644 index 000000000..246f79dfe --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/description.md @@ -0,0 +1 @@ +Use for Slack channel communication: read channel/thread history, summarize conversations, and post replies. diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/system_prompt.md new file mode 100644 index 000000000..009a3205c --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/system_prompt.md @@ -0,0 +1,45 @@ +You are the Slack MCP operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute Slack MCP reads/actions accurately in the connected workspace. + + + +- Runtime-provided Slack MCP tools for search, channel/thread reads, and related actions. + + + +- Use only runtime-provided MCP tools and their documented arguments. +- If channel/thread target is ambiguous, return `status=blocked` with candidate options. +- Never invent message content, sender identity, timestamps, or delivery outcomes. + + + +- Do not execute non-Slack tasks. + + + +- Never claim send/read success without tool evidence. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On unresolved channel/thread ambiguity, return `status=blocked` with candidates. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { "items": object | null }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/tools/__init__.py new file mode 100644 index 000000000..f60078771 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/tools/__init__.py @@ -0,0 +1,3 @@ +"""Slack route: native tool factories are empty; MCP supplies tools when configured.""" + +__all__: list[str] = [] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/tools/index.py new file mode 100644 index 000000000..08b0e005e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/tools/index.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) + + +def load_tools( + *, dependencies: dict[str, Any] | None = None, **kwargs: Any +) -> ToolsPermissions: + _ = {**(dependencies or {}), **kwargs} + return {"allow": [], "ask": []} diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/agent.py new file mode 100644 index 000000000..aa6f34935 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/agent.py @@ -0,0 +1,55 @@ +"""`teams` route: ``SubAgent`` spec for deepagents.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from deepagents import SubAgent +from langchain_core.language_models import BaseChatModel + +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, + middleware_gated_interrupt_on, +) +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( + pack_subagent, +) + +from .tools.index import load_tools + +NAME = "teams" + + +def build_subagent( + *, + dependencies: dict[str, Any], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, + extra_tools_bucket: ToolsPermissions | None = None, +) -> SubAgent: + buckets = load_tools(dependencies=dependencies) + merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket) + tools = [ + row["tool"] + for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) + if row.get("tool") is not None + ] + interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket) + description = read_md_file(__package__, "description").strip() + if not description: + description = "Handles teams tasks for this workspace." + system_prompt = read_md_file(__package__, "system_prompt").strip() + return pack_subagent( + name=NAME, + description=description, + system_prompt=system_prompt, + tools=tools, + interrupt_on=interrupt_on, + model=model, + extra_middleware=extra_middleware, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/description.md new file mode 100644 index 000000000..4fc1579b2 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/description.md @@ -0,0 +1 @@ +Use for Microsoft Teams communication: read channel/thread messages, gather context, and post replies. diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/system_prompt.md new file mode 100644 index 000000000..8c0eebdd1 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/system_prompt.md @@ -0,0 +1,55 @@ +You are the Microsoft Teams operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute Teams channel discovery, message reads, and sends accurately. + + + +- `list_teams_channels` +- `read_teams_messages` +- `send_teams_message` + + + +- Use only tools in ``. +- Resolve team/channel targets before read/send operations. +- If ambiguous, return `status=blocked` with candidate channels and `next_step`. +- Never invent message content, sender identity, timestamps, or delivery outcomes. + + + +- Do not perform non-Teams tasks. + + + +- Never claim send success without tool confirmation. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On unresolved destination ambiguity, return `status=blocked` with candidates. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "team_id": string | null, + "channel_id": string | null, + "message_id": string | null, + "matched_candidates": [ + { "team_id": string | null, "channel_id": string, "label": string | null } + ] | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/__init__.py new file mode 100644 index 000000000..60e2add49 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/__init__.py @@ -0,0 +1,15 @@ +from app.agents.new_chat.tools.teams.list_channels import ( + create_list_teams_channels_tool, +) +from app.agents.new_chat.tools.teams.read_messages import ( + create_read_teams_messages_tool, +) +from app.agents.new_chat.tools.teams.send_message import ( + create_send_teams_message_tool, +) + +__all__ = [ + "create_list_teams_channels_tool", + "create_read_teams_messages_tool", + "create_send_teams_message_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/_auth.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/_auth.py new file mode 100644 index 000000000..7cdbeb819 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/_auth.py @@ -0,0 +1,38 @@ +"""Builds Microsoft Graph auth headers for Teams connector tools.""" + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.db import SearchSourceConnector, SearchSourceConnectorType + +GRAPH_API = "https://graph.microsoft.com/v1.0" + + +async def get_teams_connector( + db_session: AsyncSession, + search_space_id: int, + user_id: str, +) -> SearchSourceConnector | None: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.TEAMS_CONNECTOR, + ) + ) + return result.scalars().first() + + +async def get_access_token( + db_session: AsyncSession, + connector: SearchSourceConnector, +) -> str: + """Get a valid Microsoft Graph access token, refreshing if expired.""" + from app.connectors.teams_connector import TeamsConnector + + tc = TeamsConnector( + session=db_session, + connector_id=connector.id, + ) + return await tc._get_valid_token() diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/index.py new file mode 100644 index 000000000..cbe76b040 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/index.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) + +from .list_channels import create_list_teams_channels_tool +from .read_messages import create_read_teams_messages_tool +from .send_message import create_send_teams_message_tool + + +def load_tools( + *, dependencies: dict[str, Any] | None = None, **kwargs: Any +) -> ToolsPermissions: + d = {**(dependencies or {}), **kwargs} + common = { + "db_session": d["db_session"], + "search_space_id": d["search_space_id"], + "user_id": d["user_id"], + } + list_ch = create_list_teams_channels_tool(**common) + read_msg = create_read_teams_messages_tool(**common) + send = create_send_teams_message_tool(**common) + return { + "allow": [ + {"name": getattr(list_ch, "name", "") or "", "tool": list_ch}, + {"name": getattr(read_msg, "name", "") or "", "tool": read_msg}, + ], + "ask": [{"name": getattr(send, "name", "") or "", "tool": send}], + } diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/list_channels.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/list_channels.py new file mode 100644 index 000000000..d7b000853 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/list_channels.py @@ -0,0 +1,92 @@ +import logging +from typing import Any + +import httpx +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from ._auth import GRAPH_API, get_access_token, get_teams_connector + +logger = logging.getLogger(__name__) + + +def create_list_teams_channels_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def list_teams_channels() -> dict[str, Any]: + """List all Microsoft Teams and their channels the user has access to. + + Returns: + Dictionary with status and a list of teams, each containing + team_id, team_name, and a list of channels (id, name). + """ + if db_session is None or search_space_id is None or user_id is None: + return {"status": "error", "message": "Teams tool not properly configured."} + + try: + connector = await get_teams_connector(db_session, search_space_id, user_id) + if not connector: + return {"status": "error", "message": "No Teams connector found."} + + token = await get_access_token(db_session, connector) + headers = {"Authorization": f"Bearer {token}"} + + async with httpx.AsyncClient(timeout=20.0) as client: + teams_resp = await client.get( + f"{GRAPH_API}/me/joinedTeams", headers=headers + ) + + if teams_resp.status_code == 401: + return { + "status": "auth_error", + "message": "Teams token expired. Please re-authenticate.", + "connector_type": "teams", + } + if teams_resp.status_code != 200: + return { + "status": "error", + "message": f"Graph API error: {teams_resp.status_code}", + } + + teams_data = teams_resp.json().get("value", []) + result_teams = [] + + async with httpx.AsyncClient(timeout=20.0) as client: + for team in teams_data: + team_id = team["id"] + ch_resp = await client.get( + f"{GRAPH_API}/teams/{team_id}/channels", + headers=headers, + ) + channels = [] + if ch_resp.status_code == 200: + channels = [ + {"id": ch["id"], "name": ch.get("displayName", "")} + for ch in ch_resp.json().get("value", []) + ] + result_teams.append( + { + "team_id": team_id, + "team_name": team.get("displayName", ""), + "channels": channels, + } + ) + + return { + "status": "success", + "teams": result_teams, + "total_teams": len(result_teams), + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error("Error listing Teams channels: %s", e, exc_info=True) + return {"status": "error", "message": "Failed to list Teams channels."} + + return list_teams_channels diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/read_messages.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/read_messages.py new file mode 100644 index 000000000..d24a7e4d3 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/read_messages.py @@ -0,0 +1,103 @@ +import logging +from typing import Any + +import httpx +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from ._auth import GRAPH_API, get_access_token, get_teams_connector + +logger = logging.getLogger(__name__) + + +def create_read_teams_messages_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def read_teams_messages( + team_id: str, + channel_id: str, + limit: int = 25, + ) -> dict[str, Any]: + """Read recent messages from a Microsoft Teams channel. + + Args: + team_id: The team ID (from list_teams_channels). + channel_id: The channel ID (from list_teams_channels). + limit: Number of messages to fetch (default 25, max 50). + + Returns: + Dictionary with status and a list of messages including + id, sender, content, timestamp. + """ + if db_session is None or search_space_id is None or user_id is None: + return {"status": "error", "message": "Teams tool not properly configured."} + + limit = min(limit, 50) + + try: + connector = await get_teams_connector(db_session, search_space_id, user_id) + if not connector: + return {"status": "error", "message": "No Teams connector found."} + + token = await get_access_token(db_session, connector) + + async with httpx.AsyncClient(timeout=20.0) as client: + resp = await client.get( + f"{GRAPH_API}/teams/{team_id}/channels/{channel_id}/messages", + headers={"Authorization": f"Bearer {token}"}, + params={"$top": limit}, + ) + + if resp.status_code == 401: + return { + "status": "auth_error", + "message": "Teams token expired. Please re-authenticate.", + "connector_type": "teams", + } + if resp.status_code == 403: + return { + "status": "error", + "message": "Insufficient permissions to read this channel.", + } + if resp.status_code != 200: + return { + "status": "error", + "message": f"Graph API error: {resp.status_code}", + } + + raw_msgs = resp.json().get("value", []) + messages = [] + for m in raw_msgs: + sender = m.get("from", {}) + user_info = sender.get("user", {}) if sender else {} + body = m.get("body", {}) + messages.append( + { + "id": m.get("id"), + "sender": user_info.get("displayName", "Unknown"), + "content": body.get("content", ""), + "content_type": body.get("contentType", "text"), + "timestamp": m.get("createdDateTime", ""), + } + ) + + return { + "status": "success", + "team_id": team_id, + "channel_id": channel_id, + "messages": messages, + "total": len(messages), + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error("Error reading Teams messages: %s", e, exc_info=True) + return {"status": "error", "message": "Failed to read Teams messages."} + + return read_teams_messages diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/send_message.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/send_message.py new file mode 100644 index 000000000..fd8d00870 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/send_message.py @@ -0,0 +1,115 @@ +import logging +from typing import Any + +import httpx +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval + +from ._auth import GRAPH_API, get_access_token, get_teams_connector + +logger = logging.getLogger(__name__) + + +def create_send_teams_message_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def send_teams_message( + team_id: str, + channel_id: str, + content: str, + ) -> dict[str, Any]: + """Send a message to a Microsoft Teams channel. + + Requires the ChannelMessage.Send OAuth scope. If the user gets a + permission error, they may need to re-authenticate with updated scopes. + + Args: + team_id: The team ID (from list_teams_channels). + channel_id: The channel ID (from list_teams_channels). + content: The message text (HTML supported). + + Returns: + Dictionary with status, message_id on success. + + IMPORTANT: + - If status is "rejected", the user explicitly declined. Do NOT retry. + """ + if db_session is None or search_space_id is None or user_id is None: + return {"status": "error", "message": "Teams tool not properly configured."} + + try: + connector = await get_teams_connector(db_session, search_space_id, user_id) + if not connector: + return {"status": "error", "message": "No Teams connector found."} + + result = request_approval( + action_type="teams_send_message", + tool_name="send_teams_message", + params={ + "team_id": team_id, + "channel_id": channel_id, + "content": content, + }, + context={"connector_id": connector.id}, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Message was not sent.", + } + + final_content = result.params.get("content", content) + final_team = result.params.get("team_id", team_id) + final_channel = result.params.get("channel_id", channel_id) + + token = await get_access_token(db_session, connector) + + async with httpx.AsyncClient(timeout=20.0) as client: + resp = await client.post( + f"{GRAPH_API}/teams/{final_team}/channels/{final_channel}/messages", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + json={"body": {"content": final_content}}, + ) + + if resp.status_code == 401: + return { + "status": "auth_error", + "message": "Teams token expired. Please re-authenticate.", + "connector_type": "teams", + } + if resp.status_code == 403: + return { + "status": "insufficient_permissions", + "message": "Missing ChannelMessage.Send permission. Please re-authenticate with updated scopes.", + } + if resp.status_code not in (200, 201): + return { + "status": "error", + "message": f"Graph API error: {resp.status_code} — {resp.text[:200]}", + } + + msg_data = resp.json() + return { + "status": "success", + "message_id": msg_data.get("id"), + "message": "Message sent to Teams channel.", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error("Error sending Teams message: %s", e, exc_info=True) + return {"status": "error", "message": "Failed to send Teams message."} + + return send_teams_message diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/__init__.py new file mode 100644 index 000000000..c8714cd04 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/__init__.py @@ -0,0 +1,20 @@ +"""Load MCP tools, partition by connector agent, apply allow/ask name rules.""" + +from __future__ import annotations + +from app.agents.multi_agent_chat.subagents.mcp_tools.permissions import ( + TOOLS_PERMISSIONS_BY_AGENT, +) + +from .index import ( + fetch_mcp_connector_metadata_maps, + load_mcp_tools_by_connector, + partition_mcp_tools_by_connector, +) + +__all__ = [ + "TOOLS_PERMISSIONS_BY_AGENT", + "fetch_mcp_connector_metadata_maps", + "load_mcp_tools_by_connector", + "partition_mcp_tools_by_connector", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/index.py new file mode 100644 index 000000000..79ab3db10 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/index.py @@ -0,0 +1,165 @@ +"""Discover MCP tools, bucket by connector agent, apply allow/ask from policy.""" + +from __future__ import annotations + +import logging +from collections import defaultdict +from collections.abc import Sequence +from typing import Any + +from langchain_core.tools import BaseTool +from sqlalchemy import cast, select +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.multi_agent_chat.constants import ( + CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS, +) +from app.agents.multi_agent_chat.subagents.mcp_tools.permissions import ( + TOOLS_PERMISSIONS_BY_AGENT, +) +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolPermissionItem, + ToolsPermissions, + mcp_tool_permission_row, +) +from app.agents.new_chat.tools.mcp_tool import load_mcp_tools +from app.db import SearchSourceConnector + +logger = logging.getLogger(__name__) + + +## Helper functions for fetching connector metadata maps + + +async def fetch_mcp_connector_metadata_maps( + session: AsyncSession, + search_space_id: int, +) -> tuple[dict[int, str], dict[str, str]]: + """Resolve connector id and display name to connector type for MCP tool routing.""" + result = await session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + cast(SearchSourceConnector.config, JSONB).has_key("server_config"), + ), + ) + id_to_type: dict[int, str] = {} + name_to_type: dict[str, str] = {} + for connector in result.scalars(): + ct = ( + connector.connector_type.value + if hasattr(connector.connector_type, "value") + else str(connector.connector_type) + ) + id_to_type[connector.id] = ct + if connector.name: + name_to_type[connector.name] = ct + return id_to_type, name_to_type + + +## Helper functions for partitioning tools by connector agent + + +def partition_mcp_tools_by_connector( + tools: Sequence[BaseTool], + connector_id_to_type: dict[int, str], + connector_name_to_type: dict[str, str], +) -> dict[str, list[BaseTool]]: + """Assign each MCP tool to one connector-agent bucket from connector metadata.""" + buckets: dict[str, list[BaseTool]] = defaultdict(list) + + for tool in tools: + meta: dict[str, Any] = getattr(tool, "metadata", None) or {} + connector_type: str | None = None + + cid = meta.get("mcp_connector_id") + if cid is not None: + try: + cid_int = int(cid) + except (TypeError, ValueError): + cid_int = None + if cid_int is not None: + connector_type = connector_id_to_type.get(cid_int) + + if connector_type is None and meta.get("mcp_transport") == "stdio": + cname = meta.get("mcp_connector_name") + if cname: + connector_type = connector_name_to_type.get(str(cname)) + + if connector_type is None: + logger.debug( + "Skipping MCP tool %r — could not resolve connector type from metadata", + getattr(tool, "name", None), + ) + continue + + connector_agent = CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS.get(connector_type) + if connector_agent is None: + logger.warning( + "MCP tool %r has unmapped connector type %s — skipped", + getattr(tool, "name", None), + connector_type, + ) + continue + + buckets[connector_agent].append(tool) + + return dict(buckets) + + +## Helper functions for splitting tools by permissions + + +def _get_mcp_tool_name(tool: BaseTool) -> str: + meta: dict[str, Any] = getattr(tool, "metadata", None) or {} + orig = meta.get("mcp_original_tool_name") + if isinstance(orig, str) and orig: + return orig + return getattr(tool, "name", "") or "" + + +def _split_tools_by_permissions( + tools: Sequence[BaseTool], + perms: ToolsPermissions, +) -> ToolsPermissions: + allow_names = frozenset(r["name"] for r in perms["allow"]) + ask_names = frozenset(r["name"] for r in perms["ask"]) + allow: list[ToolPermissionItem] = [] + ask: list[ToolPermissionItem] = [] + for t in tools: + meta: dict[str, Any] = getattr(t, "metadata", None) or {} + if meta.get("hitl") is False: + allow.append(mcp_tool_permission_row(t)) + continue + key = _get_mcp_tool_name(t) + if key in allow_names: + allow.append(mcp_tool_permission_row(t)) + elif key in ask_names: + ask.append(mcp_tool_permission_row(t)) + else: + ask.append(mcp_tool_permission_row(t)) + return {"allow": allow, "ask": ask} + + +## Main function to load MCP tools and split them by permissions for each connector agent + + +async def load_mcp_tools_by_connector( + session: AsyncSession, + search_space_id: int, +) -> dict[str, ToolsPermissions]: + """Load MCP tools and split rows using ``TOOLS_PERMISSIONS_BY_AGENT`` name sets. + + Pass ``bypass_internal_hitl=True`` so the subagent's + ``HumanInTheLoopMiddleware`` is the single HITL gate. + """ + flat = await load_mcp_tools(session, search_space_id, bypass_internal_hitl=True) + id_map, name_map = await fetch_mcp_connector_metadata_maps(session, search_space_id) + buckets = partition_mcp_tools_by_connector(flat, id_map, name_map) + return { + agent: _split_tools_by_permissions( + tools, + TOOLS_PERMISSIONS_BY_AGENT.get(agent, {"allow": [], "ask": []}), + ) + for agent, tools in buckets.items() + } diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/__init__.py new file mode 100644 index 000000000..f24dedcf2 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/__init__.py @@ -0,0 +1,23 @@ +"""Bundled MCP allow/ask name rows per connector agent (MCP-backed routes only).""" + +from __future__ import annotations + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) + +from .airtable import TOOLS_PERMISSIONS as _AIRTABLE +from .clickup import TOOLS_PERMISSIONS as _CLICKUP +from .jira import TOOLS_PERMISSIONS as _JIRA +from .linear import TOOLS_PERMISSIONS as _LINEAR +from .slack import TOOLS_PERMISSIONS as _SLACK + +TOOLS_PERMISSIONS_BY_AGENT: dict[str, ToolsPermissions] = { + "airtable": _AIRTABLE, + "clickup": _CLICKUP, + "jira": _JIRA, + "linear": _LINEAR, + "slack": _SLACK, +} + +__all__ = ["TOOLS_PERMISSIONS_BY_AGENT"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/airtable.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/airtable.py new file mode 100644 index 000000000..d2d426ef2 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/airtable.py @@ -0,0 +1,16 @@ +"""Airtable MCP: which server tool names are allow vs ask.""" + +from __future__ import annotations + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) + +TOOLS_PERMISSIONS: ToolsPermissions = { + "allow": [ + {"name": "list_bases"}, + {"name": "list_tables_for_base"}, + {"name": "list_records_for_table"}, + ], + "ask": [], +} diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/clickup.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/clickup.py new file mode 100644 index 000000000..9ddec5fe8 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/clickup.py @@ -0,0 +1,15 @@ +"""ClickUp MCP: which server tool names are allow vs ask.""" + +from __future__ import annotations + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) + +TOOLS_PERMISSIONS: ToolsPermissions = { + "allow": [ + {"name": "clickup_search"}, + {"name": "clickup_get_task"}, + ], + "ask": [], +} diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/index.py new file mode 100644 index 000000000..10781c9d9 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/index.py @@ -0,0 +1,10 @@ +"""Re-exports permission row types for MCP policy modules.""" + +from __future__ import annotations + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolPermissionItem, + ToolsPermissions, +) + +__all__ = ["ToolPermissionItem", "ToolsPermissions"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/jira.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/jira.py new file mode 100644 index 000000000..5a67c9dc1 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/jira.py @@ -0,0 +1,20 @@ +"""Jira MCP: which server tool names are allow vs ask.""" + +from __future__ import annotations + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) + +TOOLS_PERMISSIONS: ToolsPermissions = { + "allow": [ + {"name": "getAccessibleAtlassianResources"}, + {"name": "searchJiraIssuesUsingJql"}, + {"name": "getVisibleJiraProjects"}, + {"name": "getJiraProjectIssueTypesMetadata"}, + ], + "ask": [ + {"name": "createJiraIssue"}, + {"name": "editJiraIssue"}, + ], +} diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/linear.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/linear.py new file mode 100644 index 000000000..18fd827dc --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/linear.py @@ -0,0 +1,32 @@ +"""Linear MCP: which server tool names are allow vs ask.""" + +from __future__ import annotations + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) + +_TOOLS_ALLOW = ( + "list_issues", + "get_issue", + "list_my_issues", + "list_issue_statuses", + "list_issue_labels", + "list_comments", + "list_users", + "get_user", + "list_teams", + "get_team", + "list_projects", + "get_project", + "list_project_labels", + "list_cycles", + "list_documents", + "get_document", + "search_documentation", +) + +TOOLS_PERMISSIONS: ToolsPermissions = { + "allow": [{"name": n} for n in _TOOLS_ALLOW], + "ask": [{"name": "save_issue"}], +} diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/slack.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/slack.py new file mode 100644 index 000000000..f9c9d3635 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/slack.py @@ -0,0 +1,16 @@ +"""Slack MCP: which server tool names are allow vs ask.""" + +from __future__ import annotations + +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) + +TOOLS_PERMISSIONS: ToolsPermissions = { + "allow": [ + {"name": "slack_search_channels"}, + {"name": "slack_read_channel"}, + {"name": "slack_read_thread"}, + ], + "ask": [], +} diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/registry.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/registry.py new file mode 100644 index 000000000..1b7a19ad7 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/registry.py @@ -0,0 +1,196 @@ +"""Central registry of route ``build_subagent`` callables (keyed by ``NAME``).""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any, Protocol + +from deepagents import SubAgent +from langchain_core.language_models import BaseChatModel + +from app.agents.multi_agent_chat.constants import ( + SUBAGENT_TO_REQUIRED_CONNECTOR_MAP, +) +from app.agents.multi_agent_chat.subagents.builtins.deliverables.agent import ( + build_subagent as build_deliverables_subagent, +) +from app.agents.multi_agent_chat.subagents.builtins.memory.agent import ( + build_subagent as build_memory_subagent, +) +from app.agents.multi_agent_chat.subagents.builtins.research.agent import ( + build_subagent as build_research_subagent, +) +from app.agents.multi_agent_chat.subagents.connectors.airtable.agent import ( + build_subagent as build_airtable_subagent, +) +from app.agents.multi_agent_chat.subagents.connectors.calendar.agent import ( + build_subagent as build_calendar_subagent, +) +from app.agents.multi_agent_chat.subagents.connectors.clickup.agent import ( + build_subagent as build_clickup_subagent, +) +from app.agents.multi_agent_chat.subagents.connectors.confluence.agent import ( + build_subagent as build_confluence_subagent, +) +from app.agents.multi_agent_chat.subagents.connectors.discord.agent import ( + build_subagent as build_discord_subagent, +) +from app.agents.multi_agent_chat.subagents.connectors.dropbox.agent import ( + build_subagent as build_dropbox_subagent, +) +from app.agents.multi_agent_chat.subagents.connectors.gmail.agent import ( + build_subagent as build_gmail_subagent, +) +from app.agents.multi_agent_chat.subagents.connectors.google_drive.agent import ( + build_subagent as build_google_drive_subagent, +) +from app.agents.multi_agent_chat.subagents.connectors.jira.agent import ( + build_subagent as build_jira_subagent, +) +from app.agents.multi_agent_chat.subagents.connectors.linear.agent import ( + build_subagent as build_linear_subagent, +) +from app.agents.multi_agent_chat.subagents.connectors.luma.agent import ( + build_subagent as build_luma_subagent, +) +from app.agents.multi_agent_chat.subagents.connectors.notion.agent import ( + build_subagent as build_notion_subagent, +) +from app.agents.multi_agent_chat.subagents.connectors.onedrive.agent import ( + build_subagent as build_onedrive_subagent, +) +from app.agents.multi_agent_chat.subagents.connectors.slack.agent import ( + build_subagent as build_slack_subagent, +) +from app.agents.multi_agent_chat.subagents.connectors.teams.agent import ( + build_subagent as build_teams_subagent, +) +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolsPermissions, +) + + +class SubagentBuilder(Protocol): + def __call__( + self, + *, + dependencies: dict[str, Any], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, + extra_tools_bucket: ToolsPermissions | None = None, + ) -> SubAgent: ... + + +SUBAGENT_BUILDERS_BY_NAME: dict[str, SubagentBuilder] = { + "airtable": build_airtable_subagent, + "calendar": build_calendar_subagent, + "clickup": build_clickup_subagent, + "confluence": build_confluence_subagent, + "deliverables": build_deliverables_subagent, + "discord": build_discord_subagent, + "dropbox": build_dropbox_subagent, + "gmail": build_gmail_subagent, + "google_drive": build_google_drive_subagent, + "jira": build_jira_subagent, + "linear": build_linear_subagent, + "luma": build_luma_subagent, + "memory": build_memory_subagent, + "notion": build_notion_subagent, + "onedrive": build_onedrive_subagent, + "research": build_research_subagent, + "slack": build_slack_subagent, + "teams": build_teams_subagent, +} + + +def _route_resource_package(builder: SubagentBuilder) -> str: + mod = builder.__module__ + return mod[: -len(".agent")] if mod.endswith(".agent") else mod.rsplit(".", 1)[0] + + +def main_prompt_registry_subagent_lines(exclude: list[str]) -> list[tuple[str, str]]: + """(name, description) for registry specialists included for **task** (same rules as ``build_subagents``).""" + banned = frozenset(("memory", "research")) | frozenset(exclude) + rows: list[tuple[str, str]] = [] + for name in sorted(SUBAGENT_BUILDERS_BY_NAME): + if name in banned: + continue + builder = SUBAGENT_BUILDERS_BY_NAME[name] + pkg = _route_resource_package(builder) + blurb = read_md_file(pkg, "description").strip() + if not blurb: + blurb = name.replace("_", " ").title() + rows.append((name, blurb)) + return rows + + +def get_subagents_to_exclude( + available_connectors: list[str] | None, +) -> list[str]: + if available_connectors is None: + return [] + available_tokens = frozenset(available_connectors) + excluded_names: set[str] = set() + for builder_name in SUBAGENT_BUILDERS_BY_NAME: + required_tokens = SUBAGENT_TO_REQUIRED_CONNECTOR_MAP.get(builder_name) + if required_tokens is None: + excluded_names.add(builder_name) + continue + if not required_tokens: + continue + if not (required_tokens & available_tokens): + excluded_names.add(builder_name) + return sorted(excluded_names) + + +def _filter_disabled_tools_in_place( + spec: SubAgent, + disabled_names: frozenset[str], +) -> None: + """Drop UI-disabled tools from ``spec["tools"]`` and ``spec["interrupt_on"]``.""" + if not disabled_names: + return + tools = spec.get("tools") # type: ignore[typeddict-item] + if isinstance(tools, list): + spec["tools"] = [ # type: ignore[typeddict-unknown-key] + t for t in tools if getattr(t, "name", None) not in disabled_names + ] + interrupt_on = spec.get("interrupt_on") # type: ignore[typeddict-item] + if isinstance(interrupt_on, dict): + spec["interrupt_on"] = { # type: ignore[typeddict-unknown-key] + k: v for k, v in interrupt_on.items() if k not in disabled_names + } + + +def build_subagents( + *, + dependencies: dict[str, Any], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, + mcp_tools_by_agent: dict[str, ToolsPermissions] | None = None, + exclude: list[str] | None = None, + disabled_tools: list[str] | None = None, +) -> list[SubAgent]: + """Build registry subagents; skip memory/research; skip names in exclude.""" + mcp = mcp_tools_by_agent or {} + specs: list[SubAgent] = [] + excluded = ["memory", "research"] + if exclude: + excluded.extend(exclude) + disabled_names = frozenset(disabled_tools or ()) + for name in sorted(SUBAGENT_BUILDERS_BY_NAME): + if name in excluded: + continue + builder = SUBAGENT_BUILDERS_BY_NAME[name] + spec = builder( + dependencies=dependencies, + model=model, + extra_middleware=extra_middleware, + extra_tools_bucket=mcp.get(name), + ) + _filter_disabled_tools_in_place(spec, disabled_names) + specs.append(spec) + return specs diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/__init__.py new file mode 100644 index 000000000..12443da88 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/__init__.py @@ -0,0 +1,25 @@ +"""Cross-slice helpers for route subagents.""" + +from __future__ import annotations + +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_chat.subagents.shared.permissions import ( + ToolPermissionItem, + ToolsPermissions, + merge_tools_permissions, + tool_permission_row, +) +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( + pack_subagent, +) + +__all__ = [ + "ToolPermissionItem", + "ToolsPermissions", + "merge_tools_permissions", + "pack_subagent", + "read_md_file", + "tool_permission_row", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/md_file_reader.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/md_file_reader.py new file mode 100644 index 000000000..2fce413a6 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/md_file_reader.py @@ -0,0 +1,14 @@ +"""Load markdown files shipped alongside a route package.""" + +from __future__ import annotations + +from importlib import resources + + +def read_md_file(package: str, stem: str) -> str: + """Load ``{stem}.md`` from ``package`` via importlib resources, or return empty.""" + ref = resources.files(package).joinpath(f"{stem}.md") + if not ref.is_file(): + return "" + text = ref.read_text(encoding="utf-8") + return text.rstrip("\n") diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/permissions.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/permissions.py new file mode 100644 index 000000000..649478485 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/permissions.py @@ -0,0 +1,60 @@ +"""Typed tool-permission rows: allow vs ask (``name`` + optional ``tool``).""" + +from __future__ import annotations + +from typing import Literal, NotRequired, TypedDict + +from langchain_core.tools import BaseTool + +# ``native`` rows self-gate via ``request_approval`` in the tool body; +# ``mcp`` rows are gated by ``HumanInTheLoopMiddleware`` via ``interrupt_on``. +ToolKind = Literal["native", "mcp"] + + +class ToolPermissionItem(TypedDict): + """``name`` is always set; ``tool`` is present when a bound tool exists; ``kind`` defaults to ``native`` when absent.""" + + name: str + tool: NotRequired[BaseTool] + kind: NotRequired[ToolKind] + + +class ToolsPermissions(TypedDict): + """Same shape for native factories and MCP name-only policy rows.""" + + allow: list[ToolPermissionItem] + ask: list[ToolPermissionItem] + + +def tool_permission_row(tool: BaseTool) -> ToolPermissionItem: + """Build one allow/ask row for a loaded tool.""" + return {"name": getattr(tool, "name", "") or "", "tool": tool} + + +def mcp_tool_permission_row(tool: BaseTool) -> ToolPermissionItem: + """Build one allow/ask row tagged ``kind="mcp"`` so it routes through ``HumanInTheLoopMiddleware``.""" + return {"name": getattr(tool, "name", "") or "", "tool": tool, "kind": "mcp"} + + +def merge_tools_permissions( + base: ToolsPermissions, + extra: ToolsPermissions | None, +) -> ToolsPermissions: + """Concatenate allow/ask lists (e.g. native factory + MCP bucket) before building HITL maps.""" + if not extra: + return base + return { + "allow": [*base["allow"], *extra["allow"]], + "ask": [*base["ask"], *extra["ask"]], + } + + +def middleware_gated_interrupt_on( + bucket: ToolsPermissions, +) -> dict[str, bool]: + """``interrupt_on`` for ``ask`` rows whose bodies don't self-gate via ``request_approval``.""" + return { + r["name"]: True + for r in bucket["ask"] + if r.get("name") and r.get("kind") == "mcp" + } diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/subagent_builder.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/subagent_builder.py new file mode 100644 index 000000000..b6614afa9 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/subagent_builder.py @@ -0,0 +1,47 @@ +"""Build delegated sub-agent specs from route-local pieces.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any, cast + +from deepagents import SubAgent +from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +from app.agents.new_chat.middleware import DedupHITLToolCallsMiddleware + + +def pack_subagent( + *, + name: str, + description: str, + system_prompt: str, + tools: list[BaseTool], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, + interrupt_on: dict[str, bool] | None = None, +) -> SubAgent: + """Pack the route-local pieces passed in into one sub-agent spec.""" + if not system_prompt.strip(): + msg = f"Subagent {name!r}: system_prompt is empty" + raise ValueError(msg) + + middleware: list[Any] = [ + *(extra_middleware or []), + PatchToolCallsMiddleware(), + DedupHITLToolCallsMiddleware(agent_tools=tools), + ] + spec: dict[str, Any] = { + "name": name, + "description": description, + "system_prompt": system_prompt, + "tools": tools, + "middleware": middleware, + } + if model is not None: + spec["model"] = model + if interrupt_on: + spec["interrupt_on"] = interrupt_on + return cast(SubAgent, spec) diff --git a/surfsense_backend/app/agents/new_chat/chat_deepagent.py b/surfsense_backend/app/agents/new_chat/chat_deepagent.py index 36739adae..605c31416 100644 --- a/surfsense_backend/app/agents/new_chat/chat_deepagent.py +++ b/surfsense_backend/app/agents/new_chat/chat_deepagent.py @@ -31,7 +31,6 @@ from langchain.agents import create_agent from langchain.agents.middleware import ( LLMToolSelectorMiddleware, ModelCallLimitMiddleware, - ModelFallbackMiddleware, TodoListMiddleware, ToolCallLimitMiddleware, ) @@ -77,6 +76,9 @@ from app.agents.new_chat.middleware import ( create_surfsense_compaction_middleware, default_skills_sources, ) +from app.agents.new_chat.middleware.scoped_model_fallback import ( + ScopedModelFallbackMiddleware, +) from app.agents.new_chat.permissions import Rule, Ruleset from app.agents.new_chat.plugin_loader import ( PluginContext, @@ -723,6 +725,10 @@ def _build_compiled_agent_blocking( model=llm, extra_middleware=subagent_extra_middleware, ) + logging.info( + "Specialized subagents registered for task tool: %s", + [s["name"] for s in specialized_subagents], + ) except Exception as exc: # pragma: no cover - defensive logging.warning( "Specialized subagent build failed; running without them: %s", @@ -788,15 +794,15 @@ def _build_compiled_agent_blocking( # Fallback chain — primary is the agent's own model; we add cheap # alternatives. Off by default; only the first call site that # configures the chain via env should enable it. - fallback_mw: ModelFallbackMiddleware | None = None + fallback_mw: ScopedModelFallbackMiddleware | None = None if flags.enable_model_fallback and not flags.disable_new_agent_stack: try: - fallback_mw = ModelFallbackMiddleware( + fallback_mw = ScopedModelFallbackMiddleware( "openai:gpt-4o-mini", "anthropic:claude-3-5-haiku-20241022", ) except Exception: - logging.warning("ModelFallbackMiddleware init failed; skipping.") + logging.warning("ScopedModelFallbackMiddleware init failed; skipping.") fallback_mw = None model_call_limit_mw = ( ModelCallLimitMiddleware( diff --git a/surfsense_backend/app/agents/new_chat/feature_flags.py b/surfsense_backend/app/agents/new_chat/feature_flags.py index 1f5a08ec6..b3dc0fa82 100644 --- a/surfsense_backend/app/agents/new_chat/feature_flags.py +++ b/surfsense_backend/app/agents/new_chat/feature_flags.py @@ -250,24 +250,19 @@ class AgentFeatureFlags: ) -# Module-level cache. Read once at import time so the values are consistent -# across the process lifetime. Use ``reload_for_tests`` to reset in tests. -_FLAGS: AgentFeatureFlags | None = None - - def get_flags() -> AgentFeatureFlags: - """Return the resolved feature-flag state, caching on first call.""" - global _FLAGS - if _FLAGS is None: - _FLAGS = AgentFeatureFlags.from_env() - return _FLAGS + """Return the resolved feature-flag state from the **current** process environment. + + Intentionally **not** cached: ``load_dotenv`` and operator edits to env vars + must affect the next agent build without requiring a full process restart. + Cost is negligible (reads ``os.environ`` once per call). + """ + return AgentFeatureFlags.from_env() def reload_for_tests() -> AgentFeatureFlags: - """Force a fresh read from env. Tests should call this after monkeypatching env.""" - global _FLAGS - _FLAGS = AgentFeatureFlags.from_env() - return _FLAGS + """Compatibility helper for tests; equivalent to :func:`get_flags`.""" + return AgentFeatureFlags.from_env() __all__ = [ diff --git a/surfsense_backend/app/agents/new_chat/memory_extraction.py b/surfsense_backend/app/agents/new_chat/memory_extraction.py index 221c4c75a..e31774a7c 100644 --- a/surfsense_backend/app/agents/new_chat/memory_extraction.py +++ b/surfsense_backend/app/agents/new_chat/memory_extraction.py @@ -16,6 +16,7 @@ from sqlalchemy import select from app.agents.new_chat.tools.update_memory import _save_memory from app.db import SearchSpace, User, shielded_async_session +from app.utils.content_utils import extract_text_content logger = logging.getLogger(__name__) @@ -144,11 +145,7 @@ async def extract_and_save_memory( [HumanMessage(content=prompt)], config={"tags": ["surfsense:internal", "memory-extraction"]}, ) - text = ( - response.content - if isinstance(response.content, str) - else str(response.content) - ).strip() + text = extract_text_content(response.content).strip() if text == "NO_UPDATE" or not text: logger.debug("Memory extraction: no update needed (user %s)", uid) @@ -207,11 +204,7 @@ async def extract_and_save_team_memory( [HumanMessage(content=prompt)], config={"tags": ["surfsense:internal", "team-memory-extraction"]}, ) - text = ( - response.content - if isinstance(response.content, str) - else str(response.content) - ).strip() + text = extract_text_content(response.content).strip() if text == "NO_UPDATE" or not text: logger.debug( diff --git a/surfsense_backend/app/agents/new_chat/middleware/busy_mutex.py b/surfsense_backend/app/agents/new_chat/middleware/busy_mutex.py index 06a27bc96..e7d9b8f75 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/busy_mutex.py +++ b/surfsense_backend/app/agents/new_chat/middleware/busy_mutex.py @@ -134,6 +134,23 @@ class _ThreadLockManager: lock.release() self.reset(thread_id) + def release(self, thread_id: str) -> bool: + """Force-release the per-thread lock; safety-net for turns that end before ``__end__``. + + ``BusyMutexMiddleware.aafter_agent`` only releases on graph completion, so + an ``interrupt()`` pause or an early streaming bail-out would otherwise + leak the lock and block the next request with :class:`BusyError`. Returns + ``True`` when a held lock was released, ``False`` otherwise. + """ + lock = self._locks.get(thread_id) + if lock is None or not lock.locked(): + return False + try: + lock.release() + except RuntimeError: + return False + return True + # Module-level singleton — process-local but reused across all agent # instances built in this process. Subagents created in nested diff --git a/surfsense_backend/app/agents/new_chat/middleware/dedup_tool_calls.py b/surfsense_backend/app/agents/new_chat/middleware/dedup_tool_calls.py index c55347284..a6d2ce310 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/dedup_tool_calls.py +++ b/surfsense_backend/app/agents/new_chat/middleware/dedup_tool_calls.py @@ -21,6 +21,7 @@ A tool with no resolver from either path simply opts out of dedup. from __future__ import annotations +import json import logging from collections.abc import Callable from typing import Any @@ -57,6 +58,19 @@ def wrap_dedup_key_by_arg_name(arg_name: str) -> DedupResolver: return _resolver +def dedup_key_full_args(args: dict[str, Any]) -> str: + """Resolver that collapses calls only when **every** argument is identical. + + Safe default for tools where no single field uniquely identifies a call + (e.g. MCP tools whose first required field is a shared workspace id). + """ + + try: + return json.dumps(args, sort_keys=True, default=str) + except (TypeError, ValueError): + return repr(sorted(args.items())) if isinstance(args, dict) else repr(args) + + # Backwards-compatible alias for code that imported the original # private name. New callers should use :func:`wrap_dedup_key_by_arg_name`. _wrap_string_key = wrap_dedup_key_by_arg_name diff --git a/surfsense_backend/app/agents/new_chat/middleware/permission.py b/surfsense_backend/app/agents/new_chat/middleware/permission.py index 37719e96a..5ea7f1740 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/permission.py +++ b/surfsense_backend/app/agents/new_chat/middleware/permission.py @@ -19,8 +19,9 @@ Operation: the results: ``deny`` > ``ask`` > ``allow``. 3. On ``deny``: replaces the call with a synthetic ``ToolMessage`` containing a :class:`StreamingError`. -4. On ``ask``: raises a SurfSense-style ``interrupt(...)``. The reply - shape is ``{"decision_type": "once|always|reject", "feedback"?: str}``. +4. On ``ask``: raises a SurfSense-style ``interrupt(...)``. Both the legacy + SurfSense shape and LangChain HITL ``{"decisions": [{"type": ...}]}`` + replies are accepted via :func:`_normalize_permission_decision`. - ``once``: proceed. - ``always``: also persist allow rules for ``request.always`` patterns. - ``reject`` w/o feedback: raise :class:`RejectedError`. @@ -81,6 +82,75 @@ def _default_pattern_resolver(name: str) -> PatternResolver: return _resolve +# Translation from the LangChain HITL envelope (what ``stream_resume_chat`` +# sends) to SurfSense's legacy ``decision_type`` shape. ``edit`` keeps the +# original tool args — tools needing argument edits should use +# ``request_approval`` from ``app/agents/new_chat/tools/hitl.py``. +_LC_TYPE_TO_PERMISSION_DECISION: dict[str, str] = { + "approve": "once", + "reject": "reject", + "edit": "once", +} + + +def _normalize_permission_decision(decision: Any) -> dict[str, Any]: + """Coerce any accepted reply shape into ``{"decision_type": ..., "feedback"?}``. + + Falls back to ``reject`` (with a warning) on unrecognized payloads so the + middleware fails closed. + """ + if isinstance(decision, str): + return {"decision_type": decision} + if not isinstance(decision, dict): + logger.warning( + "Unrecognized permission resume value (%s); treating as reject", + type(decision).__name__, + ) + return {"decision_type": "reject"} + + if decision.get("decision_type"): + return decision + + payload: dict[str, Any] = decision + decisions = decision.get("decisions") + if isinstance(decisions, list) and decisions: + first = decisions[0] + if isinstance(first, dict): + payload = first + + raw_type = payload.get("type") or payload.get("decision_type") + if not raw_type: + logger.warning( + "Permission resume missing decision type (keys=%s); treating as reject", + list(payload.keys()), + ) + return {"decision_type": "reject"} + + raw_type = str(raw_type).lower() + mapped = _LC_TYPE_TO_PERMISSION_DECISION.get(raw_type) + if mapped is None: + # Tolerate legacy values arriving without ``decision_type`` wrapping. + if raw_type in {"once", "always", "reject"}: + mapped = raw_type + else: + logger.warning( + "Unknown permission decision type %r; treating as reject", raw_type + ) + mapped = "reject" + + if raw_type == "edit": + logger.warning( + "Permission middleware received an 'edit' decision; original args " + "kept (edits not merged here)." + ) + + out: dict[str, Any] = {"decision_type": mapped} + feedback = payload.get("feedback") or payload.get("message") + if isinstance(feedback, str) and feedback.strip(): + out["feedback"] = feedback + return out + + class PermissionMiddleware(AgentMiddleware): # type: ignore[type-arg] """Allow/deny/ask layer over the agent's tool calls. @@ -214,12 +284,7 @@ class PermissionMiddleware(AgentMiddleware): # type: ignore[type-arg] ot.interrupt_span(interrupt_type="permission_ask"), ): decision = interrupt(payload) - if isinstance(decision, dict): - return decision - # Tolerate a plain string reply ("once", "always", "reject") - if isinstance(decision, str): - return {"decision_type": decision} - return {"decision_type": "reject"} + return _normalize_permission_decision(decision) def _persist_always(self, tool_name: str, patterns: list[str]) -> None: """Promote ``always`` reply into runtime allow rules. @@ -355,4 +420,5 @@ class PermissionMiddleware(AgentMiddleware): # type: ignore[type-arg] __all__ = [ "PatternResolver", "PermissionMiddleware", + "_normalize_permission_decision", ] diff --git a/surfsense_backend/app/agents/new_chat/middleware/scoped_model_fallback.py b/surfsense_backend/app/agents/new_chat/middleware/scoped_model_fallback.py new file mode 100644 index 000000000..99eb2d74a --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/middleware/scoped_model_fallback.py @@ -0,0 +1,91 @@ +"""Fallback only on provider/network errors; let programming bugs raise.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from langchain.agents.middleware import ModelFallbackMiddleware + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from langchain.agents.middleware.types import ModelRequest, ModelResponse + from langchain_core.messages import AIMessage + + +# Matched by class name across the MRO so we don't have to import every +# provider SDK (openai/anthropic/google/...). Extend as new providers ship. +_FALLBACK_ELIGIBLE_NAMES: frozenset[str] = frozenset( + { + "RateLimitError", + "APIStatusError", + "InternalServerError", + "ServiceUnavailableError", + "BadGatewayError", + "GatewayTimeoutError", + "APIConnectionError", + "APITimeoutError", + "ConnectError", + "ConnectTimeout", + "ReadTimeout", + "RemoteProtocolError", + "TimeoutError", + "TimeoutException", + } +) + + +def _is_fallback_eligible(exc: BaseException) -> bool: + return any(cls.__name__ in _FALLBACK_ELIGIBLE_NAMES for cls in type(exc).__mro__) + + +class ScopedModelFallbackMiddleware(ModelFallbackMiddleware): + """Re-raise non-provider exceptions instead of walking the fallback chain.""" + + def wrap_model_call( # type: ignore[override] + self, + request: ModelRequest[Any], + handler: Callable[[ModelRequest[Any]], ModelResponse[Any]], + ) -> ModelResponse[Any] | AIMessage: + last_exception: Exception + try: + return handler(request) + except Exception as e: + if not _is_fallback_eligible(e): + raise + last_exception = e + + for fallback_model in self.models: + try: + return handler(request.override(model=fallback_model)) + except Exception as e: + if not _is_fallback_eligible(e): + raise + last_exception = e + continue + + raise last_exception + + async def awrap_model_call( # type: ignore[override] + self, + request: ModelRequest[Any], + handler: Callable[[ModelRequest[Any]], Awaitable[ModelResponse[Any]]], + ) -> ModelResponse[Any] | AIMessage: + last_exception: Exception + try: + return await handler(request) + except Exception as e: + if not _is_fallback_eligible(e): + raise + last_exception = e + + for fallback_model in self.models: + try: + return await handler(request.override(model=fallback_model)) + except Exception as e: + if not _is_fallback_eligible(e): + raise + last_exception = e + continue + + raise last_exception diff --git a/surfsense_backend/app/agents/new_chat/prompts/base/tool_routing_private.md b/surfsense_backend/app/agents/new_chat/prompts/base/tool_routing_private.md index ec667bf88..b8bb069e2 100644 --- a/surfsense_backend/app/agents/new_chat/prompts/base/tool_routing_private.md +++ b/surfsense_backend/app/agents/new_chat/prompts/base/tool_routing_private.md @@ -5,12 +5,19 @@ say "I don't see it in the knowledge base" or ask the user if they want you to c Ignore any knowledge base results for these services. When to use which tool: -- Linear (issues) → list_issues, get_issue, save_issue (create/update) +- Linear (issues, teams, users, projects when MCP exposes them) → hosted Linear MCP read tools (e.g. `list_issues`, `get_issue`, `list_teams`, `list_users`, …) and `save_issue` for create/update; native SurfSense Linear issue tools when present. For **multi-step Linear-only** work (several reads, structured evidence), delegate with the `task` tool to subagent **`linear_specialist`** instead of mixing unrelated tools. - ClickUp (tasks) → clickup_search, clickup_get_task - Jira (issues) → getAccessibleAtlassianResources (cloudId discovery), getVisibleJiraProjects (project discovery), getJiraProjectIssueTypesMetadata (issue type discovery), searchJiraIssuesUsingJql, createJiraIssue, editJiraIssue -- Slack (messages, channels) → slack_search_channels, slack_read_channel, slack_read_thread +- Slack (messages, channels) → `slack_search_channels`, `slack_read_channel`, `slack_read_thread`, and other `slack_*` tools when connected. For **multi-step Slack-only** work, delegate with `task` to **`slack_specialist`**. - Airtable (bases, tables, records) → list_bases, list_tables_for_base, list_records_for_table - Knowledge base content (Notion, GitHub, files, notes) → automatically searched - Real-time public web data → call web_search - Reading a specific webpage → call scrape_webpage + +**`task` subagents (when to delegate):** +- **`linear_specialist`** — Linear-only investigations and tool use. +- **`slack_specialist`** — Slack-only investigations and tool use. +- **`connector_negotiator`** — **Cross-connector** chains (e.g. data from Slack then action in Linear). +- **`explore`** — Read-only KB + web research with citations. +- **`report_writer`** — Single `generate_report` deliverable.
diff --git a/surfsense_backend/app/agents/new_chat/prompts/base/tool_routing_team.md b/surfsense_backend/app/agents/new_chat/prompts/base/tool_routing_team.md index 48b7a990b..b081a2123 100644 --- a/surfsense_backend/app/agents/new_chat/prompts/base/tool_routing_team.md +++ b/surfsense_backend/app/agents/new_chat/prompts/base/tool_routing_team.md @@ -5,12 +5,19 @@ say "I don't see it in the knowledge base" or ask if they want you to check. Ignore any knowledge base results for these services. When to use which tool: -- Linear (issues) → list_issues, get_issue, save_issue (create/update) +- Linear (issues, teams, users, projects when MCP exposes them) → hosted Linear MCP read tools (e.g. `list_issues`, `get_issue`, `list_teams`, `list_users`, …) and `save_issue` for create/update; native SurfSense Linear issue tools when present. For **multi-step Linear-only** work (several reads, structured evidence), delegate with the `task` tool to subagent **`linear_specialist`** instead of mixing unrelated tools. - ClickUp (tasks) → clickup_search, clickup_get_task - Jira (issues) → getAccessibleAtlassianResources (cloudId discovery), getVisibleJiraProjects (project discovery), getJiraProjectIssueTypesMetadata (issue type discovery), searchJiraIssuesUsingJql, createJiraIssue, editJiraIssue -- Slack (messages, channels) → slack_search_channels, slack_read_channel, slack_read_thread +- Slack (messages, channels) → `slack_search_channels`, `slack_read_channel`, `slack_read_thread`, and other `slack_*` tools when connected. For **multi-step Slack-only** work, delegate with `task` to **`slack_specialist`**. - Airtable (bases, tables, records) → list_bases, list_tables_for_base, list_records_for_table - Knowledge base content (Notion, GitHub, files, notes) → automatically searched - Real-time public web data → call web_search - Reading a specific webpage → call scrape_webpage + +**`task` subagents (when to delegate):** +- **`linear_specialist`** — Linear-only investigations and tool use. +- **`slack_specialist`** — Slack-only investigations and tool use. +- **`connector_negotiator`** — **Cross-connector** chains (e.g. data from Slack then action in Linear). +- **`explore`** — Read-only KB + web research with citations. +- **`report_writer`** — Single `generate_report` deliverable.
diff --git a/surfsense_backend/app/agents/new_chat/prompts/routing/linear.md b/surfsense_backend/app/agents/new_chat/prompts/routing/linear.md index 8b1378917..2f1bfacd9 100644 --- a/surfsense_backend/app/agents/new_chat/prompts/routing/linear.md +++ b/surfsense_backend/app/agents/new_chat/prompts/routing/linear.md @@ -1 +1,3 @@ - + +**Linear:** Prefer the `task` tool with subagent **`linear_specialist`** when the user’s request is **only about Linear** and may need several tool calls (list issues, inspect one issue, teams, users, statuses, comments, documents). Use **`connector_negotiator`** when Linear is one hop in a **multi-connector** workflow. Call Linear MCP tools directly from the parent when a **single** quick call is enough. + diff --git a/surfsense_backend/app/agents/new_chat/prompts/routing/slack.md b/surfsense_backend/app/agents/new_chat/prompts/routing/slack.md index 8b1378917..4b5d07a9a 100644 --- a/surfsense_backend/app/agents/new_chat/prompts/routing/slack.md +++ b/surfsense_backend/app/agents/new_chat/prompts/routing/slack.md @@ -1 +1,3 @@ - + +**Slack:** Prefer `task` with **`slack_specialist`** for **Slack-only** multi-step work (channels, threads, reads, writes that need approval in the specialist). Use **`connector_negotiator`** when Slack feeds another connector in one chain. Use direct `slack_*` tools from the parent for a **single** quick read or write when appropriate. + diff --git a/surfsense_backend/app/agents/new_chat/subagents/__init__.py b/surfsense_backend/app/agents/new_chat/subagents/__init__.py index 7d678ec79..bd1823b57 100644 --- a/surfsense_backend/app/agents/new_chat/subagents/__init__.py +++ b/surfsense_backend/app/agents/new_chat/subagents/__init__.py @@ -20,10 +20,14 @@ from .config import ( build_report_writer_subagent, build_specialized_subagents, ) +from .providers.linear import build_linear_specialist_subagent +from .providers.slack import build_slack_specialist_subagent __all__ = [ "build_connector_negotiator_subagent", "build_explore_subagent", + "build_linear_specialist_subagent", "build_report_writer_subagent", + "build_slack_specialist_subagent", "build_specialized_subagents", ] diff --git a/surfsense_backend/app/agents/new_chat/subagents/config.py b/surfsense_backend/app/agents/new_chat/subagents/config.py index 84ca516e0..b993d2b06 100644 --- a/surfsense_backend/app/agents/new_chat/subagents/config.py +++ b/surfsense_backend/app/agents/new_chat/subagents/config.py @@ -22,6 +22,12 @@ from typing import TYPE_CHECKING, Any from app.agents.new_chat.middleware.skills_backends import default_skills_sources from app.agents.new_chat.permissions import Rule, Ruleset +from app.agents.new_chat.subagents.providers.linear import ( + build_linear_specialist_subagent, +) +from app.agents.new_chat.subagents.providers.slack import ( + build_slack_specialist_subagent, +) if TYPE_CHECKING: from deepagents import SubAgent @@ -421,6 +427,12 @@ def build_specialized_subagents( build_report_writer_subagent( tools=tools, model=model, extra_middleware=extra_middleware ), + build_linear_specialist_subagent( + tools=tools, model=model, extra_middleware=extra_middleware + ), + build_slack_specialist_subagent( + tools=tools, model=model, extra_middleware=extra_middleware + ), build_connector_negotiator_subagent( tools=tools, model=model, extra_middleware=extra_middleware ), diff --git a/surfsense_backend/app/agents/new_chat/subagents/constants.py b/surfsense_backend/app/agents/new_chat/subagents/constants.py new file mode 100644 index 000000000..cb1da499b --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/subagents/constants.py @@ -0,0 +1,35 @@ +"""Shared constants for provider subagent safety policies.""" + +from __future__ import annotations + +# Generic mutation-deny patterns for read-only specialist roles. +WRITE_TOOL_DENY_PATTERNS: tuple[str, ...] = ( + "*create*", + "*update*", + "*delete*", + "*send*", + "*write*", + "*edit*", + "*move*", + "*mkdir*", + "*upload*", + "edit_file", + "write_file", + "move_file", + "mkdir", + "update_memory", + "update_memory_team", + "update_memory_private", +) + +# Tools that mutate virtual KB filesystem or parent/global chat state. +# Provider specialists should not mutate these surfaces directly. +NON_PROVIDER_STATE_MUTATION_DENY: frozenset[str] = frozenset( + { + # Exact tool names from shared deny patterns. + *{name for name in WRITE_TOOL_DENY_PATTERNS if "*" not in name}, + # Additional non-provider state mutation controls. + "write_todos", + "task", + } +) diff --git a/surfsense_backend/app/agents/new_chat/subagents/providers/linear.py b/surfsense_backend/app/agents/new_chat/subagents/providers/linear.py new file mode 100644 index 000000000..da332fe28 --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/subagents/providers/linear.py @@ -0,0 +1,162 @@ +"""Linear provider specialist subagent. + +This file is intentionally standalone so provider specialists can be reviewed +and evolved independently (one provider per file). +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +from app.agents.new_chat.permissions import Rule, Ruleset +from app.agents.new_chat.subagents.constants import NON_PROVIDER_STATE_MUTATION_DENY +from app.services.mcp_oauth.registry import ( + LINEAR_MCP_READONLY_TOOL_NAMES, + linear_mcp_original_tool_name, +) + +if TYPE_CHECKING: + from deepagents import SubAgent + from langchain_core.language_models import BaseChatModel + from langchain_core.tools import BaseTool + + +# Read vs write Linear MCP tools are defined in +# ``app.services.mcp_oauth.registry`` (``LINEAR_MCP_READONLY_TOOL_NAMES`` / +# ``LINEAR_MCP_WRITE_TOOL_NAMES``). Any other Linear-domain tool requires approval. + +LINEAR_SYSTEM_PROMPT = """You are the linear_specialist subagent for SurfSense. + +Role: +- You are the Linear domain specialist. Handle Linear-only requests accurately. + +Primary objective: +- Resolve the user's Linear task and return a concise, auditable result. + +Routing boundary: +- Use this subagent for Linear-domain tasks (issues, status, assignees, labels, + teams, and project references). +- If the task is primarily non-Linear or cross-connector orchestration, return + status=needs_input and hand control back to the parent with the exact next hop. + +Execution steps: +1) Verify Linear access first (use get_connected_accounts if needed). +2) Prefer read/list tools first to gather current issue facts before concluding. +3) Track key identifiers in your reasoning: issue ID, issue key, team ID, label ID. +4) If required identifiers are missing, ask the parent for exactly what is missing. +5) Return a compact result with findings + evidence references. + +Output format: +- status: success | needs_input | blocked | error +- summary: one short paragraph +- evidence: bullet list of concrete IDs / issue keys used +- next_step: one sentence (only when blocked or needs_input) + +Constraints: +- Do not invent issue keys, IDs, or workflow state names. +- Mutating Linear operations are allowed only with explicit approval. +- If Linear connector access is unavailable, stop and return status=blocked. +""" + + +def _select_linear_tools(tools: Sequence[BaseTool]) -> list[BaseTool]: + """Keep Linear tools plus minimal shared read utilities.""" + allowed_exact = { + "get_connected_accounts", + "read_file", + "ls", + "glob", + "grep", + } + selected: list[BaseTool] = [] + for tool in tools: + if tool.name in allowed_exact: + selected.append(tool) + continue + if linear_mcp_original_tool_name(tool.name) is not None: + selected.append(tool) + continue + if tool.name.startswith("linear_") or tool.name.endswith("_linear_issue"): + selected.append(tool) + return selected + + +def _is_linear_readonly_tool_name(name: str) -> bool: + """Return True when a tool name maps to a read-only Linear MCP operation.""" + base = linear_mcp_original_tool_name(name) + return base is not None and base in LINEAR_MCP_READONLY_TOOL_NAMES + + +def _is_linear_domain_tool_name(name: str) -> bool: + """Return True for Linear-domain tools handled by this specialist.""" + if linear_mcp_original_tool_name(name) is not None: + return True + return name.startswith("linear_") or name.endswith("_linear_issue") + + +def _permission_middleware(*, selected_tools: Sequence[BaseTool]) -> Any: + """Permission policy for Linear specialist.""" + from app.agents.new_chat.middleware.permission import PermissionMiddleware + + ask_tools = sorted( + { + tool.name + for tool in selected_tools + if _is_linear_domain_tool_name(tool.name) + and not _is_linear_readonly_tool_name(tool.name) + } + ) + rules: list[Rule] = [Rule(permission="*", pattern="*", action="allow")] + rules.extend( + Rule(permission=name, pattern="*", action="deny") + for name in NON_PROVIDER_STATE_MUTATION_DENY + ) + rules.extend(Rule(permission=name, pattern="*", action="ask") for name in ask_tools) + return PermissionMiddleware( + rulesets=[Ruleset(rules=rules, origin="subagent_linear_specialist")] + ) + + +def _wrap_subagent_middleware( + *, + selected_tools: Sequence[BaseTool], + extra_middleware: Sequence[Any] | None, +) -> list[Any]: + """Apply standard middleware chain used by other subagents.""" + from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware + + from app.agents.new_chat.middleware import DedupHITLToolCallsMiddleware + + return [ + *(extra_middleware or []), + _permission_middleware(selected_tools=selected_tools), + PatchToolCallsMiddleware(), + DedupHITLToolCallsMiddleware(agent_tools=list(selected_tools)), + ] + + +def build_linear_specialist_subagent( + *, + tools: Sequence[BaseTool], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, +) -> SubAgent: + """Build the ``linear_specialist`` provider subagent spec.""" + selected_tools = _select_linear_tools(tools) + spec: dict[str, Any] = { + "name": "linear_specialist", + "description": ( + "Linear operations specialist for issue and workflow requests, " + "with strict evidence tracking and approval-gated mutating operations." + ), + "system_prompt": LINEAR_SYSTEM_PROMPT, + "tools": selected_tools, + "middleware": _wrap_subagent_middleware( + selected_tools=selected_tools, + extra_middleware=extra_middleware, + ), + } + if model is not None: + spec["model"] = model + return spec # type: ignore[return-value] diff --git a/surfsense_backend/app/agents/new_chat/subagents/providers/slack.py b/surfsense_backend/app/agents/new_chat/subagents/providers/slack.py new file mode 100644 index 000000000..90ca80152 --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/subagents/providers/slack.py @@ -0,0 +1,170 @@ +"""Slack provider specialist subagent. + +This file is intentionally standalone so provider specialists can be reviewed +and evolved independently (one provider per file). +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +from app.agents.new_chat.permissions import Rule, Ruleset +from app.agents.new_chat.subagents.constants import NON_PROVIDER_STATE_MUTATION_DENY + +if TYPE_CHECKING: + from deepagents import SubAgent + from langchain_core.language_models import BaseChatModel + from langchain_core.tools import BaseTool + + +# Official references: +# - https://docs.slack.dev/ai/slack-mcp-server +# - https://www.npmjs.com/package/@modelcontextprotocol/server-slack +# +# Policy: only known read-only Slack tools are auto-allowed. Any other +# ``slack_*`` tool is treated as mutating and requires explicit approval. +SLACK_READONLY_TOOL_NAMES: frozenset[str] = frozenset( + { + # Slack-hosted MCP read tools + "slack_search_channels", + "slack_read_channel", + "slack_read_thread", + "slack_read_canvas", + "slack_read_user_profile", + # modelcontextprotocol/server-slack read tools + "slack_list_channels", + "slack_get_channel_history", + "slack_get_thread_replies", + "slack_get_users", + "slack_get_user_profile", + } +) + +SLACK_SYSTEM_PROMPT = """You are the slack_specialist subagent for SurfSense. + +Role: +- You are the Slack domain specialist. Handle Slack-only requests accurately. + +Primary objective: +- Resolve the user's Slack task and return a concise, auditable result. + +Routing boundary: +- Use this subagent for Slack-domain tasks (channels, threads, users, messages, + and Slack canvases). +- If the task is primarily non-Slack or cross-connector orchestration, return + status=needs_input and hand control back to the parent with the exact next hop. + +Execution steps: +1) Verify Slack access first (use get_connected_accounts if needed). +2) Prefer read/list tools first to gather facts before concluding. +3) Track key identifiers in your reasoning: channel ID, message ts, thread ts, user ID. +4) If required identifiers are missing, ask the parent for exactly what is missing. +5) Return a compact result with findings + evidence references. + +Output format: +- status: success | needs_input | blocked | error +- summary: one short paragraph +- evidence: bullet list of concrete IDs / timestamps used +- next_step: one sentence (only when blocked or needs_input) + +Constraints: +- Do not invent Slack IDs, channels, users, or message content. +- Mutating Slack operations are allowed only with explicit approval. +- If Slack connector access is unavailable, stop and return status=blocked. +""" + + +def _select_slack_tools(tools: Sequence[BaseTool]) -> list[BaseTool]: + """Keep Slack tools plus minimal shared read utilities.""" + allowed_exact = { + "get_connected_accounts", + "read_file", + "ls", + "glob", + "grep", + } + slack_prefix = "slack_" + selected: list[BaseTool] = [] + for tool in tools: + if tool.name in allowed_exact: + selected.append(tool) + continue + if tool.name.startswith(slack_prefix): + selected.append(tool) + return selected + + +def _permission_middleware(*, selected_tools: Sequence[BaseTool]) -> Any: + """Permission policy for Slack specialist. + + Intent: + - Allow Slack-domain operations by default. + - Gate Slack mutating operations behind approval (`ask`). + - Hard-deny non-Slack state mutations, especially KB virtual filesystem + mutation and parent-context mutation tools. + """ + from app.agents.new_chat.middleware.permission import PermissionMiddleware + + ask_tools = sorted( + { + tool.name + for tool in selected_tools + if tool.name.startswith("slack_") + and tool.name not in SLACK_READONLY_TOOL_NAMES + } + ) + rules: list[Rule] = [Rule(permission="*", pattern="*", action="allow")] + rules.extend( + Rule(permission=name, pattern="*", action="deny") + for name in NON_PROVIDER_STATE_MUTATION_DENY + ) + rules.extend(Rule(permission=name, pattern="*", action="ask") for name in ask_tools) + return PermissionMiddleware( + rulesets=[Ruleset(rules=rules, origin="subagent_slack_specialist")] + ) + + +def _wrap_subagent_middleware( + *, + selected_tools: Sequence[BaseTool], + extra_middleware: Sequence[Any] | None, +) -> list[Any]: + """Apply standard middleware chain used by other subagents.""" + from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware + + from app.agents.new_chat.middleware import DedupHITLToolCallsMiddleware + + return [ + *(extra_middleware or []), + _permission_middleware(selected_tools=selected_tools), + PatchToolCallsMiddleware(), + DedupHITLToolCallsMiddleware(agent_tools=list(selected_tools)), + ] + + +def build_slack_specialist_subagent( + *, + tools: Sequence[BaseTool], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, +) -> SubAgent: + """Build the ``slack_specialist`` provider subagent spec.""" + selected_tools = _select_slack_tools(tools) + spec: dict[str, Any] = { + "name": "slack_specialist", + "description": ( + "Slack operations specialist for any Slack-domain request " + "(channels, threads, users, and messages), with strict evidence " + "tracking and approval-gated mutating operations." + ), + "system_prompt": SLACK_SYSTEM_PROMPT, + "tools": selected_tools, + "middleware": _wrap_subagent_middleware( + selected_tools=selected_tools, + extra_middleware=extra_middleware, + ), + } + if model is not None: + spec["model"] = model + return spec # type: ignore[return-value] diff --git a/surfsense_backend/app/agents/new_chat/system_prompt.py b/surfsense_backend/app/agents/new_chat/system_prompt.py index 56f838d7e..70634c65d 100644 --- a/surfsense_backend/app/agents/new_chat/system_prompt.py +++ b/surfsense_backend/app/agents/new_chat/system_prompt.py @@ -26,6 +26,9 @@ from .prompts.composer import ( detect_provider_variant, ) +# Optional routing fragments under ``prompts/routing/`` (see composer). +_DEFAULT_CONNECTOR_ROUTING: tuple[str, ...] = ("linear", "slack") + # Public re-exports for backwards compatibility (some legacy code reads the # raw default-instructions text directly). SURFSENSE_SYSTEM_INSTRUCTIONS_TEMPLATE = ( @@ -63,6 +66,7 @@ def build_surfsense_system_prompt( mcp_connector_tools=mcp_connector_tools, citations_enabled=True, model_name=model_name, + connector_routing=_DEFAULT_CONNECTOR_ROUTING, ) @@ -93,6 +97,7 @@ def build_configurable_system_prompt( use_default_system_instructions=use_default_system_instructions, citations_enabled=citations_enabled, model_name=model_name, + connector_routing=_DEFAULT_CONNECTOR_ROUTING, ) diff --git a/surfsense_backend/app/agents/new_chat/tools/mcp_tool.py b/surfsense_backend/app/agents/new_chat/tools/mcp_tool.py index 5b96ab374..92a808a5e 100644 --- a/surfsense_backend/app/agents/new_chat/tools/mcp_tool.py +++ b/surfsense_backend/app/agents/new_chat/tools/mcp_tool.py @@ -33,6 +33,7 @@ from sqlalchemy import cast, select from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncSession +from app.agents.new_chat.middleware.dedup_tool_calls import dedup_key_full_args from app.agents.new_chat.tools.hitl import request_approval from app.agents.new_chat.tools.mcp_client import MCPClient from app.db import SearchSourceConnector @@ -45,7 +46,10 @@ _MCP_CACHE_MAX_SIZE = 50 _MCP_DISCOVERY_TIMEOUT_SECONDS = 30 _TOOL_CALL_MAX_RETRIES = 3 _TOOL_CALL_RETRY_DELAY = 1.5 # seconds, doubles per attempt -_mcp_tools_cache: dict[int, tuple[float, list[StructuredTool]]] = {} +# Keyed by ``(search_space_id, bypass_internal_hitl)`` so single-agent and +# multi-agent paths cannot share tool closures with different HITL wiring. +_MCPCacheKey = tuple[int, bool] +_mcp_tools_cache: dict[_MCPCacheKey, tuple[float, list[StructuredTool]]] = {} def _evict_expired_mcp_cache() -> None: @@ -137,12 +141,13 @@ async def _create_mcp_tool_from_definition_stdio( connector_name: str = "", connector_id: int | None = None, trusted_tools: list[str] | None = None, + bypass_internal_hitl: bool = False, ) -> StructuredTool: """Create a LangChain tool from an MCP tool definition (stdio transport). - All MCP tools are unconditionally wrapped with HITL approval. - ``request_approval()`` is called OUTSIDE the try/except so that - ``GraphInterrupt`` propagates cleanly to LangGraph. + Set ``bypass_internal_hitl=True`` when an outer ``HumanInTheLoopMiddleware`` + already gates the tool, otherwise the body's ``request_approval()`` is the + sole HITL gate (single-agent path). """ tool_name = tool_def.get("name", "unnamed_tool") raw_description = tool_def.get("description", "No description provided") @@ -161,24 +166,29 @@ async def _create_mcp_tool_from_definition_stdio( """Execute the MCP tool call via the client with retry support.""" logger.debug("MCP tool '%s' called", tool_name) - # HITL — OUTSIDE try/except so GraphInterrupt propagates to LangGraph - hitl_result = request_approval( - action_type="mcp_tool_call", - tool_name=tool_name, - params=kwargs, - context={ - "mcp_server": connector_name, - "tool_description": raw_description, - "mcp_transport": "stdio", - "mcp_connector_id": connector_id, - }, - trusted_tools=trusted_tools, - ) - if hitl_result.rejected: - return "Tool call rejected by user." - call_kwargs = _unpack_synthetic_input_data( - {k: v for k, v in hitl_result.params.items() if v is not None} - ) + if bypass_internal_hitl: + call_kwargs = _unpack_synthetic_input_data( + {k: v for k, v in kwargs.items() if v is not None} + ) + else: + # Outside try/except so ``GraphInterrupt`` propagates to LangGraph. + hitl_result = request_approval( + action_type="mcp_tool_call", + tool_name=tool_name, + params=kwargs, + context={ + "mcp_server": connector_name, + "tool_description": raw_description, + "mcp_transport": "stdio", + "mcp_connector_id": connector_id, + }, + trusted_tools=trusted_tools, + ) + if hitl_result.rejected: + return "Tool call rejected by user." + call_kwargs = _unpack_synthetic_input_data( + {k: v for k, v in hitl_result.params.items() if v is not None} + ) last_error: Exception | None = None for attempt in range(_TOOL_CALL_MAX_RETRIES): @@ -221,7 +231,9 @@ async def _create_mcp_tool_from_definition_stdio( "mcp_connector_name": connector_name or None, "mcp_is_generic": True, "hitl": True, - "hitl_dedup_key": next(iter(input_schema.get("required", [])), None), + # Full-args hash: shared identifiers (cloudId, workspaceId, …) + # would otherwise collapse legitimate batches. + "dedup_key": dedup_key_full_args, }, ) @@ -240,11 +252,14 @@ async def _create_mcp_tool_from_definition_http( readonly_tools: frozenset[str] | None = None, tool_name_prefix: str | None = None, is_generic_mcp: bool = False, + bypass_internal_hitl: bool = False, ) -> StructuredTool: """Create a LangChain tool from an MCP tool definition (HTTP transport). Write tools are wrapped with HITL approval; read-only tools (listed in - ``readonly_tools``) execute immediately without user confirmation. + ``readonly_tools``) execute immediately without user confirmation. Set + ``bypass_internal_hitl=True`` when an outer ``HumanInTheLoopMiddleware`` + already gates the tool. When ``tool_name_prefix`` is set (multi-account disambiguation), the tool exposed to the LLM gets a prefixed name (e.g. ``linear_25_list_issues``) @@ -302,7 +317,7 @@ async def _create_mcp_tool_from_definition_http( """Execute the MCP tool call via HTTP transport.""" logger.debug("MCP HTTP tool '%s' called", exposed_name) - if is_readonly: + if is_readonly or bypass_internal_hitl: call_kwargs = _unpack_synthetic_input_data( {k: v for k, v in kwargs.items() if v is not None} ) @@ -385,7 +400,9 @@ async def _create_mcp_tool_from_definition_http( "mcp_connector_name": connector_name or None, "mcp_is_generic": is_generic_mcp, "hitl": not is_readonly, - "hitl_dedup_key": next(iter(input_schema.get("required", [])), None), + # Full-args hash: shared identifiers (cloudId, workspaceId, …) + # would otherwise collapse legitimate batches. + "dedup_key": dedup_key_full_args, "mcp_original_tool_name": original_tool_name, "mcp_connector_id": connector_id, }, @@ -400,6 +417,8 @@ async def _load_stdio_mcp_tools( connector_name: str, server_config: dict[str, Any], trusted_tools: list[str] | None = None, + *, + bypass_internal_hitl: bool = False, ) -> list[StructuredTool]: """Load tools from a stdio-based MCP server.""" tools: list[StructuredTool] = [] @@ -451,6 +470,7 @@ async def _load_stdio_mcp_tools( connector_name=connector_name, connector_id=connector_id, trusted_tools=trusted_tools, + bypass_internal_hitl=bypass_internal_hitl, ) tools.append(tool) except Exception as e: @@ -473,6 +493,8 @@ async def _load_http_mcp_tools( readonly_tools: frozenset[str] | None = None, tool_name_prefix: str | None = None, is_generic_mcp: bool = False, + *, + bypass_internal_hitl: bool = False, ) -> list[StructuredTool]: """Load tools from an HTTP-based MCP server. @@ -598,6 +620,7 @@ async def _load_http_mcp_tools( readonly_tools=readonly_tools, tool_name_prefix=tool_name_prefix, is_generic_mcp=is_generic_mcp, + bypass_internal_hitl=bypass_internal_hitl, ) tools.append(tool) except Exception as e: @@ -905,14 +928,10 @@ async def _mark_connector_auth_expired(connector_id: int) -> None: def invalidate_mcp_tools_cache(search_space_id: int | None = None) -> None: - """Invalidate cached MCP tools. - - Args: - search_space_id: If provided, only invalidate for this search space. - If None, invalidate all cached MCP tools. - """ + """Invalidate cached MCP tools (both ``bypass_internal_hitl`` variants together).""" if search_space_id is not None: - _mcp_tools_cache.pop(search_space_id, None) + for key in [k for k in _mcp_tools_cache if k[0] == search_space_id]: + _mcp_tools_cache.pop(key, None) else: _mcp_tools_cache.clear() @@ -920,27 +939,29 @@ def invalidate_mcp_tools_cache(search_space_id: int | None = None) -> None: async def load_mcp_tools( session: AsyncSession, search_space_id: int, + *, + bypass_internal_hitl: bool = False, ) -> list[StructuredTool]: - """Load all MCP tools from user's active MCP server connectors. + """Load all MCP tools from the user's active MCP server connectors. - This discovers tools dynamically from MCP servers using the protocol. - Supports both stdio (local process) and HTTP (remote server) transports. - - Results are cached per search space for up to 5 minutes to avoid - re-spawning MCP server processes on every chat message. + Results are cached per ``(search_space_id, bypass_internal_hitl)`` for up + to 5 minutes; bypass is keyed because each variant builds a different tool + closure (with vs. without the in-wrapper ``request_approval`` gate). """ _evict_expired_mcp_cache() now = time.monotonic() - cached = _mcp_tools_cache.get(search_space_id) + cache_key: _MCPCacheKey = (search_space_id, bypass_internal_hitl) + cached = _mcp_tools_cache.get(cache_key) if cached is not None: cached_at, cached_tools = cached if now - cached_at < _MCP_CACHE_TTL_SECONDS: logger.info( - "Using cached MCP tools for search space %s (%d tools, age=%.0fs)", + "Using cached MCP tools for search space %s (%d tools, age=%.0fs, bypass_hitl=%s)", search_space_id, len(cached_tools), now - cached_at, + bypass_internal_hitl, ) return list(cached_tools) @@ -1064,6 +1085,7 @@ async def load_mcp_tools( readonly_tools=task["readonly_tools"], tool_name_prefix=task["tool_name_prefix"], is_generic_mcp=task.get("is_generic_mcp", False), + bypass_internal_hitl=bypass_internal_hitl, ), timeout=_MCP_DISCOVERY_TIMEOUT_SECONDS, ) @@ -1074,6 +1096,7 @@ async def load_mcp_tools( task["connector_name"], task["server_config"], trusted_tools=task["trusted_tools"], + bypass_internal_hitl=bypass_internal_hitl, ), timeout=_MCP_DISCOVERY_TIMEOUT_SECONDS, ) @@ -1095,14 +1118,17 @@ async def load_mcp_tools( results = await asyncio.gather(*[_discover_one(t) for t in discovery_tasks]) tools: list[StructuredTool] = [tool for sublist in results for tool in sublist] - _mcp_tools_cache[search_space_id] = (now, tools) + _mcp_tools_cache[cache_key] = (now, tools) if len(_mcp_tools_cache) > _MCP_CACHE_MAX_SIZE: oldest_key = min(_mcp_tools_cache, key=lambda k: _mcp_tools_cache[k][0]) del _mcp_tools_cache[oldest_key] logger.info( - "Loaded %d MCP tools for search space %d", len(tools), search_space_id + "Loaded %d MCP tools for search space %d (bypass_hitl=%s)", + len(tools), + search_space_id, + bypass_internal_hitl, ) return tools diff --git a/surfsense_backend/app/agents/new_chat/tools/update_memory.py b/surfsense_backend/app/agents/new_chat/tools/update_memory.py index fbc9edbba..062668aac 100644 --- a/surfsense_backend/app/agents/new_chat/tools/update_memory.py +++ b/surfsense_backend/app/agents/new_chat/tools/update_memory.py @@ -27,6 +27,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db import SearchSpace, User, async_session_maker +from app.utils.content_utils import extract_text_content logger = logging.getLogger(__name__) @@ -188,12 +189,11 @@ async def _forced_rewrite(content: str, llm: Any) -> str | None: [HumanMessage(content=prompt)], config={"tags": ["surfsense:internal"]}, ) - text = ( - response.content - if isinstance(response.content, str) - else str(response.content) - ) - return text.strip() + text = extract_text_content(response.content).strip() + if not text: + logger.warning("Forced rewrite returned empty text; aborting rewrite") + return None + return text except Exception: logger.exception("Forced rewrite LLM call failed") return None @@ -235,6 +235,16 @@ async def _save_memory( label : str Human label for log messages (e.g. "user memory", "team memory"). """ + if not isinstance(updated_memory, str): + logger.warning( + "Refusing non-string memory payload (type=%s)", + type(updated_memory).__name__, + ) + return { + "status": "error", + "message": "Internal error: memory payload must be a string.", + } + content = updated_memory # --- forced rewrite if over the hard limit --- diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 97b4cf509..f6f0c7f62 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -587,6 +587,9 @@ class Config: # Anonymous / no-login mode settings NOLOGIN_MODE_ENABLED = os.getenv("NOLOGIN_MODE_ENABLED", "FALSE").upper() == "TRUE" + MULTI_AGENT_CHAT_ENABLED = ( + os.getenv("MULTI_AGENT_CHAT_ENABLED", "FALSE").upper() == "TRUE" + ) ANON_TOKEN_LIMIT = int(os.getenv("ANON_TOKEN_LIMIT", "500000")) ANON_TOKEN_WARNING_THRESHOLD = int( os.getenv("ANON_TOKEN_WARNING_THRESHOLD", "400000") diff --git a/surfsense_backend/app/indexing_pipeline/document_chunker.py b/surfsense_backend/app/indexing_pipeline/document_chunker.py index 4f3c698ef..6ae81b7a8 100644 --- a/surfsense_backend/app/indexing_pipeline/document_chunker.py +++ b/surfsense_backend/app/indexing_pipeline/document_chunker.py @@ -1,5 +1,15 @@ +import re + from app.config import config +# Regex that matches a Markdown table block (header + separator + one or more rows) +# A table block starts with a | at the beginning of a line and ends when a +# non-table line (or end of string) is encountered. +_TABLE_BLOCK_RE = re.compile( + r"(?:(?:^|\n)(?=[ \t]*\|)(?:[ \t]*\|[^\n]*\n)+)", + re.MULTILINE, +) + def chunk_text(text: str, use_code_chunker: bool = False) -> list[str]: """Chunk a text string using the configured chunker and return the chunk texts.""" @@ -7,3 +17,43 @@ def chunk_text(text: str, use_code_chunker: bool = False) -> list[str]: config.code_chunker_instance if use_code_chunker else config.chunker_instance ) return [c.text for c in chunker.chunk(text)] + + +def chunk_text_hybrid(text: str) -> list[str]: + """Table-aware chunker that prevents Markdown tables from being split mid-row. + + Algorithm: + 1. Scan the document for Markdown table blocks. + 2. Each table block is emitted as a single, unmodified chunk so that its + header, separator row, and data rows always stay together. + 3. The non-table prose segments between (and around) tables are passed through + the normal ``chunk_text`` chunker and their sub-chunks are interleaved in + document order. + + This ensures that table data is never sliced in the middle by the token-based + chunker, which would otherwise produce garbled rows that are useless for RAG. + + Fixes #1334. + """ + chunks: list[str] = [] + cursor = 0 + + for match in _TABLE_BLOCK_RE.finditer(text): + # Prose before this table + prose = text[cursor : match.start()].strip() + if prose: + chunks.extend(chunk_text(prose)) + + # The table itself is kept as one indivisible chunk + table_block = match.group(0).strip() + if table_block: + chunks.append(table_block) + + cursor = match.end() + + # Remaining prose after the last table (or entire text if no tables) + trailing = text[cursor:].strip() + if trailing: + chunks.extend(chunk_text(trailing)) + + return chunks diff --git a/surfsense_backend/app/indexing_pipeline/indexing_pipeline_service.py b/surfsense_backend/app/indexing_pipeline/indexing_pipeline_service.py index e6b2458f3..2339647ea 100644 --- a/surfsense_backend/app/indexing_pipeline/indexing_pipeline_service.py +++ b/surfsense_backend/app/indexing_pipeline/indexing_pipeline_service.py @@ -19,7 +19,7 @@ from app.db import ( DocumentType, ) from app.indexing_pipeline.connector_document import ConnectorDocument -from app.indexing_pipeline.document_chunker import chunk_text +from app.indexing_pipeline.document_chunker import chunk_text, chunk_text_hybrid from app.indexing_pipeline.document_embedder import embed_texts from app.indexing_pipeline.document_hashing import ( compute_content_hash, @@ -387,11 +387,19 @@ class IndexingPipelineService: ) t_step = time.perf_counter() - chunk_texts = await asyncio.to_thread( - chunk_text, - connector_doc.source_markdown, - use_code_chunker=connector_doc.should_use_code_chunker, - ) + if connector_doc.should_use_code_chunker: + chunk_texts = await asyncio.to_thread( + chunk_text, + connector_doc.source_markdown, + use_code_chunker=True, + ) + else: + # Use the table-aware hybrid chunker so Markdown tables are not + # split mid-row (see issue #1334). + chunk_texts = await asyncio.to_thread( + chunk_text_hybrid, + connector_doc.source_markdown, + ) texts_to_embed = [content, *chunk_texts] embeddings = await asyncio.to_thread(embed_texts, texts_to_embed) diff --git a/surfsense_backend/app/routes/memory_routes.py b/surfsense_backend/app/routes/memory_routes.py index f5df45cf1..e57ca4055 100644 --- a/surfsense_backend/app/routes/memory_routes.py +++ b/surfsense_backend/app/routes/memory_routes.py @@ -16,6 +16,7 @@ from app.agents.new_chat.llm_config import ( from app.agents.new_chat.tools.update_memory import MEMORY_HARD_LIMIT, _save_memory from app.db import User, get_async_session from app.users import current_active_user +from app.utils.content_utils import extract_text_content logger = logging.getLogger(__name__) @@ -123,11 +124,7 @@ async def edit_user_memory( [HumanMessage(content=prompt)], config={"tags": ["surfsense:internal", "memory-edit"]}, ) - updated = ( - response.content - if isinstance(response.content, str) - else str(response.content) - ).strip() + updated = extract_text_content(response.content).strip() except Exception as e: logger.exception("Memory edit LLM call failed: %s", e) raise HTTPException(status_code=500, detail="Memory edit failed.") from e diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index 2ade207d4..ad96654f5 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -2396,6 +2396,7 @@ async def resume_chat( thread_visibility=thread.visibility, filesystem_selection=filesystem_selection, request_id=getattr(http_request.state, "request_id", "unknown"), + disabled_tools=request.disabled_tools, ), media_type="text/event-stream", headers={ diff --git a/surfsense_backend/app/routes/search_spaces_routes.py b/surfsense_backend/app/routes/search_spaces_routes.py index 5ecfb1814..0f0e43035 100644 --- a/surfsense_backend/app/routes/search_spaces_routes.py +++ b/surfsense_backend/app/routes/search_spaces_routes.py @@ -35,6 +35,7 @@ from app.schemas import ( SearchSpaceWithStats, ) from app.users import current_active_user +from app.utils.content_utils import extract_text_content from app.utils.rbac import check_permission, check_search_space_access logger = logging.getLogger(__name__) @@ -356,11 +357,7 @@ async def edit_team_memory( [HumanMessage(content=prompt)], config={"tags": ["surfsense:internal", "memory-edit"]}, ) - updated = ( - response.content - if isinstance(response.content, str) - else str(response.content) - ).strip() + updated = extract_text_content(response.content).strip() except Exception as e: logger.exception("Team memory edit LLM call failed: %s", e) raise HTTPException(status_code=500, detail="Team memory edit failed.") from e diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py index 1a85484fa..95d183433 100644 --- a/surfsense_backend/app/schemas/new_chat.py +++ b/surfsense_backend/app/schemas/new_chat.py @@ -367,6 +367,9 @@ class ResumeDecision(BaseModel): class ResumeRequest(BaseModel): search_space_id: int decisions: list[ResumeDecision] + # Mirrors ``NewChatRequest.disabled_tools`` so the resumed run sees the + # same tool surface as the originating turn. + disabled_tools: list[str] | None = None filesystem_mode: Literal["cloud", "desktop_local_folder"] = "cloud" client_platform: Literal["web", "desktop"] = "web" local_filesystem_mounts: list[LocalFilesystemMountPayload] | None = None diff --git a/surfsense_backend/app/services/composio_service.py b/surfsense_backend/app/services/composio_service.py index edfab1d15..d73a0d4ce 100644 --- a/surfsense_backend/app/services/composio_service.py +++ b/surfsense_backend/app/services/composio_service.py @@ -1027,6 +1027,505 @@ class ComposioService: logger.error(f"Failed to list Calendar events: {e!s}") return [], str(e) + @staticmethod + def _unwrap_response_data(data: Any) -> Any: + """Composio responses often nest the meaningful payload under + ``data.data.response_data``. Walk that envelope safely and return + whichever inner dict actually has the result keys.""" + if not isinstance(data, dict): + return data + inner = data.get("data", data) + if isinstance(inner, dict): + return inner.get("response_data", inner) + return inner + + @staticmethod + def _split_email_csv(value: str | None) -> list[str] | None: + """Tools accept comma-separated cc/bcc strings; Composio expects an array.""" + if not value: + return None + addrs = [e.strip() for e in value.split(",") if e.strip()] + return addrs or None + + # ===== Gmail write methods ===== + + async def send_gmail_email( + self, + connected_account_id: str, + entity_id: str, + to: str, + subject: str, + body: str, + cc: str | None = None, + bcc: str | None = None, + is_html: bool = False, + ) -> tuple[str | None, str | None, str | None]: + """Send a Gmail message via the Composio ``GMAIL_SEND_EMAIL`` toolkit. + + Returns: + Tuple of (message_id, thread_id, error). On success ``error`` is + None and at least one of the IDs is populated when Composio + returns them; on failure both IDs are None. + """ + try: + params: dict[str, Any] = { + "recipient_email": to, + "subject": subject, + "body": body, + "is_html": is_html, + } + if cc: + cc_list = self._split_email_csv(cc) + if cc_list: + params["cc"] = cc_list + if bcc: + bcc_list = self._split_email_csv(bcc) + if bcc_list: + params["bcc"] = bcc_list + + result = await self.execute_tool( + connected_account_id=connected_account_id, + tool_name="GMAIL_SEND_EMAIL", + params=params, + entity_id=entity_id, + ) + if not result.get("success"): + return None, None, result.get("error", "Unknown error") + + payload = self._unwrap_response_data(result.get("data", {})) + message_id = None + thread_id = None + if isinstance(payload, dict): + message_id = ( + payload.get("id") + or payload.get("message_id") + or payload.get("messageId") + ) + thread_id = payload.get("threadId") or payload.get("thread_id") + return message_id, thread_id, None + except Exception as e: + logger.error(f"Failed to send Gmail email: {e!s}") + return None, None, str(e) + + async def create_gmail_draft( + self, + connected_account_id: str, + entity_id: str, + to: str, + subject: str, + body: str, + cc: str | None = None, + bcc: str | None = None, + is_html: bool = False, + ) -> tuple[str | None, str | None, str | None, str | None]: + """Create a Gmail draft via the Composio ``GMAIL_CREATE_EMAIL_DRAFT`` toolkit. + + Returns: + Tuple of (draft_id, message_id, thread_id, error). On success + ``error`` is None and ``draft_id`` is populated. + """ + try: + params: dict[str, Any] = { + "recipient_email": to, + "subject": subject, + "body": body, + "is_html": is_html, + } + cc_list = self._split_email_csv(cc) + if cc_list: + params["cc"] = cc_list + bcc_list = self._split_email_csv(bcc) + if bcc_list: + params["bcc"] = bcc_list + + result = await self.execute_tool( + connected_account_id=connected_account_id, + tool_name="GMAIL_CREATE_EMAIL_DRAFT", + params=params, + entity_id=entity_id, + ) + if not result.get("success"): + return None, None, None, result.get("error", "Unknown error") + + payload = self._unwrap_response_data(result.get("data", {})) + draft_id = None + message_id = None + thread_id = None + if isinstance(payload, dict): + draft_id = payload.get("id") or payload.get("draft_id") + draft_message = payload.get("message") or {} + if isinstance(draft_message, dict): + message_id = draft_message.get("id") or draft_message.get( + "message_id" + ) + thread_id = draft_message.get("threadId") or draft_message.get( + "thread_id" + ) + if message_id is None: + message_id = payload.get("message_id") or payload.get("messageId") + if thread_id is None: + thread_id = payload.get("thread_id") or payload.get("threadId") + return draft_id, message_id, thread_id, None + except Exception as e: + logger.error(f"Failed to create Gmail draft: {e!s}") + return None, None, None, str(e) + + async def update_gmail_draft( + self, + connected_account_id: str, + entity_id: str, + draft_id: str, + to: str | None = None, + subject: str | None = None, + body: str | None = None, + cc: str | None = None, + bcc: str | None = None, + is_html: bool = False, + ) -> tuple[str | None, str | None, str | None]: + """Update an existing Gmail draft via ``GMAIL_UPDATE_DRAFT``. + + Returns: + Tuple of (draft_id, message_id, error). + """ + try: + params: dict[str, Any] = { + "draft_id": draft_id, + "is_html": is_html, + } + if to: + params["recipient_email"] = to + if subject is not None: + params["subject"] = subject + if body is not None: + params["body"] = body + cc_list = self._split_email_csv(cc) + if cc_list: + params["cc"] = cc_list + bcc_list = self._split_email_csv(bcc) + if bcc_list: + params["bcc"] = bcc_list + + result = await self.execute_tool( + connected_account_id=connected_account_id, + tool_name="GMAIL_UPDATE_DRAFT", + params=params, + entity_id=entity_id, + ) + if not result.get("success"): + return None, None, result.get("error", "Unknown error") + + payload = self._unwrap_response_data(result.get("data", {})) + new_draft_id = draft_id + message_id = None + if isinstance(payload, dict): + new_draft_id = payload.get("id") or payload.get("draft_id") or draft_id + draft_message = payload.get("message") or {} + if isinstance(draft_message, dict): + message_id = draft_message.get("id") or draft_message.get( + "message_id" + ) + if message_id is None: + message_id = payload.get("message_id") or payload.get("messageId") + return new_draft_id, message_id, None + except Exception as e: + logger.error(f"Failed to update Gmail draft: {e!s}") + return None, None, str(e) + + async def trash_gmail_message( + self, + connected_account_id: str, + entity_id: str, + message_id: str, + ) -> str | None: + """Move a Gmail message to trash via ``GMAIL_MOVE_TO_TRASH``. + + Returns the error message on failure, ``None`` on success. + """ + try: + result = await self.execute_tool( + connected_account_id=connected_account_id, + tool_name="GMAIL_MOVE_TO_TRASH", + params={"message_id": message_id}, + entity_id=entity_id, + ) + if not result.get("success"): + return result.get("error", "Unknown error") + return None + except Exception as e: + logger.error(f"Failed to trash Gmail message: {e!s}") + return str(e) + + # ===== Google Calendar write methods ===== + + async def create_calendar_event( + self, + connected_account_id: str, + entity_id: str, + summary: str, + start_datetime: str, + end_datetime: str, + timezone: str | None = None, + description: str | None = None, + location: str | None = None, + attendees: list[str] | None = None, + calendar_id: str = "primary", + ) -> tuple[str | None, str | None, str | None]: + """Create a Google Calendar event via ``GOOGLECALENDAR_CREATE_EVENT``. + + Composio strips trailing timezone info on ``start_datetime`` / + ``end_datetime`` and uses the ``timezone`` field as the IANA name, + so callers may pass ISO 8601 strings with or without offsets. + + Returns: + Tuple of (event_id, html_link, error). + """ + try: + params: dict[str, Any] = { + "summary": summary, + "start_datetime": start_datetime, + "end_datetime": end_datetime, + "calendar_id": calendar_id, + } + if timezone: + params["timezone"] = timezone + if description: + params["description"] = description + if location: + params["location"] = location + if attendees: + params["attendees"] = [a for a in attendees if a] + + result = await self.execute_tool( + connected_account_id=connected_account_id, + tool_name="GOOGLECALENDAR_CREATE_EVENT", + params=params, + entity_id=entity_id, + ) + if not result.get("success"): + return None, None, result.get("error", "Unknown error") + + payload = self._unwrap_response_data(result.get("data", {})) + event_id = None + html_link = None + if isinstance(payload, dict): + event_id = payload.get("id") or payload.get("event_id") + html_link = payload.get("htmlLink") or payload.get("html_link") + return event_id, html_link, None + except Exception as e: + logger.error(f"Failed to create Calendar event: {e!s}") + return None, None, str(e) + + async def update_calendar_event( + self, + connected_account_id: str, + entity_id: str, + event_id: str, + summary: str | None = None, + start_time: str | None = None, + end_time: str | None = None, + timezone: str | None = None, + description: str | None = None, + location: str | None = None, + attendees: list[str] | None = None, + calendar_id: str = "primary", + ) -> tuple[str | None, str | None, str | None]: + """Patch an existing Google Calendar event via ``GOOGLECALENDAR_PATCH_EVENT``. + + Uses PATCH (not PUT) semantics so omitted fields are preserved. + + Returns: + Tuple of (event_id, html_link, error). + """ + try: + params: dict[str, Any] = { + "event_id": event_id, + "calendar_id": calendar_id, + } + if summary is not None: + params["summary"] = summary + if start_time is not None: + params["start_time"] = start_time + if end_time is not None: + params["end_time"] = end_time + if timezone: + params["timezone"] = timezone + if description is not None: + params["description"] = description + if location is not None: + params["location"] = location + if attendees is not None: + params["attendees"] = [a for a in attendees if a] + + result = await self.execute_tool( + connected_account_id=connected_account_id, + tool_name="GOOGLECALENDAR_PATCH_EVENT", + params=params, + entity_id=entity_id, + ) + if not result.get("success"): + return None, None, result.get("error", "Unknown error") + + payload = self._unwrap_response_data(result.get("data", {})) + new_event_id = event_id + html_link = None + if isinstance(payload, dict): + new_event_id = payload.get("id") or payload.get("event_id") or event_id + html_link = payload.get("htmlLink") or payload.get("html_link") + return new_event_id, html_link, None + except Exception as e: + logger.error(f"Failed to patch Calendar event: {e!s}") + return None, None, str(e) + + async def delete_calendar_event( + self, + connected_account_id: str, + entity_id: str, + event_id: str, + calendar_id: str = "primary", + ) -> str | None: + """Delete a Google Calendar event via ``GOOGLECALENDAR_DELETE_EVENT``. + + Returns the error message on failure, ``None`` on success (idempotent + on already-deleted events). + """ + try: + result = await self.execute_tool( + connected_account_id=connected_account_id, + tool_name="GOOGLECALENDAR_DELETE_EVENT", + params={ + "event_id": event_id, + "calendar_id": calendar_id, + }, + entity_id=entity_id, + ) + if not result.get("success"): + return result.get("error", "Unknown error") + return None + except Exception as e: + logger.error(f"Failed to delete Calendar event: {e!s}") + return str(e) + + # ===== Google Drive write methods ===== + + @staticmethod + def _drive_web_view_link(file_id: str, mime_type: str | None) -> str: + """Synthesize a Google Drive ``webViewLink`` from id + mimeType. + + Composio's ``GOOGLEDRIVE_CREATE_FILE_FROM_TEXT`` returns flat + metadata (id, name, mimeType) but does not always include a + ``webViewLink``. We rebuild the canonical UI URL based on the + Workspace MIME type so callers can keep using a single field. + """ + if not file_id: + return "" + mt = (mime_type or "").lower() + if mt == "application/vnd.google-apps.document": + return f"https://docs.google.com/document/d/{file_id}/edit" + if mt == "application/vnd.google-apps.spreadsheet": + return f"https://docs.google.com/spreadsheets/d/{file_id}/edit" + if mt == "application/vnd.google-apps.presentation": + return f"https://docs.google.com/presentation/d/{file_id}/edit" + if mt == "application/vnd.google-apps.folder": + return f"https://drive.google.com/drive/folders/{file_id}" + return f"https://drive.google.com/file/d/{file_id}/view" + + async def create_drive_file_from_text( + self, + connected_account_id: str, + entity_id: str, + name: str, + mime_type: str, + content: str | None = None, + parent_id: str | None = None, + ) -> tuple[dict[str, Any] | None, str | None]: + """Create a Google Drive file from text via ``GOOGLEDRIVE_CREATE_FILE_FROM_TEXT``. + + Composio's tool requires ``text_content`` even for "empty" files; + an empty string is accepted. Native Workspace types (Docs, Sheets) + are produced by setting ``mime_type`` to the Google Apps MIME, and + Drive auto-converts the text payload (e.g. CSV → Sheet). + + Returns: + Tuple of (file_meta, error). ``file_meta`` keys: + ``id``, ``name``, ``mimeType``, ``webViewLink``. + """ + try: + params: dict[str, Any] = { + "file_name": name, + "mime_type": mime_type, + "text_content": content if content is not None else "", + } + if parent_id: + params["parent_id"] = parent_id + + result = await self.execute_tool( + connected_account_id=connected_account_id, + tool_name="GOOGLEDRIVE_CREATE_FILE_FROM_TEXT", + params=params, + entity_id=entity_id, + ) + if not result.get("success"): + return None, result.get("error", "Unknown error") + + payload = self._unwrap_response_data(result.get("data", {})) + file_id: str | None = None + file_name: str | None = name + mime: str | None = mime_type + web_view_link: str | None = None + + if isinstance(payload, dict): + file_id = ( + payload.get("id") or payload.get("file_id") or payload.get("fileId") + ) + file_name = payload.get("name") or payload.get("file_name") or name + mime = payload.get("mimeType") or payload.get("mime_type") or mime_type + web_view_link = payload.get("webViewLink") or payload.get( + "web_view_link" + ) + + if not file_id: + return None, "Composio response did not include a file id" + + if not web_view_link: + web_view_link = self._drive_web_view_link(file_id, mime) + + return ( + { + "id": file_id, + "name": file_name, + "mimeType": mime, + "webViewLink": web_view_link, + }, + None, + ) + except Exception as e: + logger.error(f"Failed to create Drive file: {e!s}") + return None, str(e) + + async def trash_drive_file( + self, + connected_account_id: str, + entity_id: str, + file_id: str, + ) -> str | None: + """Move a Google Drive file to trash via ``GOOGLEDRIVE_TRASH_FILE``. + + Returns the error message on failure, ``None`` on success. + """ + try: + result = await self.execute_tool( + connected_account_id=connected_account_id, + tool_name="GOOGLEDRIVE_TRASH_FILE", + params={"file_id": file_id}, + entity_id=entity_id, + ) + if not result.get("success"): + return result.get("error", "Unknown error") + return None + except Exception as e: + logger.error(f"Failed to trash Drive file: {e!s}") + return str(e) + # ===== User Info Methods ===== async def get_connected_account_email( diff --git a/surfsense_backend/app/services/mcp_oauth/registry.py b/surfsense_backend/app/services/mcp_oauth/registry.py index 835d70184..310c3f6e8 100644 --- a/surfsense_backend/app/services/mcp_oauth/registry.py +++ b/surfsense_backend/app/services/mcp_oauth/registry.py @@ -14,10 +14,57 @@ accuracy high. from __future__ import annotations +import re from dataclasses import dataclass, field from app.db import SearchSourceConnectorType +# Linear hosted MCP (https://linear.app/docs/mcp). Tool names are matched at +# discovery time: names the server does not advertise are ignored. +# See also https://github.com/linear/linear/issues/1049 for server-reported names. +LINEAR_MCP_WRITE_TOOL_NAMES: frozenset[str] = frozenset({"save_issue"}) +LINEAR_MCP_READONLY_TOOL_NAMES: frozenset[str] = frozenset( + { + # Issues + "list_issues", + "get_issue", + "list_my_issues", + "list_issue_statuses", + "list_issue_labels", + "list_comments", + # People & teams + "list_users", + "get_user", + "list_teams", + "get_team", + # Projects & planning + "list_projects", + "get_project", + "list_project_labels", + "list_cycles", + # Documents + "list_documents", + "get_document", + # Misc read + "search_documentation", + } +) +LINEAR_MCP_TOOL_NAMES: frozenset[str] = ( + LINEAR_MCP_READONLY_TOOL_NAMES | LINEAR_MCP_WRITE_TOOL_NAMES +) +_LINEAR_MCP_PREFIXED_NAME_RE = re.compile(r"^linear_\d+_(.+)$") + + +def linear_mcp_original_tool_name(name: str) -> str | None: + """Map ``linear__`` or bare MCP tool name to base name.""" + m = _LINEAR_MCP_PREFIXED_NAME_RE.match(name) + if m: + base = m.group(1) + return base if base in LINEAR_MCP_TOOL_NAMES else None + if name in LINEAR_MCP_TOOL_NAMES: + return name + return None + @dataclass(frozen=True) class MCPServiceConfig: @@ -50,12 +97,8 @@ MCP_SERVICES: dict[str, MCPServiceConfig] = { name="Linear", mcp_url="https://mcp.linear.app/mcp", connector_type="LINEAR_CONNECTOR", - allowed_tools=[ - "list_issues", - "get_issue", - "save_issue", - ], - readonly_tools=frozenset({"list_issues", "get_issue"}), + allowed_tools=sorted(LINEAR_MCP_TOOL_NAMES), + readonly_tools=LINEAR_MCP_READONLY_TOOL_NAMES, account_metadata_keys=["organization_name", "organization_url_key"], ), "jira": MCPServiceConfig( diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 487602c3b..1a2f38077 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -28,6 +28,7 @@ from langchain_core.messages import HumanMessage from sqlalchemy.future import select from sqlalchemy.orm import selectinload +from app.agents.multi_agent_chat import create_multi_agent_chat_deep_agent from app.agents.new_chat.chat_deepagent import create_surfsense_deep_agent from app.agents.new_chat.checkpointer import get_checkpointer from app.agents.new_chat.context import SurfSenseContextSchema @@ -82,6 +83,8 @@ from app.utils.user_message_multimodal import build_human_message_content _background_tasks: set[asyncio.Task] = set() _perf_log = get_perf_logger() +logger = logging.getLogger(__name__) + TURN_CANCELLING_INITIAL_DELAY_MS = 200 TURN_CANCELLING_BACKOFF_FACTOR = 2 TURN_CANCELLING_MAX_DELAY_MS = 1500 @@ -572,6 +575,43 @@ async def _preflight_llm(llm: Any) -> None: ) +async def _build_main_agent_for_thread( + agent_factory: Any, + *, + llm: Any, + search_space_id: int, + db_session: Any, + connector_service: ConnectorService, + checkpointer: Any, + user_id: str | None, + thread_id: int | None, + agent_config: AgentConfig | None, + firecrawl_api_key: str | None, + thread_visibility: ChatVisibility | None, + filesystem_selection: FilesystemSelection | None, + disabled_tools: list[str] | None = None, + mentioned_document_ids: list[int] | None = None, +) -> Any: + """Single (re)build path so the agent factory cannot drift across + initial build, preflight repin, and mid-stream 429 recovery for one + ``thread_id``: a graph swap mid-turn would corrupt checkpointer state.""" + return await agent_factory( + llm=llm, + search_space_id=search_space_id, + db_session=db_session, + connector_service=connector_service, + checkpointer=checkpointer, + user_id=user_id, + thread_id=thread_id, + agent_config=agent_config, + firecrawl_api_key=firecrawl_api_key, + thread_visibility=thread_visibility, + filesystem_selection=filesystem_selection, + disabled_tools=disabled_tools, + mentioned_document_ids=mentioned_document_ids, + ) + + async def _settle_speculative_agent_build(task: asyncio.Task[Any]) -> None: """Wait for a discarded speculative agent build to release shared state. @@ -2438,6 +2478,10 @@ async def stream_new_chat( _premium_reserved_micros = 0 _premium_request_id: str | None = None + # ``BusyError`` fires before the lock is acquired; the ``finally`` must + # not release the in-flight caller's lock. + _busy_error_raised = False + _emit_stream_error = partial( _emit_stream_terminal_error, streaming_service=streaming_service, @@ -2752,13 +2796,23 @@ async def stream_new_chat( ) visibility = thread_visibility or ChatVisibility.PRIVATE + from app.config import config as _app_config + + use_multi_agent = bool(_app_config.MULTI_AGENT_CHAT_ENABLED) + _t0 = time.perf_counter() + agent_factory = ( + create_multi_agent_chat_deep_agent + if use_multi_agent + else create_surfsense_deep_agent + ) # Speculative agent build — runs in parallel with the preflight # task (if any). Built with the *current* ``llm`` / ``agent_config``; # if preflight reports 429 we will discard this future and rebuild # against the freshly pinned config below. agent_build_task = asyncio.create_task( - create_surfsense_deep_agent( + _build_main_agent_for_thread( + agent_factory, llm=llm, search_space_id=search_space_id, db_session=session, @@ -2769,9 +2823,9 @@ async def stream_new_chat( agent_config=agent_config, firecrawl_api_key=firecrawl_api_key, thread_visibility=visibility, + filesystem_selection=filesystem_selection, disabled_tools=disabled_tools, mentioned_document_ids=mentioned_document_ids, - filesystem_selection=filesystem_selection, ), name="agent_build:stream_new_chat", ) @@ -2862,7 +2916,7 @@ async def stream_new_chat( ) # Rebuild against the new llm/agent_config. Sequential # here because we no longer have anything to overlap with. - agent = await create_surfsense_deep_agent( + agent = await agent_factory( llm=llm, search_space_id=search_space_id, db_session=session, @@ -3448,7 +3502,8 @@ async def stream_new_chat( title_task = None _t0 = time.perf_counter() - agent = await create_surfsense_deep_agent( + agent = await _build_main_agent_for_thread( + agent_factory, llm=llm, search_space_id=search_space_id, db_session=session, @@ -3459,9 +3514,9 @@ async def stream_new_chat( agent_config=agent_config, firecrawl_api_key=firecrawl_api_key, thread_visibility=visibility, + filesystem_selection=filesystem_selection, disabled_tools=disabled_tools, mentioned_document_ids=mentioned_document_ids, - filesystem_selection=filesystem_selection, ) _perf_log.info( "[stream_new_chat] Runtime rate-limit recovery repinned " @@ -3634,6 +3689,12 @@ async def stream_new_chat( # Handle any errors import traceback + # ``BusyError`` fires before the agent acquires the lock; the + # cleanup path must skip lock release to avoid freeing the + # in-flight caller's lock. Classification is handled below. + if isinstance(e, BusyError): + _busy_error_raised = True + ( error_kind, error_code, @@ -3807,6 +3868,16 @@ async def stream_new_chat( chat_id, stream_result.sandbox_files ) + # ``aafter_agent`` doesn't fire on ``interrupt()`` or early bailout. + # Skip on ``BusyError`` (caller never acquired the lock). + if not _busy_error_raised: + with contextlib.suppress(Exception): + end_turn(str(chat_id)) + _perf_log.info( + "[stream_new_chat] end_turn cleanup (chat_id=%s)", + chat_id, + ) + # Break circular refs held by the agent graph, tools, and LLM # wrappers so the GC can reclaim them in a single pass. agent = llm = connector_service = None @@ -3833,6 +3904,7 @@ async def stream_resume_chat( thread_visibility: ChatVisibility | None = None, filesystem_selection: FilesystemSelection | None = None, request_id: str | None = None, + disabled_tools: list[str] | None = None, ) -> AsyncGenerator[str, None]: streaming_service = VercelStreamingService() stream_result = StreamResult() @@ -3851,11 +3923,13 @@ async def stream_resume_chat( fs_mode, fs_platform, ) - from app.services.token_tracking_service import start_turn accumulator = start_turn() + # Skip the finally release on ``BusyError`` (caller never acquired the lock). + _busy_error_raised = False + _emit_stream_error = partial( _emit_stream_terminal_error, streaming_service=streaming_service, @@ -4089,10 +4163,17 @@ async def stream_resume_chat( ) visibility = thread_visibility or ChatVisibility.PRIVATE + from app.config import config as _app_config _t0 = time.perf_counter() + agent_factory = ( + create_multi_agent_chat_deep_agent + if _app_config.MULTI_AGENT_CHAT_ENABLED + else create_surfsense_deep_agent + ) agent_build_task = asyncio.create_task( - create_surfsense_deep_agent( + _build_main_agent_for_thread( + agent_factory, llm=llm, search_space_id=search_space_id, db_session=session, @@ -4104,6 +4185,7 @@ async def stream_resume_chat( firecrawl_api_key=firecrawl_api_key, thread_visibility=visibility, filesystem_selection=filesystem_selection, + disabled_tools=disabled_tools, ), name="agent_build:stream_resume", ) @@ -4180,7 +4262,8 @@ async def stream_resume_chat( "fallback_config_id": llm_config_id, }, ) - agent = await create_surfsense_deep_agent( + agent = await _build_main_agent_for_thread( + agent_factory, llm=llm, search_space_id=search_space_id, db_session=session, @@ -4192,6 +4275,7 @@ async def stream_resume_chat( firecrawl_api_key=firecrawl_api_key, thread_visibility=visibility, filesystem_selection=filesystem_selection, + disabled_tools=disabled_tools, ) if agent is None: @@ -4217,6 +4301,9 @@ async def stream_resume_chat( "thread_id": str(chat_id), "request_id": request_id or "unknown", "turn_id": stream_result.turn_id, + # Side-channel consumed by ``SurfSenseCheckpointedSubAgentMiddleware`` + # to forward the resume into a subagent's pending ``interrupt()``. + "surfsense_resume_value": {"decisions": decisions}, }, # See ``stream_new_chat`` above for rationale: effectively # uncapped to mirror the agent default and OpenCode's @@ -4361,7 +4448,8 @@ async def stream_resume_chat( raise stream_exc _t0 = time.perf_counter() - agent = await create_surfsense_deep_agent( + agent = await _build_main_agent_for_thread( + agent_factory, llm=llm, search_space_id=search_space_id, db_session=session, @@ -4373,6 +4461,7 @@ async def stream_resume_chat( firecrawl_api_key=firecrawl_api_key, thread_visibility=visibility, filesystem_selection=filesystem_selection, + disabled_tools=disabled_tools, ) _perf_log.info( "[stream_resume] Runtime rate-limit recovery repinned " @@ -4487,6 +4576,12 @@ async def stream_resume_chat( except Exception as e: import traceback + # ``BusyError`` fires before the agent acquires the lock; the + # cleanup path must skip lock release to avoid freeing the + # in-flight caller's lock. Classification is handled below. + if isinstance(e, BusyError): + _busy_error_raised = True + ( error_kind, error_code, @@ -4621,6 +4716,16 @@ async def stream_resume_chat( accumulator=accumulator, ) + # Release the lock from the original interrupted turn or any + # re-interrupt/bailout. Skip on ``BusyError`` (lock not held here). + if not _busy_error_raised: + with contextlib.suppress(Exception): + end_turn(str(chat_id)) + _perf_log.info( + "[stream_resume] end_turn cleanup (chat_id=%s)", + chat_id, + ) + agent = llm = connector_service = None stream_result = None session = None diff --git a/surfsense_backend/pyproject.toml b/surfsense_backend/pyproject.toml index 4235ac962..523a8a1ac 100644 --- a/surfsense_backend/pyproject.toml +++ b/surfsense_backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "surf-new-backend" -version = "0.0.22" +version = "0.0.23" description = "SurfSense Backend" requires-python = ">=3.12" dependencies = [ diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/__init__.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/__init__.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/__init__.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_hitl_bridge.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_hitl_bridge.py new file mode 100644 index 000000000..dbc2c9c00 --- /dev/null +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_hitl_bridge.py @@ -0,0 +1,208 @@ +"""End-to-end resume-bridge tests against a real LangGraph subagent.""" + +from __future__ import annotations + +import ast + +import pytest +from langchain.tools import ToolRuntime +from langchain_core.messages import HumanMessage +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.graph import END, START, StateGraph +from langgraph.types import Command, interrupt +from typing_extensions import TypedDict + +from app.agents.multi_agent_chat.middleware.main_agent.checkpointed_subagent_middleware.task_tool import ( + build_task_tool_with_parent_config, +) + + +class _SubagentState(TypedDict, total=False): + messages: list + decision_text: str + + +def _build_single_interrupt_subagent(): + def approve_node(state): + from langchain_core.messages import AIMessage + + decision = interrupt( + { + "action_requests": [ + { + "name": "do_thing", + "args": {"x": 1}, + "description": "test action", + } + ], + "review_configs": [{}], + } + ) + return { + "messages": [AIMessage(content="done")], + "decision_text": repr(decision), + } + + graph = StateGraph(_SubagentState) + graph.add_node("approve", approve_node) + graph.add_edge(START, "approve") + graph.add_edge("approve", END) + return graph.compile(checkpointer=InMemorySaver()) + + +def _make_runtime(config: dict) -> ToolRuntime: + return ToolRuntime( + state={"messages": [HumanMessage(content="seed")]}, + context=None, + config=config, + stream_writer=None, + tool_call_id="parent-tcid-1", + store=None, + ) + + +@pytest.mark.asyncio +async def test_resume_bridge_dispatches_decision_into_pending_subagent(): + """Side-channel decision must reach the subagent's pending interrupt verbatim.""" + subagent = _build_single_interrupt_subagent() + task_tool = build_task_tool_with_parent_config( + [ + { + "name": "approver", + "description": "approves things", + "runnable": subagent, + } + ] + ) + + parent_config: dict = { + "configurable": {"thread_id": "shared-thread"}, + "recursion_limit": 100, + } + await subagent.ainvoke({"messages": [HumanMessage(content="seed")]}, parent_config) + snap = await subagent.aget_state(parent_config) + assert snap.tasks and snap.tasks[0].interrupts, ( + "fixture broken: subagent should be paused on its interrupt" + ) + + parent_config["configurable"]["surfsense_resume_value"] = { + "decisions": ["APPROVED"] + } + runtime = _make_runtime(parent_config) + + result = await task_tool.coroutine( + description="please approve", + subagent_type="approver", + runtime=runtime, + ) + + assert isinstance(result, Command) + update = result.update + assert update["decision_text"] == repr({"decisions": ["APPROVED"]}) + assert "surfsense_resume_value" not in parent_config["configurable"] + + final = await subagent.aget_state(parent_config) + assert not final.tasks or all(not t.interrupts for t in final.tasks) + + +@pytest.mark.asyncio +async def test_pending_interrupt_without_resume_value_raises_runtime_error(): + """Bridge must fail loud rather than silently replay the user's interrupt.""" + subagent = _build_single_interrupt_subagent() + task_tool = build_task_tool_with_parent_config( + [ + { + "name": "approver", + "description": "approves things", + "runnable": subagent, + } + ] + ) + + parent_config: dict = { + "configurable": {"thread_id": "guard-thread"}, + "recursion_limit": 100, + } + await subagent.ainvoke({"messages": [HumanMessage(content="seed")]}, parent_config) + snap = await subagent.aget_state(parent_config) + assert snap.tasks and snap.tasks[0].interrupts, "fixture broken" + + runtime = _make_runtime(parent_config) + + with pytest.raises(RuntimeError, match="resume bridge is broken"): + await task_tool.coroutine( + description="please approve", + subagent_type="approver", + runtime=runtime, + ) + + +def _build_bundle_subagent(): + def bundle_node(state): + from langchain_core.messages import AIMessage + + decision = interrupt( + { + "action_requests": [ + {"name": "create_a", "args": {}, "description": ""}, + {"name": "create_b", "args": {}, "description": ""}, + {"name": "create_c", "args": {}, "description": ""}, + ], + "review_configs": [{}, {}, {}], + } + ) + return { + "messages": [AIMessage(content="bundle-done")], + "decision_text": repr(decision), + } + + graph = StateGraph(_SubagentState) + graph.add_node("bundle", bundle_node) + graph.add_edge(START, "bundle") + graph.add_edge("bundle", END) + return graph.compile(checkpointer=InMemorySaver()) + + +@pytest.mark.asyncio +async def test_bundle_three_mixed_decisions_arrive_in_order(): + """Approve / edit / reject for a 3-action bundle must land at ordinals 0/1/2.""" + subagent = _build_bundle_subagent() + task_tool = build_task_tool_with_parent_config( + [ + { + "name": "bundler", + "description": "creates a bundle", + "runnable": subagent, + } + ] + ) + + parent_config: dict = { + "configurable": {"thread_id": "bundle-thread"}, + "recursion_limit": 100, + } + await subagent.ainvoke({"messages": [HumanMessage(content="seed")]}, parent_config) + + decisions_payload = { + "decisions": [ + {"type": "approve", "args": {}}, + {"type": "edit", "args": {"args": {"name": "edited-b"}}}, + {"type": "reject", "args": {"message": "no thanks"}}, + ] + } + parent_config["configurable"]["surfsense_resume_value"] = decisions_payload + runtime = _make_runtime(parent_config) + + result = await task_tool.coroutine( + description="run bundle", + subagent_type="bundler", + runtime=runtime, + ) + + assert isinstance(result, Command) + received = ast.literal_eval(result.update["decision_text"]) + assert received == decisions_payload + assert received["decisions"][0]["type"] == "approve" + assert received["decisions"][1]["type"] == "edit" + assert received["decisions"][1]["args"] == {"args": {"name": "edited-b"}} + assert received["decisions"][2]["type"] == "reject" diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_pending_interrupt.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_pending_interrupt.py new file mode 100644 index 000000000..75242689d --- /dev/null +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_pending_interrupt.py @@ -0,0 +1,55 @@ +"""Pins the first-wins assumption of ``get_first_pending_subagent_interrupt``. + +The bridge currently relies on at-most-one pending interrupt per snapshot +(sequential tool nodes). If parallel tool calls are ever enabled, the bridge +needs an id-aware lookup; these tests will need to be revisited at that point. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +from app.agents.multi_agent_chat.middleware.main_agent.checkpointed_subagent_middleware.resume import ( + get_first_pending_subagent_interrupt, +) + + +class TestGetFirstPendingSubagentInterrupt: + def test_returns_first_when_multiple_top_level_interrupts_pending(self): + first = SimpleNamespace(id="i-1", value={"decision": "approve"}) + second = SimpleNamespace(id="i-2", value={"decision": "reject"}) + state = SimpleNamespace(interrupts=(first, second), tasks=()) + + assert get_first_pending_subagent_interrupt(state) == ( + "i-1", + {"decision": "approve"}, + ) + + def test_returns_first_when_multiple_subtask_interrupts_pending(self): + first = SimpleNamespace(id="i-A", value="approve") + second = SimpleNamespace(id="i-B", value="reject") + sub_task = SimpleNamespace(interrupts=(first, second)) + state = SimpleNamespace(interrupts=(), tasks=(sub_task,)) + + assert get_first_pending_subagent_interrupt(state) == ("i-A", "approve") + + def test_returns_none_when_no_interrupts(self): + state = SimpleNamespace(interrupts=(), tasks=()) + + assert get_first_pending_subagent_interrupt(state) == (None, None) + + def test_returns_none_when_state_is_none(self): + assert get_first_pending_subagent_interrupt(None) == (None, None) + + def test_skips_interrupts_with_none_value(self): + empty = SimpleNamespace(id="i-empty", value=None) + real = SimpleNamespace(id="i-real", value="approve") + state = SimpleNamespace(interrupts=(empty, real), tasks=()) + + assert get_first_pending_subagent_interrupt(state) == ("i-real", "approve") + + def test_normalizes_non_string_id_to_none(self): + interrupt = SimpleNamespace(id=12345, value="approve") + state = SimpleNamespace(interrupts=(interrupt,), tasks=()) + + assert get_first_pending_subagent_interrupt(state) == (None, "approve") diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_resume_helpers.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_resume_helpers.py new file mode 100644 index 000000000..347b32dbd --- /dev/null +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_resume_helpers.py @@ -0,0 +1,71 @@ +"""Resume side-channel must be read exactly once per turn.""" + +from __future__ import annotations + +from langchain.tools import ToolRuntime + +from app.agents.multi_agent_chat.middleware.main_agent.checkpointed_subagent_middleware.config import ( + consume_surfsense_resume, + has_surfsense_resume, +) + + +def _runtime_with_config(config: dict) -> ToolRuntime: + return ToolRuntime( + state=None, + context=None, + config=config, + stream_writer=None, + tool_call_id="tcid-test", + store=None, + ) + + +class TestConsumeSurfsenseResume: + def test_pops_value_on_first_call(self): + runtime = _runtime_with_config( + {"configurable": {"surfsense_resume_value": {"decisions": ["approve"]}}} + ) + + assert consume_surfsense_resume(runtime) == {"decisions": ["approve"]} + + def test_second_call_returns_none(self): + configurable: dict = {"surfsense_resume_value": {"decisions": ["approve"]}} + runtime = _runtime_with_config({"configurable": configurable}) + + consume_surfsense_resume(runtime) + + assert consume_surfsense_resume(runtime) is None + assert "surfsense_resume_value" not in configurable + + def test_returns_none_when_no_payload_queued(self): + runtime = _runtime_with_config({"configurable": {}}) + + assert consume_surfsense_resume(runtime) is None + + def test_returns_none_when_configurable_missing(self): + runtime = _runtime_with_config({}) + + assert consume_surfsense_resume(runtime) is None + + +class TestHasSurfsenseResume: + def test_true_when_payload_queued(self): + runtime = _runtime_with_config( + {"configurable": {"surfsense_resume_value": "approve"}} + ) + + assert has_surfsense_resume(runtime) is True + + def test_does_not_consume_payload(self): + configurable = {"surfsense_resume_value": "approve"} + runtime = _runtime_with_config({"configurable": configurable}) + + has_surfsense_resume(runtime) + + assert configurable == {"surfsense_resume_value": "approve"} + + def test_false_when_payload_absent(self): + runtime = _runtime_with_config({"configurable": {}}) + + assert has_surfsense_resume(runtime) is False diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/__init__.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/__init__.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py new file mode 100644 index 000000000..648e52115 --- /dev/null +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py @@ -0,0 +1,96 @@ +"""Subagent resilience contract: ``extra_middleware`` reaches the agent chain.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Iterator +from typing import Any + +import pytest +from langchain.agents import create_agent +from langchain.agents.middleware import ModelFallbackMiddleware +from langchain_core.callbacks import ( + AsyncCallbackManagerForLLMRun, + CallbackManagerForLLMRun, +) +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, +) +from langchain_core.messages import AIMessage, BaseMessage, HumanMessage +from langchain_core.outputs import ChatGeneration, ChatResult + +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( + pack_subagent, +) + + +class RateLimitError(Exception): + """Name matches the scoped-fallback eligibility allowlist.""" + + +class _AlwaysFailingChatModel(BaseChatModel): + @property + def _llm_type(self) -> str: + return "always-failing-test-model" + + def _generate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + msg = "primary llm exploded" + raise RateLimitError(msg) + + async def _agenerate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + msg = "primary llm exploded" + raise RateLimitError(msg) + + def _stream(self, *args: Any, **kwargs: Any) -> Iterator[ChatGeneration]: + msg = "primary llm exploded" + raise RateLimitError(msg) + + async def _astream( + self, *args: Any, **kwargs: Any + ) -> AsyncIterator[ChatGeneration]: + msg = "primary llm exploded" + raise RateLimitError(msg) + yield # pragma: no cover - unreachable, satisfies async generator typing + + +@pytest.mark.asyncio +async def test_subagent_recovers_when_primary_llm_fails(): + """Fallback in ``extra_middleware`` must finish the turn when primary raises.""" + primary = _AlwaysFailingChatModel() + fallback = FakeMessagesListChatModel( + responses=[AIMessage(content="recovered via fallback")] + ) + + spec = pack_subagent( + name="resilience_test", + description="test subagent", + system_prompt="be helpful", + tools=[], + model=primary, + extra_middleware=[ModelFallbackMiddleware(fallback)], + ) + + agent = create_agent( + model=spec["model"], + tools=spec["tools"], + middleware=spec["middleware"], + system_prompt=spec["system_prompt"], + ) + + result = await agent.ainvoke({"messages": [HumanMessage(content="hi")]}) + + final = result["messages"][-1] + assert isinstance(final, AIMessage) + assert final.content == "recovered via fallback" diff --git a/surfsense_backend/tests/unit/agents/new_chat/middleware/__init__.py b/surfsense_backend/tests/unit/agents/new_chat/middleware/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/tests/unit/agents/new_chat/middleware/test_scoped_model_fallback.py b/surfsense_backend/tests/unit/agents/new_chat/middleware/test_scoped_model_fallback.py new file mode 100644 index 000000000..80b9862e7 --- /dev/null +++ b/surfsense_backend/tests/unit/agents/new_chat/middleware/test_scoped_model_fallback.py @@ -0,0 +1,128 @@ +"""``ScopedModelFallbackMiddleware`` triggers fallback only on provider errors.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Iterator +from typing import Any + +import pytest +from langchain_core.callbacks import ( + AsyncCallbackManagerForLLMRun, + CallbackManagerForLLMRun, +) +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import AIMessage, BaseMessage +from langchain_core.outputs import ChatGeneration, ChatResult + + +class _RaisingChatModel(BaseChatModel): + exc_to_raise: Any + + @property + def _llm_type(self) -> str: + return "raising-test-model" + + def _generate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + raise self.exc_to_raise + + async def _agenerate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + raise self.exc_to_raise + + def _stream(self, *args: Any, **kwargs: Any) -> Iterator[ChatGeneration]: + raise self.exc_to_raise + + async def _astream( + self, *args: Any, **kwargs: Any + ) -> AsyncIterator[ChatGeneration]: + raise self.exc_to_raise + yield # pragma: no cover - unreachable + + +class _RecordingChatModel(BaseChatModel): + response_text: str = "fallback-ok" + call_count: int = 0 + + @property + def _llm_type(self) -> str: + return "recording-test-model" + + def _generate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + self.call_count += 1 + return ChatResult( + generations=[ChatGeneration(message=AIMessage(content=self.response_text))] + ) + + async def _agenerate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + return self._generate(messages, stop, None, **kwargs) + + +class RateLimitError(Exception): + """Name matches the scoped-fallback eligibility allowlist.""" + + +def _build_agent(primary: BaseChatModel, fallback: BaseChatModel): + from langchain.agents import create_agent + + from app.agents.new_chat.middleware.scoped_model_fallback import ( + ScopedModelFallbackMiddleware, + ) + + return create_agent( + model=primary, + tools=[], + middleware=[ScopedModelFallbackMiddleware(fallback)], + system_prompt="be helpful", + ) + + +@pytest.mark.asyncio +async def test_provider_errors_trigger_fallback(): + """Eligible exception names must drive the fallback chain.""" + primary = _RaisingChatModel(exc_to_raise=RateLimitError("429 from provider")) + fallback = _RecordingChatModel(response_text="recovered") + + agent = _build_agent(primary, fallback) + result = await agent.ainvoke({"messages": [("user", "hi")]}) + + final = result["messages"][-1] + assert isinstance(final, AIMessage) + assert final.content == "recovered" + assert fallback.call_count == 1 + + +@pytest.mark.asyncio +async def test_programming_errors_propagate_without_invoking_fallback(): + """Non-eligible exceptions must propagate; fallback must not be invoked.""" + primary = _RaisingChatModel(exc_to_raise=KeyError("missing_state_field")) + fallback = _RecordingChatModel(response_text="should-never-arrive") + + agent = _build_agent(primary, fallback) + + with pytest.raises(KeyError, match="missing_state_field"): + await agent.ainvoke({"messages": [("user", "hi")]}) + + assert fallback.call_count == 0 diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_dedup_tool_calls.py b/surfsense_backend/tests/unit/agents/new_chat/test_dedup_tool_calls.py index e04f50815..61d9b499f 100644 --- a/surfsense_backend/tests/unit/agents/new_chat/test_dedup_tool_calls.py +++ b/surfsense_backend/tests/unit/agents/new_chat/test_dedup_tool_calls.py @@ -130,6 +130,79 @@ def test_registry_propagates_dedup_key_to_tool_metadata() -> None: 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 / + cloudId — so 3 distinct issues in the same workspace collapsed to 1. + + With :func:`dedup_key_full_args` only fully identical arg dicts dedup. + """ + from app.agents.new_chat.middleware.dedup_tool_calls import dedup_key_full_args + + tool = _make_tool("createJiraIssue", dedup_key=dedup_key_full_args) + mw = DedupHITLToolCallsMiddleware(agent_tools=[tool]) + state = { + "messages": [ + _msg( + { + "name": "createJiraIssue", + "args": { + "cloudId": "ws.atlassian.net", + "projectKey": "PROJ", + "summary": "Fix login bug", + }, + "id": "1", + }, + { + "name": "createJiraIssue", + "args": { + "cloudId": "ws.atlassian.net", + "projectKey": "PROJ", + "summary": "Add dark mode", + }, + "id": "2", + }, + { + "name": "createJiraIssue", + "args": { + "cloudId": "ws.atlassian.net", + "projectKey": "PROJ", + "summary": "Improve perf", + }, + "id": "3", + }, + ) + ] + } + out = mw.after_model(state, _Runtime()) + assert out is None # nothing dropped — all three differ in summary + + +def test_full_args_dedup_drops_only_exact_duplicates() -> None: + from app.agents.new_chat.middleware.dedup_tool_calls import dedup_key_full_args + + tool = _make_tool("createJiraIssue", dedup_key=dedup_key_full_args) + mw = DedupHITLToolCallsMiddleware(agent_tools=[tool]) + args = {"cloudId": "ws.atlassian.net", "summary": "Fix bug"} + state = { + "messages": [ + _msg( + {"name": "createJiraIssue", "args": args, "id": "1"}, + {"name": "createJiraIssue", "args": dict(args), "id": "2"}, + { + "name": "createJiraIssue", + "args": {**args, "summary": "Different"}, + "id": "3", + }, + ) + ] + } + out = mw.after_model(state, _Runtime()) + assert out is not None + new_calls = out["messages"][0].tool_calls + assert {c["id"] for c in new_calls} == {"1", "3"} + + def test_unknown_tool_passes_through() -> None: mw = DedupHITLToolCallsMiddleware(agent_tools=None) state = { diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_hitl_auto_approve.py b/surfsense_backend/tests/unit/agents/new_chat/test_hitl_auto_approve.py index 0bbdf37bf..d0ea73376 100644 --- a/surfsense_backend/tests/unit/agents/new_chat/test_hitl_auto_approve.py +++ b/surfsense_backend/tests/unit/agents/new_chat/test_hitl_auto_approve.py @@ -27,6 +27,7 @@ class TestDefaultAutoApprovedToolsList: expected = { "create_gmail_draft", "update_gmail_draft", + "create_calendar_event", "create_notion_page", "create_confluence_page", "create_google_drive_file", @@ -41,13 +42,12 @@ class TestDefaultAutoApprovedToolsList: assert isinstance(DEFAULT_AUTO_APPROVED_TOOLS, frozenset) def test_send_tools_are_not_auto_approved(self) -> None: - # External-broadcast tools must always prompt. + # External-broadcast / destructive tools must always prompt. for tool_name in ( "send_gmail_email", "send_discord_message", "send_teams_message", "delete_notion_page", - "create_calendar_event", "delete_calendar_event", ): assert tool_name not in DEFAULT_AUTO_APPROVED_TOOLS, ( diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_memory_response_content.py b/surfsense_backend/tests/unit/agents/new_chat/test_memory_response_content.py new file mode 100644 index 000000000..1f338ee3e --- /dev/null +++ b/surfsense_backend/tests/unit/agents/new_chat/test_memory_response_content.py @@ -0,0 +1,89 @@ +"""Unit tests for extracting text from LLM memory responses.""" + +import pytest + +from app.agents.new_chat.tools.update_memory import _save_memory +from app.utils.content_utils import extract_text_content + +pytestmark = pytest.mark.unit + + +class _Recorder: + def __init__(self) -> None: + self.applied_content: str | None = None + self.commit_calls = 0 + self.rollback_calls = 0 + + def apply(self, content: str) -> None: + self.applied_content = content + + async def commit(self) -> None: + self.commit_calls += 1 + + async def rollback(self) -> None: + self.rollback_calls += 1 + + +def test_extract_text_content_keeps_no_update_bare_string_from_content_blocks() -> None: + content = [ + {"type": "thinking", "thinking": "No"}, + {"type": "thinking", "thinking": " memorizable info."}, + "NO_UPDATE", + ] + + assert extract_text_content(content).strip() == "NO_UPDATE" + + +def test_extract_text_content_ignores_thinking_blocks_and_keeps_markdown_text() -> None: + markdown = ( + "## Work Context\n" + "- (2026-05-02) [fact] Anish is hardening SurfSense memory extraction.\n" + ) + content = [ + {"type": "thinking", "thinking": "This is durable context."}, + {"type": "text", "text": markdown}, + ] + + assert extract_text_content(content).strip() == markdown.strip() + + +def test_extract_text_content_returns_empty_when_only_thinking_blocks_are_present() -> ( + None +): + content = [ + {"type": "thinking", "thinking": "No durable fact."}, + {"type": "thinking", "thinking": "Return no update."}, + ] + + assert extract_text_content(content) == "" + + +def test_extract_text_content_preserves_plain_string_responses() -> None: + markdown = ( + "## Preferences\n" + "- (2026-05-02) [pref] Anish prefers no regex for memory validation.\n" + ) + + assert extract_text_content(markdown) == markdown + + +@pytest.mark.asyncio +async def test_save_memory_rejects_non_string_payload_before_commit() -> None: + recorder = _Recorder() + + result = await _save_memory( + updated_memory=["NO_UPDATE"], # type: ignore[arg-type] + old_memory=None, + llm=None, + apply_fn=recorder.apply, + commit_fn=recorder.commit, + rollback_fn=recorder.rollback, + label="memory", + scope="user", + ) + + assert result["status"] == "error" + assert "must be a string" in result["message"] + assert recorder.applied_content is None + assert recorder.commit_calls == 0 + assert recorder.rollback_calls == 0 diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_permission_middleware.py b/surfsense_backend/tests/unit/agents/new_chat/test_permission_middleware.py index a997c8d61..47059ade6 100644 --- a/surfsense_backend/tests/unit/agents/new_chat/test_permission_middleware.py +++ b/surfsense_backend/tests/unit/agents/new_chat/test_permission_middleware.py @@ -6,7 +6,10 @@ import pytest from langchain_core.messages import AIMessage, ToolMessage from app.agents.new_chat.errors import CorrectedError, RejectedError -from app.agents.new_chat.middleware.permission import PermissionMiddleware +from app.agents.new_chat.middleware.permission import ( + PermissionMiddleware, + _normalize_permission_decision, +) from app.agents.new_chat.permissions import Rule, Ruleset pytestmark = pytest.mark.unit @@ -112,3 +115,149 @@ class TestAsk: # Runtime ruleset got the always-allow rule new_rules = [r for r in mw._runtime_ruleset.rules if r.action == "allow"] assert any(r.permission == "send_email" for r in new_rules) + + +class TestNormalizeDecision: + """Resume shapes ``_normalize_permission_decision`` must accept.""" + + def test_legacy_decision_type_dict_passes_through(self) -> None: + decision = {"decision_type": "once"} + assert _normalize_permission_decision(decision) == {"decision_type": "once"} + + def test_legacy_decision_type_with_feedback_passes_through(self) -> None: + decision = {"decision_type": "reject", "feedback": "no thanks"} + assert _normalize_permission_decision(decision) == decision + + def test_plain_string_wrapped(self) -> None: + assert _normalize_permission_decision("once") == {"decision_type": "once"} + assert _normalize_permission_decision("reject") == {"decision_type": "reject"} + + def test_lc_envelope_approve_maps_to_once(self) -> None: + decision = {"decisions": [{"type": "approve"}]} + assert _normalize_permission_decision(decision) == {"decision_type": "once"} + + def test_lc_envelope_reject_maps_to_reject(self) -> None: + decision = {"decisions": [{"type": "reject"}]} + assert _normalize_permission_decision(decision) == {"decision_type": "reject"} + + def test_lc_envelope_reject_with_message_carries_feedback(self) -> None: + decision = {"decisions": [{"type": "reject", "message": "wrong recipient"}]} + out = _normalize_permission_decision(decision) + assert out == {"decision_type": "reject", "feedback": "wrong recipient"} + + def test_lc_envelope_reject_with_feedback_field(self) -> None: + decision = { + "decisions": [{"type": "reject", "feedback": "tighten the subject"}] + } + out = _normalize_permission_decision(decision) + assert out == {"decision_type": "reject", "feedback": "tighten the subject"} + + def test_lc_envelope_edit_maps_to_once(self) -> None: + # Pins the contract: edited args are NOT merged by permission. + decision = { + "decisions": [ + { + "type": "edit", + "edited_action": { + "name": "send_email", + "args": {"subject": "edited"}, + }, + } + ] + } + assert _normalize_permission_decision(decision) == {"decision_type": "once"} + + def test_lc_single_decision_without_envelope(self) -> None: + assert _normalize_permission_decision({"type": "approve"}) == { + "decision_type": "once" + } + + def test_unknown_type_falls_back_to_reject(self) -> None: + decision = {"decisions": [{"type": "totally_unknown"}]} + assert _normalize_permission_decision(decision) == {"decision_type": "reject"} + + def test_missing_type_falls_back_to_reject(self) -> None: + assert _normalize_permission_decision({"decisions": [{}]}) == { + "decision_type": "reject" + } + + def test_non_dict_non_string_falls_back_to_reject(self) -> None: + assert _normalize_permission_decision(None) == {"decision_type": "reject"} + assert _normalize_permission_decision(42) == {"decision_type": "reject"} + + def test_empty_decisions_list_falls_back_to_reject(self) -> None: + # Fail-closed on a malformed reply rather than treat it as approve. + assert _normalize_permission_decision({"decisions": []}) == { + "decision_type": "reject" + } + + +class TestResumeShapesEndToEnd: + """LangChain HITL envelope reaches ``_process`` correctly via ``_raise_interrupt``.""" + + def test_lc_approve_envelope_keeps_call(self) -> None: + mw = PermissionMiddleware(rulesets=[]) + mw._raise_interrupt = lambda **kw: { # type: ignore[assignment] + "decisions": [{"type": "approve"}] + } + state = {"messages": [_msg({"name": "send_email", "args": {}, "id": "1"})]} + original = mw._raise_interrupt + mw._raise_interrupt = lambda **kw: _normalize_permission_decision( # type: ignore[assignment] + original(**kw) + ) + out = mw.after_model(state, _FakeRuntime()) + assert out is None + + def test_lc_reject_envelope_raises(self) -> None: + mw = PermissionMiddleware(rulesets=[]) + original = lambda **kw: {"decisions": [{"type": "reject"}]} # noqa: E731 + mw._raise_interrupt = lambda **kw: _normalize_permission_decision( # type: ignore[assignment] + original(**kw) + ) + state = {"messages": [_msg({"name": "send_email", "args": {}, "id": "1"})]} + with pytest.raises(RejectedError): + mw.after_model(state, _FakeRuntime()) + + def test_lc_reject_with_message_raises_corrected(self) -> None: + mw = PermissionMiddleware(rulesets=[]) + original = lambda **kw: { # noqa: E731 + "decisions": [{"type": "reject", "message": "wrong recipient"}] + } + mw._raise_interrupt = lambda **kw: _normalize_permission_decision( # type: ignore[assignment] + original(**kw) + ) + state = {"messages": [_msg({"name": "send_email", "args": {}, "id": "1"})]} + with pytest.raises(CorrectedError) as excinfo: + mw.after_model(state, _FakeRuntime()) + assert excinfo.value.feedback == "wrong recipient" + + def test_lc_edit_envelope_keeps_call_with_original_args(self) -> None: + # Pins the "edit -> once, args unchanged" contract. + mw = PermissionMiddleware(rulesets=[]) + original = lambda **kw: { # noqa: E731 + "decisions": [ + { + "type": "edit", + "edited_action": { + "name": "send_email", + "args": {"to": "edited@example.com"}, + }, + } + ] + } + mw._raise_interrupt = lambda **kw: _normalize_permission_decision( # type: ignore[assignment] + original(**kw) + ) + state = { + "messages": [ + _msg( + { + "name": "send_email", + "args": {"to": "original@example.com"}, + "id": "1", + } + ) + ] + } + out = mw.after_model(state, _FakeRuntime()) + assert out is None diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_specialized_subagents.py b/surfsense_backend/tests/unit/agents/new_chat/test_specialized_subagents.py index 0adb578ce..3035cc8e0 100644 --- a/surfsense_backend/tests/unit/agents/new_chat/test_specialized_subagents.py +++ b/surfsense_backend/tests/unit/agents/new_chat/test_specialized_subagents.py @@ -210,10 +210,16 @@ class TestConnectorNegotiatorSubagent: class TestBuildSpecializedSubagents: - def test_returns_three_specs(self) -> None: + def test_returns_five_specs(self) -> None: specs = build_specialized_subagents(tools=ALL_TOOLS) names = [s["name"] for s in specs] # type: ignore[index] - assert names == ["explore", "report_writer", "connector_negotiator"] + assert names == [ + "explore", + "report_writer", + "linear_specialist", + "slack_specialist", + "connector_negotiator", + ] def test_all_specs_have_unique_names(self) -> None: specs = build_specialized_subagents(tools=ALL_TOOLS) diff --git a/surfsense_backend/tests/unit/middleware/test_knowledge_search.py b/surfsense_backend/tests/unit/middleware/test_knowledge_search.py index 2933a0504..3529a946b 100644 --- a/surfsense_backend/tests/unit/middleware/test_knowledge_search.py +++ b/surfsense_backend/tests/unit/middleware/test_knowledge_search.py @@ -202,6 +202,15 @@ class FakeBudgetLLM: class TestKnowledgeBaseSearchMiddlewarePlanner: + @pytest.fixture(autouse=True) + def _disable_planner_runnable(self, monkeypatch): + # ``FakeLLM`` is a duck-typed mock; ``create_agent`` (used when the + # planner Runnable path is enabled) calls ``.bind()`` on the LLM, + # which the mock does not implement. Pin the flag off so the + # planner falls through to the legacy ``self.llm.ainvoke`` path + # these tests assert against (``llm.calls[0]["config"]``). + monkeypatch.setenv("SURFSENSE_ENABLE_KB_PLANNER_RUNNABLE", "false") + def test_render_recent_conversation_prefers_latest_messages_under_budget(self): messages = [ HumanMessage(content="old user context " * 40), diff --git a/surfsense_backend/uv.lock b/surfsense_backend/uv.lock index 4dd5156e7..812be636a 100644 --- a/surfsense_backend/uv.lock +++ b/surfsense_backend/uv.lock @@ -7947,7 +7947,7 @@ wheels = [ [[package]] name = "surf-new-backend" -version = "0.0.22" +version = "0.0.23" source = { editable = "." } dependencies = [ { name = "alembic" }, diff --git a/surfsense_browser_extension/package.json b/surfsense_browser_extension/package.json index b8b5cb2ec..82c0a349a 100644 --- a/surfsense_browser_extension/package.json +++ b/surfsense_browser_extension/package.json @@ -1,7 +1,7 @@ { "name": "surfsense_browser_extension", "displayName": "Surfsense Browser Extension", - "version": "0.0.22", + "version": "0.0.23", "description": "Extension to collect Browsing History for SurfSense.", "author": "https://github.com/MODSetter", "engines": { diff --git a/surfsense_desktop/package.json b/surfsense_desktop/package.json index 744ab65ab..4ef624760 100644 --- a/surfsense_desktop/package.json +++ b/surfsense_desktop/package.json @@ -1,6 +1,6 @@ { "name": "surfsense-desktop", - "version": "0.0.22", + "version": "0.0.23", "description": "SurfSense Desktop App", "main": "dist/main.js", "scripts": { diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index f2bae4167..9b5510df3 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -73,24 +73,24 @@ import { createStreamFlushHelpers } from "@/lib/chat/stream-flush"; import { consumeSseEvents, hasPersistableContent, + markInterruptsCompleted, processSharedStreamEvent, } from "@/lib/chat/stream-pipeline"; import { - applyInterruptRequestToContentParts, applyTurnIdToAssistantMessageList, - markInterruptDecisionOnContentParts, mergeChatTurnIdIntoMessage, - mergeEditedInterruptAction, readStreamedChatTurnId, readStreamedMessageId, } from "@/lib/chat/stream-side-effects"; import { + addToolCall, buildContentForPersistence, buildContentForUI, type ContentPartsState, type FrameBatchedUpdater, type ThinkingStepData, type ToolUIGate, + updateToolCall, } from "@/lib/chat/streaming-state"; import { appendMessage, @@ -107,6 +107,7 @@ import { type NewChatUserImagePayload, } from "@/lib/chat/user-turn-api-parts"; import { NotFoundError } from "@/lib/error"; +import { type BundleSubmit, HitlBundleProvider } from "@/lib/hitl"; import { trackChatBlocked, trackChatCreated, @@ -138,6 +139,62 @@ const MobileReportPanel = dynamic( { ssr: false } ); +/** + * Generate a synthetic ``toolCallId`` for an action_request that has no + * matching streamed tool-call card (HITL-blocked subagent calls don't surface + * as tool-call events). Suffixes a counter when the base id is already taken + * — sequential interrupts for the same tool name otherwise collide on + * ``interrupt-${name}-${i}`` and crash assistant-ui with a duplicate-key error. + */ +function freshSynthToolCallId( + toolCallIndices: Map, + toolName: string, + index: number +): string { + const base = `interrupt-${toolName}-${index}`; + if (!toolCallIndices.has(base)) return base; + let n = 1; + while (toolCallIndices.has(`${base}-${n}`)) n++; + return `${base}-${n}`; +} + +/** + * Pair each ``action_request`` to a unique pending tool-call card, preserving + * order so ``decisions[i]`` lines up with ``action_requests[i]`` on the wire. + * + * Same-name bundles (e.g. three ``create_jira_issue``) used to collapse onto + * one card because the matcher keyed by name; this consumes each card via the + * ``claimed`` set and walks forward in DOM order. + */ +function pairBundleToolCallIds( + toolCallIndices: Map, + contentParts: Array<{ + type: string; + toolName?: string; + result?: unknown; + }>, + actionRequests: ReadonlyArray<{ name: string }> +): Array { + const claimed = new Set(); + const paired: Array = []; + for (const action of actionRequests) { + let matched: string | null = null; + for (const [tcId, idx] of toolCallIndices) { + if (claimed.has(tcId)) continue; + const part = contentParts[idx]; + if (!part || part.type !== "tool-call" || part.toolName !== action.name) continue; + const result = part.result as Record | undefined | null; + if (result == null || (result.__interrupt__ === true && !result.__decided__)) { + matched = tcId; + claimed.add(tcId); + break; + } + } + paired.push(matched); + } + return paired; +} + /** * Zod schema for mentioned document info (for type-safe parsing) */ @@ -209,6 +266,7 @@ export default function NewChatPage() { threadId: number; assistantMsgId: string; interruptData: Record; + bundleToolCallIds: string[]; } | null>(null); const toolsWithUI = TOOLS_WITH_UI_ALL; const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom); @@ -1068,7 +1126,39 @@ export default function NewChatPage() { case "data-interrupt-request": { wasInterrupted = true; const interruptData = parsed.data as Record; - applyInterruptRequestToContentParts(contentPartsState, toolsWithUI, interruptData); + const actionRequests = (interruptData.action_requests ?? []) as Array<{ + name: string; + args: Record; + }>; + const paired = pairBundleToolCallIds( + contentPartsState.toolCallIndices, + contentPartsState.contentParts, + actionRequests + ); + const bundleToolCallIds: string[] = []; + for (let i = 0; i < actionRequests.length; i++) { + const action = actionRequests[i]; + let targetTcId = paired[i]; + if (!targetTcId) { + targetTcId = freshSynthToolCallId( + contentPartsState.toolCallIndices, + action.name, + i + ); + addToolCall( + contentPartsState, + toolsWithUI, + targetTcId, + action.name, + action.args, + true + ); + } + updateToolCall(contentPartsState, targetTcId, { + result: { __interrupt__: true, ...interruptData }, + }); + bundleToolCallIds.push(targetTcId); + } setMessages((prev) => prev.map((m) => m.id === assistantMsgId @@ -1081,6 +1171,7 @@ export default function NewChatPage() { threadId: currentThreadId, assistantMsgId, interruptData, + bundleToolCallIds, }); } break; @@ -1127,10 +1218,7 @@ export default function NewChatPage() { setMessages((prev) => prev.map((m) => m.id === oldUserMsgId - ? mergeChatTurnIdIntoMessage( - { ...m, id: newUserMsgId }, - parsedMsg.turnId - ) + ? mergeChatTurnIdIntoMessage({ ...m, id: newUserMsgId }, parsedMsg.turnId) : m ) ); @@ -1172,10 +1260,7 @@ export default function NewChatPage() { setMessages((prev) => prev.map((m) => m.id === oldAssistantMsgId - ? mergeChatTurnIdIntoMessage( - { ...m, id: newAssistantMsgId }, - parsedMsg.turnId - ) + ? mergeChatTurnIdIntoMessage({ ...m, id: newAssistantMsgId }, parsedMsg.turnId) : m ) ); @@ -1342,13 +1427,38 @@ export default function NewChatPage() { } } - // Merge edited args if present to fix race condition - if (decisions.length > 0 && decisions[0].type === "edit") { - mergeEditedInterruptAction(contentParts, decisions[0].edited_action); + // Apply each decision to its own card by toolCallId so mixed + // bundles (approve/edit/reject) and multi-edit bundles do not + // collapse onto ``decisions[0]``. Cards outside the bundle are + // untouched. Mirrors the host ``hitl-decision`` handler. + const decisionByTcId = new Map(); + const tcIds = pendingInterrupt.bundleToolCallIds; + if (decisions.length === tcIds.length) { + for (let i = 0; i < tcIds.length; i++) decisionByTcId.set(tcIds[i], decisions[i]); + } + if (decisionByTcId.size > 0) { + for (const part of contentParts) { + if (part.type !== "tool-call") continue; + const tcId = part.toolCallId as string | undefined; + const d = tcId ? decisionByTcId.get(tcId) : undefined; + if (!d) continue; + if (typeof part.result !== "object" || part.result === null) continue; + if (!("__interrupt__" in (part.result as Record))) continue; + const decided = d.type as "approve" | "reject" | "edit"; + if (decided === "edit" && d.edited_action) { + const mergedArgs = { ...part.args, ...d.edited_action.args }; + part.args = mergedArgs; + // Sync argsText so the rendered card shows the + // edited inputs (assistant-ui prefers it over + // JSON.stringify(args)). + part.argsText = JSON.stringify(mergedArgs, null, 2); + } + part.result = { + ...(part.result as Record), + __decided__: decided, + }; + } } - - const decisionType = decisions[0]?.type as "approve" | "reject" | undefined; - markInterruptDecisionOnContentParts(contentParts, decisionType); try { const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"; @@ -1365,6 +1475,7 @@ export default function NewChatPage() { body: JSON.stringify({ search_space_id: searchSpaceId, decisions, + disabled_tools: disabledTools.length > 0 ? disabledTools : undefined, filesystem_mode: selection.filesystem_mode, client_platform: selection.client_platform, local_filesystem_mounts: selection.local_filesystem_mounts, @@ -1413,7 +1524,39 @@ export default function NewChatPage() { switch (parsed.type) { case "data-interrupt-request": { const interruptData = parsed.data as Record; - applyInterruptRequestToContentParts(contentPartsState, toolsWithUI, interruptData); + const actionRequests = (interruptData.action_requests ?? []) as Array<{ + name: string; + args: Record; + }>; + const paired = pairBundleToolCallIds( + contentPartsState.toolCallIndices, + contentPartsState.contentParts, + actionRequests + ); + const bundleToolCallIds: string[] = []; + for (let i = 0; i < actionRequests.length; i++) { + const action = actionRequests[i]; + let targetTcId = paired[i]; + if (!targetTcId) { + targetTcId = freshSynthToolCallId( + contentPartsState.toolCallIndices, + action.name, + i + ); + addToolCall( + contentPartsState, + toolsWithUI, + targetTcId, + action.name, + action.args, + true + ); + } + updateToolCall(contentPartsState, targetTcId, { + result: { __interrupt__: true, ...interruptData }, + }); + bundleToolCallIds.push(targetTcId); + } setMessages((prev) => prev.map((m) => m.id === assistantMsgId @@ -1425,6 +1568,7 @@ export default function NewChatPage() { threadId: resumeThreadId, assistantMsgId, interruptData, + bundleToolCallIds, }); break; } @@ -1470,10 +1614,7 @@ export default function NewChatPage() { setMessages((prev) => prev.map((m) => m.id === oldAssistantMsgId - ? mergeChatTurnIdIntoMessage( - { ...m, id: newAssistantMsgId }, - parsedMsg.turnId - ) + ? mergeChatTurnIdIntoMessage({ ...m, id: newAssistantMsgId }, parsedMsg.turnId) : m ) ); @@ -1510,6 +1651,7 @@ export default function NewChatPage() { messages, searchSpaceId, localFilesystemEnabled, + disabledTools, queryClient, tokenUsageStore, fetchWithTurnCancellingRetry, @@ -1526,57 +1668,119 @@ export default function NewChatPage() { edited_action?: { name: string; args: Record }; }>; }; - if (detail?.decisions && pendingInterrupt) { - const decision = detail.decisions[0]; - const decisionType = decision?.type as "approve" | "reject" | "edit"; + if (!detail?.decisions || !pendingInterrupt) return; + const incoming = detail.decisions; + if (incoming.length === 0) return; + const tcIds = pendingInterrupt.bundleToolCallIds; + const N = tcIds.length; - setMessages((prev) => - prev.map((m) => { - if (m.id !== pendingInterrupt.assistantMsgId) return m; - const parts = m.content as unknown as Array>; - const newContent = parts.map((part) => { - if ( - part.type === "tool-call" && - typeof part.result === "object" && - part.result !== null && - "__interrupt__" in part.result - ) { - // For edit decisions, also update the displayed args - if (decisionType === "edit" && decision.edited_action) { - return { - ...part, - args: decision.edited_action.args, // Update displayed args - // Sync argsText so the rendered card shows - // the edited inputs — assistant-ui prefers - // caller-supplied argsText over - // JSON.stringify(args). - argsText: JSON.stringify(decision.edited_action.args, null, 2), - result: { - ...(part.result as Record), - __decided__: decisionType, - }, - }; - } - return { - ...part, - result: { - ...(part.result as Record), - __decided__: decisionType, - }, - }; - } - return part; - }); - return { ...m, content: newContent as unknown as ThreadMessageLike["content"] }; - }) + // Bundles must submit exactly one decision per action_request. + // Refuse rather than silently broadcast a single decision across + // the bundle (would mis-apply rejects/edits and diverge from + // what handleResume sends to /resume). + if (N > 1 && incoming.length !== N) { + toast.error( + `Cannot resume: ${incoming.length} decision(s) submitted for ${N} pending actions.` ); - handleResume(detail.decisions); + return; } + + const byTcId = new Map(); + for (let i = 0; i < tcIds.length; i++) byTcId.set(tcIds[i], incoming[i]); + const submittedDecisions = tcIds.map((id) => byTcId.get(id)!); + + setMessages((prev) => + prev.map((m) => { + if (m.id !== pendingInterrupt.assistantMsgId) return m; + const parts = m.content as unknown as Array>; + const newContent = parts.map((part) => { + const tcId = part.toolCallId as string | undefined; + const d = tcId ? byTcId.get(tcId) : undefined; + if (!d || part.type !== "tool-call") return part; + if (typeof part.result !== "object" || part.result === null) return part; + if (!("__interrupt__" in (part.result as Record))) return part; + const decided = d.type as "approve" | "reject" | "edit"; + if (decided === "edit" && d.edited_action) { + return { + ...part, + args: d.edited_action.args, + // Sync argsText so the card renders the edited + // inputs (assistant-ui prefers it over JSON.stringify). + argsText: JSON.stringify(d.edited_action.args, null, 2), + result: { + ...(part.result as Record), + __decided__: decided, + }, + }; + } + return { + ...part, + result: { + ...(part.result as Record), + __decided__: decided, + }, + }; + }); + return { ...m, content: newContent as unknown as ThreadMessageLike["content"] }; + }) + ); + handleResume(submittedDecisions); }; window.addEventListener("hitl-decision", handler); return () => window.removeEventListener("hitl-decision", handler); }, [handleResume, pendingInterrupt]); + // Mirror staged bundle decisions onto the cards visually so prev/next nav + // reflects past choices instead of re-prompting. Submit's ``hitl-decision`` + // handler still runs the actual resume. + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail as { + toolCallId: string; + decision: { + type: string; + message?: string; + edited_action?: { name: string; args: Record }; + }; + }; + if (!detail?.toolCallId || !detail?.decision || !pendingInterrupt) return; + setMessages((prev) => + prev.map((m) => { + if (m.id !== pendingInterrupt.assistantMsgId) return m; + const parts = m.content as unknown as Array>; + const newContent = parts.map((part) => { + if (part.toolCallId !== detail.toolCallId) return part; + if (part.type !== "tool-call") return part; + if (typeof part.result !== "object" || part.result === null) return part; + if (!("__interrupt__" in (part.result as Record))) return part; + const decided = detail.decision.type as "approve" | "reject" | "edit"; + if (decided === "edit" && detail.decision.edited_action) { + return { + ...part, + args: detail.decision.edited_action.args, + argsText: JSON.stringify(detail.decision.edited_action.args, null, 2), + result: { + ...(part.result as Record), + __decided__: decided, + }, + }; + } + return { + ...part, + result: { + ...(part.result as Record), + __decided__: decided, + }, + }; + }); + return { ...m, content: newContent as unknown as ThreadMessageLike["content"] }; + }) + ); + }; + window.addEventListener("hitl-stage", handler); + return () => window.removeEventListener("hitl-stage", handler); + }, [pendingInterrupt]); + // Convert message (pass through since already in correct format) const convertMessage = useCallback( (message: ThreadMessageLike): ThreadMessageLike => message, @@ -1860,10 +2064,7 @@ export default function NewChatPage() { setMessages((prev) => prev.map((m) => m.id === oldUserMsgId - ? mergeChatTurnIdIntoMessage( - { ...m, id: newUserMsgId }, - parsedMsg.turnId - ) + ? mergeChatTurnIdIntoMessage({ ...m, id: newUserMsgId }, parsedMsg.turnId) : m ) ); @@ -1889,10 +2090,7 @@ export default function NewChatPage() { setMessages((prev) => prev.map((m) => m.id === oldAssistantMsgId - ? mergeChatTurnIdIntoMessage( - { ...m, id: newAssistantMsgId }, - parsedMsg.turnId - ) + ? mergeChatTurnIdIntoMessage({ ...m, id: newAssistantMsgId }, parsedMsg.turnId) : m ) ); @@ -2081,6 +2279,12 @@ export default function NewChatPage() { [handleRegenerate, messages, agentActionItems] ); + const handleBundleSubmit = useCallback((orderedDecisions) => { + window.dispatchEvent( + new CustomEvent("hitl-decision", { detail: { decisions: orderedDecisions } }) + ); + }, []); + const handleEditDialogChoice = useCallback( async (choice: EditMessageDialogChoice) => { const pending = editDialogState; @@ -2151,14 +2355,19 @@ export default function NewChatPage() { -
-
- + +
+
+ +
+ + +
- - - -
+ { diff --git a/surfsense_web/app/dashboard/[search_space_id]/purchase-success/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/purchase-success/page.tsx index b3d504ed5..8eaec3e5a 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/purchase-success/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/purchase-success/page.tsx @@ -119,8 +119,7 @@ export default function PurchaseSuccessPage() { "Stripe reported the checkout as failed or expired. Your card was not charged."} {state.kind === "error" && "Don't worry — if your card was charged, your purchase will still apply within a minute or two."} - {state.kind === "no_session" && - "Your purchase is being applied to your account."} + {state.kind === "no_session" && "Your purchase is being applied to your account."} @@ -134,7 +133,8 @@ export default function PurchaseSuccessPage() { )} {state.kind === "completed" && state.data.purchase_type === "premium_tokens" && (

- New premium credit balance: {formatCredit(state.data.premium_credit_micros_limit ?? 0)} + New premium credit balance:{" "} + {formatCredit(state.data.premium_credit_micros_limit ?? 0)}

)} {state.kind === "error" && ( diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 3b9d9a526..7bccc22ee 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -40,6 +40,7 @@ import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container"; import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet"; +import { withBundleStep } from "@/components/hitl-bundle-pager"; import type { SerializableCitation } from "@/components/tool-ui/citation"; import { openSafeNavigationHref, @@ -502,6 +503,51 @@ const MessageInfoDropdown: FC = () => { ); }; +// Wrap each tool-ui card with ``withBundleStep`` so multi-card HITL bundles +// page through them and stage decisions instead of firing one resume per card. +const TOOLS_BY_NAME = { + generate_report: withBundleStep(GenerateReportToolUI), + generate_resume: withBundleStep(GenerateResumeToolUI), + generate_podcast: withBundleStep(GeneratePodcastToolUI), + generate_video_presentation: withBundleStep(GenerateVideoPresentationToolUI), + display_image: withBundleStep(GenerateImageToolUI), + generate_image: withBundleStep(GenerateImageToolUI), + update_memory: withBundleStep(UpdateMemoryToolUI), + execute: withBundleStep(SandboxExecuteToolUI), + execute_code: withBundleStep(SandboxExecuteToolUI), + create_notion_page: withBundleStep(CreateNotionPageToolUI), + update_notion_page: withBundleStep(UpdateNotionPageToolUI), + delete_notion_page: withBundleStep(DeleteNotionPageToolUI), + create_linear_issue: withBundleStep(CreateLinearIssueToolUI), + update_linear_issue: withBundleStep(UpdateLinearIssueToolUI), + delete_linear_issue: withBundleStep(DeleteLinearIssueToolUI), + create_google_drive_file: withBundleStep(CreateGoogleDriveFileToolUI), + delete_google_drive_file: withBundleStep(DeleteGoogleDriveFileToolUI), + create_onedrive_file: withBundleStep(CreateOneDriveFileToolUI), + delete_onedrive_file: withBundleStep(DeleteOneDriveFileToolUI), + create_dropbox_file: withBundleStep(CreateDropboxFileToolUI), + delete_dropbox_file: withBundleStep(DeleteDropboxFileToolUI), + create_calendar_event: withBundleStep(CreateCalendarEventToolUI), + update_calendar_event: withBundleStep(UpdateCalendarEventToolUI), + delete_calendar_event: withBundleStep(DeleteCalendarEventToolUI), + create_gmail_draft: withBundleStep(CreateGmailDraftToolUI), + update_gmail_draft: withBundleStep(UpdateGmailDraftToolUI), + send_gmail_email: withBundleStep(SendGmailEmailToolUI), + trash_gmail_email: withBundleStep(TrashGmailEmailToolUI), + create_jira_issue: withBundleStep(CreateJiraIssueToolUI), + update_jira_issue: withBundleStep(UpdateJiraIssueToolUI), + delete_jira_issue: withBundleStep(DeleteJiraIssueToolUI), + create_confluence_page: withBundleStep(CreateConfluencePageToolUI), + update_confluence_page: withBundleStep(UpdateConfluencePageToolUI), + delete_confluence_page: withBundleStep(DeleteConfluencePageToolUI), + web_search: () => null, + link_preview: () => null, + multi_link_preview: () => null, + scrape_webpage: () => null, +} as const; + +const TOOLS_FALLBACK = withBundleStep(ToolFallback); + const AssistantMessageInner: FC = () => { const isMobile = !useMediaQuery("(min-width: 768px)"); @@ -513,47 +559,8 @@ const AssistantMessageInner: FC = () => { Text: MarkdownText, Reasoning: ReasoningMessagePart, tools: { - by_name: { - generate_report: GenerateReportToolUI, - generate_resume: GenerateResumeToolUI, - generate_podcast: GeneratePodcastToolUI, - generate_video_presentation: GenerateVideoPresentationToolUI, - display_image: GenerateImageToolUI, - generate_image: GenerateImageToolUI, - update_memory: UpdateMemoryToolUI, - execute: SandboxExecuteToolUI, - execute_code: SandboxExecuteToolUI, - create_notion_page: CreateNotionPageToolUI, - update_notion_page: UpdateNotionPageToolUI, - delete_notion_page: DeleteNotionPageToolUI, - create_linear_issue: CreateLinearIssueToolUI, - update_linear_issue: UpdateLinearIssueToolUI, - delete_linear_issue: DeleteLinearIssueToolUI, - create_google_drive_file: CreateGoogleDriveFileToolUI, - delete_google_drive_file: DeleteGoogleDriveFileToolUI, - create_onedrive_file: CreateOneDriveFileToolUI, - delete_onedrive_file: DeleteOneDriveFileToolUI, - create_dropbox_file: CreateDropboxFileToolUI, - delete_dropbox_file: DeleteDropboxFileToolUI, - create_calendar_event: CreateCalendarEventToolUI, - update_calendar_event: UpdateCalendarEventToolUI, - delete_calendar_event: DeleteCalendarEventToolUI, - create_gmail_draft: CreateGmailDraftToolUI, - update_gmail_draft: UpdateGmailDraftToolUI, - send_gmail_email: SendGmailEmailToolUI, - trash_gmail_email: TrashGmailEmailToolUI, - create_jira_issue: CreateJiraIssueToolUI, - update_jira_issue: UpdateJiraIssueToolUI, - delete_jira_issue: DeleteJiraIssueToolUI, - create_confluence_page: CreateConfluencePageToolUI, - update_confluence_page: UpdateConfluencePageToolUI, - delete_confluence_page: DeleteConfluencePageToolUI, - web_search: () => null, - link_preview: () => null, - multi_link_preview: () => null, - scrape_webpage: () => null, - }, - Fallback: ToolFallback, + by_name: TOOLS_BY_NAME, + Fallback: TOOLS_FALLBACK, }, }} /> diff --git a/surfsense_web/components/editor/plate-editor.tsx b/surfsense_web/components/editor/plate-editor.tsx index c42cb991e..51ad7d700 100644 --- a/surfsense_web/components/editor/plate-editor.tsx +++ b/surfsense_web/components/editor/plate-editor.tsx @@ -11,6 +11,7 @@ import { EditorSaveContext } from "@/components/editor/editor-save-context"; import { CitationKit, injectCitationNodes } from "@/components/editor/plugins/citation-kit"; import { type EditorPreset, presetMap } from "@/components/editor/presets"; import { escapeMdxExpressions } from "@/components/editor/utils/escape-mdx"; +import { safeDeserializeMarkdown } from "@/components/editor/utils/safe-deserialize"; import { Editor, EditorContainer } from "@/components/ui/editor"; import { preprocessCitationMarkdown } from "@/lib/citations/citation-parser"; @@ -169,15 +170,17 @@ export function PlateEditor({ : markdown ? (editor) => { if (!enableCitations) { - return editor - .getApi(MarkdownPlugin) - .markdown.deserialize(escapeMdxExpressions(markdown)); + return safeDeserializeMarkdown( + editor, + escapeMdxExpressions(markdown) + ) as Value; } const { content: rewritten, urlMap } = preprocessCitationMarkdown(markdown); - const value = editor - .getApi(MarkdownPlugin) - .markdown.deserialize(escapeMdxExpressions(rewritten)); - return injectCitationNodes(value as Descendant[], urlMap) as Value; + const value = safeDeserializeMarkdown( + editor, + escapeMdxExpressions(rewritten) + ); + return injectCitationNodes(value, urlMap) as Value; } : undefined, }); @@ -200,14 +203,13 @@ export function PlateEditor({ let newValue: Descendant[]; if (enableCitations) { const { content: rewritten, urlMap } = preprocessCitationMarkdown(markdown); - const deserialized = editor - .getApi(MarkdownPlugin) - .markdown.deserialize(escapeMdxExpressions(rewritten)) as Descendant[]; + const deserialized = safeDeserializeMarkdown( + editor, + escapeMdxExpressions(rewritten) + ); newValue = injectCitationNodes(deserialized, urlMap); } else { - newValue = editor - .getApi(MarkdownPlugin) - .markdown.deserialize(escapeMdxExpressions(markdown)) as Descendant[]; + newValue = safeDeserializeMarkdown(editor, escapeMdxExpressions(markdown)); } editor.tf.reset(); editor.tf.setValue(newValue as Value); diff --git a/surfsense_web/components/editor/utils/safe-deserialize.ts b/surfsense_web/components/editor/utils/safe-deserialize.ts new file mode 100644 index 000000000..e359a7791 --- /dev/null +++ b/surfsense_web/components/editor/utils/safe-deserialize.ts @@ -0,0 +1,64 @@ +// --------------------------------------------------------------------------- +// Safe markdown deserialization for the Plate editor +// --------------------------------------------------------------------------- +// `remark-mdx` treats any HTML-like tag as JSX, so unbalanced inline HTML +// (very common in GitHub READMEs, web-scraped pages, PDF conversions) makes +// it throw "Expected a closing tag for ``" and crash the editor. +// +// Per the MDX maintainers' guidance (mdx-js/mdx, ipikuka/next-mdx-remote-client +// #14), MDX is the wrong format for untrusted markdown and the recommended +// fix is to fall back to plain markdown parsing. `MarkdownPlugin.deserialize` +// accepts a per-call `remarkPlugins` override, so we can: +// +// 1. Try with `remarkMdx` (rich MDX features, e.g. JSX-style components). +// 2. On failure, retry without `remarkMdx` (lenient HTML, like GitHub). +// 3. As a last resort, render the raw source in a paragraph so the user +// never sees a crashed editor. +// --------------------------------------------------------------------------- + +import { MarkdownPlugin, remarkMdx } from "@platejs/markdown"; +import type { Descendant } from "platejs"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; +import type { PlateEditorInstance } from "@/components/editor/plate-editor"; + +const STRICT_PLUGINS = [remarkGfm, remarkMath, remarkMdx]; +const LENIENT_PLUGINS = [remarkGfm, remarkMath]; + +function plainTextFallback(markdown: string): Descendant[] { + return [ + { + type: "p", + children: [{ text: markdown }], + } as unknown as Descendant, + ]; +} + +/** + * Deserialize markdown into a Plate value, gracefully degrading when the + * MDX-strict parser rejects raw HTML. Always returns a renderable value; + * never throws. + */ +export function safeDeserializeMarkdown( + editor: PlateEditorInstance, + markdown: string +): Descendant[] { + const api = editor.getApi(MarkdownPlugin).markdown; + + try { + return api.deserialize(markdown, { remarkPlugins: STRICT_PLUGINS }) as Descendant[]; + } catch (mdxError) { + if (process.env.NODE_ENV !== "production") { + console.warn( + "[plate-editor] MDX parse failed, retrying without remark-mdx:", + mdxError + ); + } + try { + return api.deserialize(markdown, { remarkPlugins: LENIENT_PLUGINS }) as Descendant[]; + } catch (fallbackError) { + console.error("[plate-editor] markdown deserialize failed:", fallbackError); + return plainTextFallback(markdown); + } + } +} diff --git a/surfsense_web/components/hitl-bundle-pager/index.ts b/surfsense_web/components/hitl-bundle-pager/index.ts new file mode 100644 index 000000000..ce434d224 --- /dev/null +++ b/surfsense_web/components/hitl-bundle-pager/index.ts @@ -0,0 +1,2 @@ +export { PagerChrome } from "./pager-chrome"; +export { withBundleStep } from "./with-bundle-step"; diff --git a/surfsense_web/components/hitl-bundle-pager/pager-chrome.tsx b/surfsense_web/components/hitl-bundle-pager/pager-chrome.tsx new file mode 100644 index 000000000..77d75fb6d --- /dev/null +++ b/surfsense_web/components/hitl-bundle-pager/pager-chrome.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useHitlBundle } from "@/lib/hitl"; + +/** + * Prev/next nav and Submit for the current step of an active HITL bundle. + * Submission is gated on every action_request having a staged decision. + */ +export function PagerChrome() { + const bundle = useHitlBundle(); + if (!bundle) return null; + + const total = bundle.toolCallIds.length; + const step = bundle.currentStep; + const allStaged = bundle.stagedCount === total; + + return ( +
+ + + {step + 1} / {total} + + · + + {bundle.stagedCount} of {total} decided + + +
+ +
+
+ ); +} diff --git a/surfsense_web/components/hitl-bundle-pager/with-bundle-step.tsx b/surfsense_web/components/hitl-bundle-pager/with-bundle-step.tsx new file mode 100644 index 000000000..64ac801fb --- /dev/null +++ b/surfsense_web/components/hitl-bundle-pager/with-bundle-step.tsx @@ -0,0 +1,37 @@ +"use client"; + +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; +import type { ComponentType } from "react"; +import { ToolCallIdProvider, useHitlBundle } from "@/lib/hitl"; +import { PagerChrome } from "./pager-chrome"; + +/** + * Wrap a tool-ui card so that, when a multi-card HITL bundle is active: + * - cards belonging to the bundle but not the current step render ``null``; + * - the current-step card renders normally and is followed by ``PagerChrome``. + * + * Cards stay completely unchanged — the wrapper provides the + * ``ToolCallIdContext`` that ``useHitlDecision`` reads to stage decisions + * against the right ``toolCallId`` instead of firing the global event. + */ +export function withBundleStep

>( + Component: ComponentType

+): ComponentType

{ + function BundleStepWrapped(props: P) { + const bundle = useHitlBundle(); + const toolCallId = props.toolCallId; + const inBundle = bundle?.isInBundle(toolCallId) ?? false; + const isStep = bundle?.isCurrentStep(toolCallId) ?? false; + + if (bundle && inBundle && !isStep) return null; + + return ( + + + {bundle && isStep ? : null} + + ); + } + BundleStepWrapped.displayName = `withBundleStep(${Component.displayName ?? Component.name ?? "ToolUI"})`; + return BundleStepWrapped as ComponentType

; +} diff --git a/surfsense_web/lib/chat/stream-side-effects.ts b/surfsense_web/lib/chat/stream-side-effects.ts index 136afce44..cdc2f74c6 100644 --- a/surfsense_web/lib/chat/stream-side-effects.ts +++ b/surfsense_web/lib/chat/stream-side-effects.ts @@ -1,91 +1,4 @@ import type { ThreadMessageLike } from "@assistant-ui/react"; -import { - addToolCall, - type ContentPartsState, - type ToolUIGate, - updateToolCall, -} from "@/lib/chat/streaming-state"; - -type InterruptActionRequest = { - name: string; - args: Record; -}; - -export type EditedInterruptAction = { - name: string; - args: Record; -}; - -function readInterruptActions(interruptData: Record): InterruptActionRequest[] { - return (interruptData.action_requests ?? []) as InterruptActionRequest[]; -} - -/** - * Applies an interrupt request payload to tool-call parts. Existing tool cards - * are updated in-place; missing ones are upserted so approval UI always shows. - */ -export function applyInterruptRequestToContentParts( - contentPartsState: ContentPartsState, - toolsWithUI: ToolUIGate, - interruptData: Record -): void { - const { contentParts, toolCallIndices } = contentPartsState; - const actionRequests = readInterruptActions(interruptData); - for (const action of actionRequests) { - const existingEntry = Array.from(toolCallIndices.entries()).find(([, idx]) => { - const part = contentParts[idx]; - return part?.type === "tool-call" && part.toolName === action.name; - }); - - if (existingEntry) { - updateToolCall(contentPartsState, existingEntry[0], { - result: { __interrupt__: true, ...interruptData }, - }); - } else { - const toolCallId = `interrupt-${action.name}`; - addToolCall(contentPartsState, toolsWithUI, toolCallId, action.name, action.args, true); - updateToolCall(contentPartsState, toolCallId, { - result: { __interrupt__: true, ...interruptData }, - }); - } - } -} - -export function mergeEditedInterruptAction( - contentParts: ContentPartsState["contentParts"], - editedAction: EditedInterruptAction | undefined -): void { - if (!editedAction) return; - for (const part of contentParts) { - if (part.type === "tool-call" && part.toolName === editedAction.name) { - const mergedArgs = { ...part.args, ...editedAction.args }; - part.args = mergedArgs; - // assistant-ui prefers argsText over JSON.stringify(args) - part.argsText = JSON.stringify(mergedArgs, null, 2); - break; - } - } -} - -export function markInterruptDecisionOnContentParts( - contentParts: ContentPartsState["contentParts"], - decisionType: "approve" | "reject" | undefined -): void { - if (!decisionType) return; - for (const part of contentParts) { - if ( - part.type === "tool-call" && - typeof part.result === "object" && - part.result !== null && - "__interrupt__" in (part.result as Record) - ) { - part.result = { - ...(part.result as Record), - __decided__: decisionType, - }; - } - } -} /** * When a streamed message is persisted, the backend returns the durable diff --git a/surfsense_web/lib/hitl/bundle-context.tsx b/surfsense_web/lib/hitl/bundle-context.tsx new file mode 100644 index 000000000..3f52ee4d0 --- /dev/null +++ b/surfsense_web/lib/hitl/bundle-context.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react"; +import type { HitlDecision } from "./types"; + +export type BundleSubmit = (orderedDecisions: HitlDecision[]) => void; + +export interface HitlBundleAPI { + toolCallIds: readonly string[]; + currentStep: number; + stagedCount: number; + isInBundle: (toolCallId: string) => boolean; + isCurrentStep: (toolCallId: string) => boolean; + getStaged: (toolCallId: string) => HitlDecision | undefined; + stage: (toolCallId: string, decision: HitlDecision) => void; + goToStep: (i: number) => void; + next: () => void; + prev: () => void; + submit: () => void; +} + +const HitlBundleContext = createContext(null); +const ToolCallIdContext = createContext(null); + +export function useHitlBundle(): HitlBundleAPI | null { + return useContext(HitlBundleContext); +} + +export function useToolCallIdContext(): string | null { + return useContext(ToolCallIdContext); +} + +export function ToolCallIdProvider({ + toolCallId, + children, +}: { + toolCallId: string; + children: ReactNode; +}) { + return {children}; +} + +interface HitlBundleProviderProps { + toolCallIds: readonly string[] | null; + onSubmit: BundleSubmit; + children: ReactNode; +} + +/** + * Activates only when ``toolCallIds`` has 2+ entries; single-card interrupts + * keep their direct ``window`` dispatch path so N=1 UX is unchanged. + */ +export function HitlBundleProvider({ toolCallIds, onSubmit, children }: HitlBundleProviderProps) { + const active = toolCallIds !== null && toolCallIds.length >= 2; + const ids = useMemo(() => (active ? [...toolCallIds] : []), [active, toolCallIds]); + const bundleKey = ids.join("|"); + + // Derived-state-from-props: reset staging + step when the bundle changes. + const [prevBundleKey, setPrevBundleKey] = useState(bundleKey); + const [staged, setStaged] = useState>(() => new Map()); + const [currentStep, setCurrentStep] = useState(0); + if (bundleKey !== prevBundleKey) { + setPrevBundleKey(bundleKey); + setStaged(new Map()); + setCurrentStep(0); + } + + const isInBundle = useCallback((tcId: string) => ids.includes(tcId), [ids]); + const isCurrentStep = useCallback( + (tcId: string) => active === true && ids[currentStep] === tcId, + [active, ids, currentStep] + ); + const getStaged = useCallback((tcId: string) => staged.get(tcId), [staged]); + const stage = useCallback( + (tcId: string, decision: HitlDecision) => { + if (!active || !ids.includes(tcId)) return; + setStaged((prev) => { + const next = new Map(prev); + next.set(tcId, decision); + return next; + }); + // Mirror the staged decision onto the card immediately so prev/next + // nav doesn't re-show approve/reject buttons for already-decided cards. + // Submit's ``hitl-decision`` event re-applies these (no-op) and runs + // the actual resume. + window.dispatchEvent( + new CustomEvent("hitl-stage", { detail: { toolCallId: tcId, decision } }) + ); + const idx = ids.indexOf(tcId); + if (idx >= 0 && idx < ids.length - 1) { + setCurrentStep(idx + 1); + } + }, + [active, ids] + ); + const goToStep = useCallback( + (i: number) => { + if (i < 0 || i >= ids.length) return; + setCurrentStep(i); + }, + [ids.length] + ); + const next = useCallback(() => { + setCurrentStep((s) => Math.min(s + 1, Math.max(0, ids.length - 1))); + }, [ids.length]); + const prev = useCallback(() => { + setCurrentStep((s) => Math.max(s - 1, 0)); + }, []); + + const submit = useCallback(() => { + if (!active) return; + if (staged.size !== ids.length) return; + const ordered: HitlDecision[] = []; + for (const tcId of ids) { + const d = staged.get(tcId); + if (!d) return; + ordered.push(d); + } + onSubmit(ordered); + }, [active, ids, staged, onSubmit]); + + const value = useMemo(() => { + if (!active) return null; + return { + toolCallIds: ids, + currentStep, + stagedCount: staged.size, + isInBundle, + isCurrentStep, + getStaged, + stage, + goToStep, + next, + prev, + submit, + }; + }, [ + active, + ids, + currentStep, + staged, + isInBundle, + isCurrentStep, + getStaged, + stage, + goToStep, + next, + prev, + submit, + ]); + + return {children}; +} diff --git a/surfsense_web/lib/hitl/index.ts b/surfsense_web/lib/hitl/index.ts index decf5980d..4bb15e8b5 100644 --- a/surfsense_web/lib/hitl/index.ts +++ b/surfsense_web/lib/hitl/index.ts @@ -1,3 +1,11 @@ +export { + type BundleSubmit, + type HitlBundleAPI, + HitlBundleProvider, + ToolCallIdProvider, + useHitlBundle, + useToolCallIdContext, +} from "./bundle-context"; export type { HitlDecision, InterruptActionRequest, diff --git a/surfsense_web/lib/hitl/use-hitl-decision.ts b/surfsense_web/lib/hitl/use-hitl-decision.ts index 439f35f21..e2aaf8514 100644 --- a/surfsense_web/lib/hitl/use-hitl-decision.ts +++ b/surfsense_web/lib/hitl/use-hitl-decision.ts @@ -1,17 +1,41 @@ /** * Shared hook for dispatching HITL decisions. * - * All tool-ui components that handle approve/reject/edit should use this - * instead of manually constructing `CustomEvent("hitl-decision", ...)`. + * Tool-ui cards always call ``dispatch([decision])``. When a multi-card bundle + * is active (``HitlBundleProvider``), the dispatch is intercepted and staged + * against this card's ``toolCallId`` so the orchestrator can submit one + * ordered N-decision payload. With no bundle active (N=1 path), it falls back + * to the legacy ``window`` event the host listens for in ``page.tsx``. */ import { useCallback } from "react"; +import { useHitlBundle, useToolCallIdContext } from "./bundle-context"; import type { HitlDecision } from "./types"; export function useHitlDecision() { - const dispatch = useCallback((decisions: HitlDecision[]) => { - window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions } })); - }, []); + const bundle = useHitlBundle(); + const toolCallId = useToolCallIdContext(); + + const dispatch = useCallback( + (decisions: HitlDecision[]) => { + if (bundle && toolCallId && bundle.isInBundle(toolCallId) && decisions.length > 0) { + if (decisions.length > 1 && process.env.NODE_ENV !== "production") { + // Tool-ui cards stage one decision per call; a multi-decision + // dispatch into an active bundle would silently drop tail entries. + // eslint-disable-next-line no-console + console.warn( + "[hitl] dispatch received %d decisions inside an active bundle; only [0] will be staged for %s", + decisions.length, + toolCallId + ); + } + bundle.stage(toolCallId, decisions[0]); + return; + } + window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions } })); + }, + [bundle, toolCallId] + ); return { dispatch }; } diff --git a/surfsense_web/next.config.ts b/surfsense_web/next.config.ts index 6cfcb5187..81f349f26 100644 --- a/surfsense_web/next.config.ts +++ b/surfsense_web/next.config.ts @@ -29,6 +29,13 @@ const nextConfig: NextConfig = { hostname: "**", }, ], + // Allow remote SVGs (e.g. README badges from img.shields.io, trendshift.io, + // etc.) which are otherwise blocked by next/image. The CSP below sandboxes + // the SVG and forbids any embedded scripts, which is the mitigation + // recommended by Vercel's NEXTJS_SAFE_SVG_IMAGES conformance rule. + dangerouslyAllowSVG: true, + contentDispositionType: "attachment", + contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;", }, experimental: { optimizePackageImports: [ diff --git a/surfsense_web/package.json b/surfsense_web/package.json index 2adec8638..782409c3c 100644 --- a/surfsense_web/package.json +++ b/surfsense_web/package.json @@ -1,6 +1,6 @@ { "name": "surfsense_web", - "version": "0.0.22", + "version": "0.0.23", "private": true, "description": "SurfSense Frontend", "scripts": {