diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example index ba89059c8..86c1b326f 100644 --- a/surfsense_backend/.env.example +++ b/surfsense_backend/.env.example @@ -282,9 +282,6 @@ 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 deleted file mode 100644 index f568dc6b2..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Deepagents-backed routes: ``subagents/``; main-agent graph under ``main_agent/`` (SRP subpackages).""" - -from __future__ import annotations - -from .main_agent import create_surfsense_deep_agent - -__all__ = ["create_surfsense_deep_agent"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/constants.py b/surfsense_backend/app/agents/multi_agent_chat/constants.py deleted file mode 100644 index 972677502..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/constants.py +++ /dev/null @@ -1,43 +0,0 @@ -"""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 deleted file mode 100644 index b9a18fe53..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Main-agent deep agent: ``runtime/`` (factory), ``graph/`` (compile), ``system_prompt/``, etc.""" - -from __future__ import annotations - -from .runtime import create_surfsense_deep_agent - -__all__ = ["create_surfsense_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 deleted file mode 100644 index 550ba54c5..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/context_prune/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""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 deleted file mode 100644 index c8bf6d6e0..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/context_prune/prune_tool_names.py +++ /dev/null @@ -1,26 +0,0 @@ -"""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 deleted file mode 100644 index e12108484..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""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 deleted file mode 100644 index 7afa30a31..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/compile_graph_sync.py +++ /dev/null @@ -1,85 +0,0 @@ -"""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.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 - -from .middleware import build_main_agent_deepagent_middleware - - -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/graph/middleware/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/__init__.py deleted file mode 100644 index 757ee02f8..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Main-agent graph middleware assembly (SurfSense + LangChain + deepagents).""" - -from __future__ import annotations - -from .deepagent_stack import build_main_agent_deepagent_middleware - -__all__ = ["build_main_agent_deepagent_middleware"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/__init__.py deleted file mode 100644 index d03b571ca..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -"""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/main_agent/graph/middleware/checkpointed_subagent_middleware/config.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/config.py deleted file mode 100644 index 16211686c..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/config.py +++ /dev/null @@ -1,44 +0,0 @@ -"""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/main_agent/graph/middleware/checkpointed_subagent_middleware/constants.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/constants.py deleted file mode 100644 index 6c4519f3a..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/constants.py +++ /dev/null @@ -1,18 +0,0 @@ -"""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/main_agent/graph/middleware/checkpointed_subagent_middleware/middleware.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/middleware.py deleted file mode 100644 index da8a62cdc..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/middleware.py +++ /dev/null @@ -1,103 +0,0 @@ -"""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/main_agent/graph/middleware/checkpointed_subagent_middleware/propagation.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/propagation.py deleted file mode 100644 index 55aae7201..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/propagation.py +++ /dev/null @@ -1,74 +0,0 @@ -"""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/main_agent/graph/middleware/checkpointed_subagent_middleware/resume.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/resume.py deleted file mode 100644 index 0bb477b6b..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/resume.py +++ /dev/null @@ -1,76 +0,0 @@ -"""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/main_agent/graph/middleware/checkpointed_subagent_middleware/task_tool.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/task_tool.py deleted file mode 100644 index d23dc33a9..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/task_tool.py +++ /dev/null @@ -1,231 +0,0 @@ -"""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} - message_text = ( - result["messages"][-1].text.rstrip() if result["messages"][-1].text else "" - ) - 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/main_agent/graph/middleware/deepagent_stack.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/deepagent_stack.py deleted file mode 100644 index 74e47cfab..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/deepagent_stack.py +++ /dev/null @@ -1,506 +0,0 @@ -"""Assemble the main-agent deep-agent middleware list (LangChain + SurfSense + deepagents).""" - -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 deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware -from deepagents.middleware.skills import SkillsMiddleware -from deepagents.middleware.subagents import GENERAL_PURPOSE_SUBAGENT -from langchain.agents.middleware import ( - LLMToolSelectorMiddleware, - ModelCallLimitMiddleware, - ModelFallbackMiddleware, - TodoListMiddleware, - ToolCallLimitMiddleware, -) -from langchain_anthropic.middleware import AnthropicPromptCachingMiddleware -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.shared.permissions import ( - ToolsPermissions, -) -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 ( - ActionLogMiddleware, - AnonymousDocumentMiddleware, - BusyMutexMiddleware, - ClearToolUsesEdit, - DedupHITLToolCallsMiddleware, - DoomLoopMiddleware, - FileIntentMiddleware, - KnowledgeBasePersistenceMiddleware, - KnowledgePriorityMiddleware, - KnowledgeTreeMiddleware, - MemoryInjectionMiddleware, - NoopInjectionMiddleware, - OtelSpanMiddleware, - PermissionMiddleware, - RetryAfterMiddleware, - SpillingContextEditingMiddleware, - SpillToBackendEdit, - SurfSenseFilesystemMiddleware, - ToolCallNameRepairMiddleware, - build_skills_backend_factory, - create_surfsense_compaction_middleware, - default_skills_sources, -) -from app.agents.new_chat.permissions import Rule, Ruleset -from app.agents.new_chat.plugin_loader import ( - PluginContext, - load_allowed_plugin_names_from_env, - load_plugin_middlewares, -) -from app.agents.new_chat.tools.registry import BUILTIN_TOOLS -from app.db import ChatVisibility - -from ...context_prune.prune_tool_names import safe_exclude_tools -from .checkpointed_subagent_middleware import SurfSenseCheckpointedSubAgentMiddleware - - -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]: - """Build ordered middleware for ``create_agent`` (Nones already stripped).""" - _memory_middleware = MemoryInjectionMiddleware( - user_id=user_id, - search_space_id=search_space_id, - thread_visibility=visibility, - ) - - gp_middleware = [ - TodoListMiddleware(), - _memory_middleware, - FileIntentMiddleware(llm=llm), - SurfSenseFilesystemMiddleware( - backend=backend_resolver, - filesystem_mode=filesystem_mode, - search_space_id=search_space_id, - created_by_id=user_id, - thread_id=thread_id, - ), - create_surfsense_compaction_middleware(llm, StateBackend), - PatchToolCallsMiddleware(), - AnthropicPromptCachingMiddleware(unsupported_model_behavior="ignore"), - ] - - # Build permission rulesets up front so the GP subagent can mirror ``ask`` - # rules into ``interrupt_on``: tool calls emitted from within ``task`` runs - # never reach the parent's ``PermissionMiddleware``. - is_desktop_fs = filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER - permission_enabled = flags.enable_permission and not flags.disable_new_agent_stack - permission_rulesets: list[Ruleset] = [] - if permission_enabled or is_desktop_fs: - permission_rulesets.append( - Ruleset( - rules=[Rule(permission="*", pattern="*", action="allow")], - origin="surfsense_defaults", - ) - ) - if is_desktop_fs: - permission_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", - ) - ) - - # Tools that self-prompt via ``request_approval`` must not also appear - # as ``ask`` rules — that would double-prompt the user for one call. - _tool_names_in_use = {t.name for t in tools} - - # Deny parent-bound tools whose ``required_connector`` is missing. - # No-op today (connector subagents are pruned upstream); guards future - # additions to the parent's tool list. - 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: - permission_rulesets.append( - Ruleset(rules=_synthesized, origin="connector_synthesized") - ) - gp_interrupt_on: dict[str, bool] = { - rule.permission: True - for rs in permission_rulesets - for rule in rs.rules - if rule.action == "ask" and rule.permission in _tool_names_in_use - } - - general_purpose_spec: SubAgent = { # type: ignore[typeddict-unknown-key] - **GENERAL_PURPOSE_SUBAGENT, - "model": llm, - "tools": tools, - "middleware": gp_middleware, - } - if gp_interrupt_on: - general_purpose_spec["interrupt_on"] = gp_interrupt_on - - # Deny-only on subagents: ``task`` runs bypass the parent's - # PermissionMiddleware, while bucket-based ask gates own the ask path. - subagent_deny_rulesets: list[Ruleset] = [ - Ruleset( - rules=[r for r in rs.rules if r.action == "deny"], - origin=rs.origin, - ) - for rs in permission_rulesets - ] - subagent_deny_rulesets = [rs for rs in subagent_deny_rulesets if rs.rules] - - subagent_deny_permission_mw: PermissionMiddleware | None = ( - PermissionMiddleware(rulesets=subagent_deny_rulesets) - if subagent_deny_rulesets - else None - ) - - if subagent_deny_permission_mw is not None: - # Run deny check on already-repaired tool calls; insert before - # PatchToolCallsMiddleware (append if the slot moves). - _patch_idx = next( - ( - i - for i, m in enumerate(gp_middleware) - if isinstance(m, PatchToolCallsMiddleware) - ), - len(gp_middleware), - ) - gp_middleware.insert(_patch_idx, subagent_deny_permission_mw) - - registry_subagents: list[SubAgent] = [] - try: - subagent_extra_middleware: list[Any] = [ - TodoListMiddleware(), - SurfSenseFilesystemMiddleware( - backend=backend_resolver, - filesystem_mode=filesystem_mode, - search_space_id=search_space_id, - created_by_id=user_id, - thread_id=thread_id, - ), - ] - if subagent_deny_permission_mw is not None: - subagent_extra_middleware.append(subagent_deny_permission_mw) - registry_subagents = build_subagents( - dependencies=subagent_dependencies, - model=llm, - extra_middleware=subagent_extra_middleware, - mcp_tools_by_agent=mcp_tools_by_agent or {}, - exclude=get_subagents_to_exclude(available_connectors), - disabled_tools=disabled_tools, - ) - logging.info( - "Registry subagents: %s", - [s["name"] for s in registry_subagents], - ) - except Exception: - logging.exception("Registry subagent build failed") - raise - - subagent_specs: list[SubAgent] = [general_purpose_spec, *registry_subagents] - - summarization_mw = create_surfsense_compaction_middleware(llm, StateBackend) - - context_edit_mw = None - if ( - flags.enable_context_editing - and not flags.disable_new_agent_stack - and max_input_tokens - ): - 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]", - ) - context_edit_mw = SpillingContextEditingMiddleware( - edits=[spill_edit, clear_edit], - backend_resolver=backend_resolver, - ) - - retry_mw = ( - RetryAfterMiddleware(max_retries=3) - if flags.enable_retry_after and not flags.disable_new_agent_stack - else None - ) - fallback_mw: ModelFallbackMiddleware | None = None - if flags.enable_model_fallback and not flags.disable_new_agent_stack: - try: - fallback_mw = ModelFallbackMiddleware( - "openai:gpt-4o-mini", - "anthropic:claude-3-5-haiku-20241022", - ) - except Exception: - logging.warning("ModelFallbackMiddleware init failed; skipping.") - fallback_mw = None - model_call_limit_mw = ( - ModelCallLimitMiddleware( - thread_limit=120, - run_limit=80, - exit_behavior="end", - ) - if flags.enable_model_call_limit and not flags.disable_new_agent_stack - else None - ) - tool_call_limit_mw = ( - ToolCallLimitMiddleware( - thread_limit=300, run_limit=80, exit_behavior="continue" - ) - if flags.enable_tool_call_limit and not flags.disable_new_agent_stack - else None - ) - - noop_mw = ( - NoopInjectionMiddleware() - if flags.enable_compaction_v2 and not flags.disable_new_agent_stack - else None - ) - - repair_mw = None - if flags.enable_tool_call_repair and not flags.disable_new_agent_stack: - registered_names: set[str] = {t.name for t in tools} - registered_names |= { - "write_todos", - "ls", - "read_file", - "write_file", - "edit_file", - "glob", - "grep", - "execute", - "task", - "mkdir", - "cd", - "pwd", - "move_file", - "rm", - "rmdir", - "list_tree", - "execute_code", - } - repair_mw = ToolCallNameRepairMiddleware( - registered_tool_names=registered_names, - fuzzy_match_threshold=None, - ) - - doom_loop_mw = ( - DoomLoopMiddleware(threshold=3) - if flags.enable_doom_loop and not flags.disable_new_agent_stack - else None - ) - - permission_mw: PermissionMiddleware | None = ( - PermissionMiddleware(rulesets=permission_rulesets) - if permission_rulesets - else None - ) - - action_log_mw: ActionLogMiddleware | None = None - if ( - flags.enable_action_log - and not flags.disable_new_agent_stack - and thread_id is not None - ): - try: - tool_defs_by_name = {td.name: td for td in BUILTIN_TOOLS} - action_log_mw = 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, - ) - action_log_mw = None - - busy_mutex_mw: BusyMutexMiddleware | None = ( - BusyMutexMiddleware() - if flags.enable_busy_mutex and not flags.disable_new_agent_stack - else None - ) - - otel_mw: OtelSpanMiddleware | None = ( - OtelSpanMiddleware() - if flags.enable_otel and not flags.disable_new_agent_stack - else None - ) - - plugin_middlewares: list[Any] = [] - if flags.enable_plugin_loader and not flags.disable_new_agent_stack: - try: - allowed_names = load_allowed_plugin_names_from_env() - if allowed_names: - plugin_middlewares = 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, - ) - plugin_middlewares = [] - - skills_mw: SkillsMiddleware | None = None - if flags.enable_skills and not flags.disable_new_agent_stack: - try: - skills_factory = build_skills_backend_factory( - search_space_id=search_space_id - if filesystem_mode == FilesystemMode.CLOUD - else None, - ) - skills_mw = SkillsMiddleware( - backend=skills_factory, - sources=default_skills_sources(), - ) - except Exception as exc: # pragma: no cover - defensive - logging.warning("SkillsMiddleware init failed; skipping: %s", exc) - skills_mw = None - - selector_mw: LLMToolSelectorMiddleware | None = None - if ( - flags.enable_llm_tool_selector - and not flags.disable_new_agent_stack - and len(tools) > 30 - ): - try: - selector_mw = 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.") - selector_mw = None - - deepagent_middleware = [ - busy_mutex_mw, - otel_mw, - TodoListMiddleware(), - _memory_middleware, - AnonymousDocumentMiddleware( - anon_session_id=anon_session_id, - ) - if filesystem_mode == FilesystemMode.CLOUD - else None, - KnowledgeTreeMiddleware( - search_space_id=search_space_id, - filesystem_mode=filesystem_mode, - llm=llm, - ) - if filesystem_mode == FilesystemMode.CLOUD - else None, - 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, - ), - FileIntentMiddleware(llm=llm), - SurfSenseFilesystemMiddleware( - backend=backend_resolver, - filesystem_mode=filesystem_mode, - search_space_id=search_space_id, - created_by_id=user_id, - thread_id=thread_id, - ), - KnowledgeBasePersistenceMiddleware( - search_space_id=search_space_id, - created_by_id=user_id, - filesystem_mode=filesystem_mode, - thread_id=thread_id, - ) - if filesystem_mode == FilesystemMode.CLOUD - else None, - skills_mw, - SurfSenseCheckpointedSubAgentMiddleware( - checkpointer=checkpointer, - backend=StateBackend, - subagents=subagent_specs, - ), - selector_mw, - model_call_limit_mw, - tool_call_limit_mw, - context_edit_mw, - summarization_mw, - noop_mw, - retry_mw, - fallback_mw, - repair_mw, - permission_mw, - doom_loop_mw, - action_log_mw, - PatchToolCallsMiddleware(), - DedupHITLToolCallsMiddleware(agent_tools=list(tools)), - *plugin_middlewares, - AnthropicPromptCachingMiddleware(unsupported_model_behavior="ignore"), - ] - return [m for m in deepagent_middleware if m is not None] 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 deleted file mode 100644 index 3d4ae977d..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Async factory: wiring tools, prompts, MCP buckets, then graph compile.""" - -from __future__ import annotations - -from .factory import create_surfsense_deep_agent - -__all__ = ["create_surfsense_deep_agent"] 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 deleted file mode 100644 index 6a6fd39b7..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/factory.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Async factory: tools, system prompt, MCP buckets for subagents, then sync graph compile.""" - -from __future__ import annotations - -import asyncio -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.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 ..graph.compile_graph_sync import build_compiled_agent_graph_sync -from ..system_prompt import build_main_agent_system_prompt -from ..tools import ( - MAIN_AGENT_SURFSENSE_TOOL_NAMES, - MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED, -) - -_perf_log = get_perf_logger() - - -async def create_surfsense_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() - 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("Failed to discover available connectors/document types: %s", e) - _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() - mcp_tools_by_agent = await load_mcp_tools_by_connector(db_session, search_space_id) - _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 - - _t0 = time.perf_counter() - agent = 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_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, - ) - _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 deleted file mode 100644 index d58aecdf4..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""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 deleted file mode 100644 index 151280707..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""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 deleted file mode 100644 index 5f09b9cac..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/compose.py +++ /dev/null @@ -1,53 +0,0 @@ -"""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 deleted file mode 100644 index f29e7f9ef..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/load_md.py +++ /dev/null @@ -1,16 +0,0 @@ -"""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 deleted file mode 100644 index fa85af8d5..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/provider_hints.py +++ /dev/null @@ -1,50 +0,0 @@ -"""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 deleted file mode 100644 index 568b52baf..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""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 deleted file mode 100644 index db3909bbd..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/citations.py +++ /dev/null @@ -1,11 +0,0 @@ -"""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 deleted file mode 100644 index 7de722080..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/provider.py +++ /dev/null @@ -1,9 +0,0 @@ -"""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 deleted file mode 100644 index 90f4cc2d6..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/registry_subagents.py +++ /dev/null @@ -1,27 +0,0 @@ -"""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 deleted file mode 100644 index b14d87002..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/system_instruction.py +++ /dev/null @@ -1,35 +0,0 @@ -"""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 deleted file mode 100644 index bc4d48ef5..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/tools.py +++ /dev/null @@ -1,20 +0,0 @@ -"""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 deleted file mode 100644 index d5b3fea4e..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/tool_instruction_block.py +++ /dev/null @@ -1,86 +0,0 @@ -"""```` + ```` 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 deleted file mode 100644 index b53f8165a..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""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 deleted file mode 100644 index 6bf575501..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/agent_private.md +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index fa95849c1..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/agent_team.md +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 5af3ca1f3..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/citations_off.md +++ /dev/null @@ -1,15 +0,0 @@ - -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 deleted file mode 100644 index 4e6d6ce6d..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/citations_on.md +++ /dev/null @@ -1,15 +0,0 @@ - -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 deleted file mode 100644 index 8b1378917..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/__init__.py +++ /dev/null @@ -1 +0,0 @@ - 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 deleted file mode 100644 index 0f156bf24..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/scrape_webpage.md +++ /dev/null @@ -1,13 +0,0 @@ - -- 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 deleted file mode 100644 index 222709b38..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/search_surfsense_docs.md +++ /dev/null @@ -1,9 +0,0 @@ - -- 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 deleted file mode 100644 index f83fe40b4..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/update_memory_private.md +++ /dev/null @@ -1,16 +0,0 @@ - -- 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 deleted file mode 100644 index 1c74fdf6e..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/update_memory_team.md +++ /dev/null @@ -1,7 +0,0 @@ - -- 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 deleted file mode 100644 index 4789a6ed9..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/web_search.md +++ /dev/null @@ -1,8 +0,0 @@ - -- 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 deleted file mode 100644 index 75c3c0e5f..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/kb_only_policy_private.md +++ /dev/null @@ -1,19 +0,0 @@ - -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 deleted file mode 100644 index 7c4aba1f8..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/kb_only_policy_team.md +++ /dev/null @@ -1,19 +0,0 @@ - -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 deleted file mode 100644 index e91075c35..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/main_agent_tool_routing.md +++ /dev/null @@ -1,27 +0,0 @@ - -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 deleted file mode 100644 index 8f7da14f8..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/memory_protocol_private.md +++ /dev/null @@ -1,6 +0,0 @@ - -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 deleted file mode 100644 index 61d89cc5d..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/memory_protocol_team.md +++ /dev/null @@ -1,6 +0,0 @@ - -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 deleted file mode 100644 index 350da6220..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/parameter_resolution.md +++ /dev/null @@ -1,15 +0,0 @@ - -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 deleted file mode 100644 index 8b1378917..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/__init__.py +++ /dev/null @@ -1 +0,0 @@ - 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 deleted file mode 100644 index 89154c443..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/anthropic.md +++ /dev/null @@ -1,16 +0,0 @@ - -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 deleted file mode 100644 index 4254e9ed5..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/deepseek.md +++ /dev/null @@ -1,18 +0,0 @@ - -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 deleted file mode 100644 index 8b1378917..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/default.md +++ /dev/null @@ -1 +0,0 @@ - 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 deleted file mode 100644 index c72c1bc72..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/google.md +++ /dev/null @@ -1,18 +0,0 @@ - -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 deleted file mode 100644 index 3219e10d3..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/grok.md +++ /dev/null @@ -1,16 +0,0 @@ - -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 deleted file mode 100644 index 3fe07d180..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/kimi.md +++ /dev/null @@ -1,21 +0,0 @@ - -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 deleted file mode 100644 index 7ff3ec912..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/openai_classic.md +++ /dev/null @@ -1,20 +0,0 @@ - -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 deleted file mode 100644 index aad52f995..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/openai_codex.md +++ /dev/null @@ -1,13 +0,0 @@ - -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 deleted file mode 100644 index 6c8a34087..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/openai_reasoning.md +++ /dev/null @@ -1,22 +0,0 @@ - -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 deleted file mode 100644 index 8b1378917..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/__init__.py +++ /dev/null @@ -1 +0,0 @@ - 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 deleted file mode 100644 index 137904545..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/_preamble.md +++ /dev/null @@ -1,9 +0,0 @@ - -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 deleted file mode 100644 index ecec982c1..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/scrape_webpage.md +++ /dev/null @@ -1,10 +0,0 @@ - -- 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 deleted file mode 100644 index cfa32e889..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/search_surfsense_docs.md +++ /dev/null @@ -1,9 +0,0 @@ - -- 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 deleted file mode 100644 index 3ba11f179..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/update_memory_private.md +++ /dev/null @@ -1,12 +0,0 @@ - -- 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 deleted file mode 100644 index 7eaca8818..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/update_memory_team.md +++ /dev/null @@ -1,26 +0,0 @@ - -- 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 deleted file mode 100644 index 79a3a9b12..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/web_search.md +++ /dev/null @@ -1,10 +0,0 @@ - -- 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 deleted file mode 100644 index 80e86e5c8..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""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 deleted file mode 100644 index 5d309261c..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/index.py +++ /dev/null @@ -1,17 +0,0 @@ -"""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/subagents/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/__init__.py deleted file mode 100644 index ca9e4aa3e..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -"""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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 0f7070645..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/agent.py +++ /dev/null @@ -1,55 +0,0 @@ -"""`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 deleted file mode 100644 index 4dd0f67fe..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/description.md +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index c44f131bb..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/system_prompt.md +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index d0fe94217..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -"""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 deleted file mode 100644 index ab9dbc0ea..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/generate_image.py +++ /dev/null @@ -1,247 +0,0 @@ -"""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 deleted file mode 100644 index 938e73bd4..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/index.py +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index 55d9b3565..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/podcast.py +++ /dev/null @@ -1,92 +0,0 @@ -"""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 deleted file mode 100644 index 385100c62..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/report.py +++ /dev/null @@ -1,1061 +0,0 @@ -"""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 deleted file mode 100644 index ece3ce241..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/resume.py +++ /dev/null @@ -1,799 +0,0 @@ -"""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 deleted file mode 100644 index a9f3447ab..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/video_presentation.py +++ /dev/null @@ -1,80 +0,0 @@ -"""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/memory/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/__init__.py deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 0afe207ce..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/agent.py +++ /dev/null @@ -1,55 +0,0 @@ -"""`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 deleted file mode 100644 index 4c2cdcd0e..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/description.md +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 32becf233..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/system_prompt.md +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index 0441a8cb4..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""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 deleted file mode 100644 index 6c65b2cee..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/index.py +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 23375a081..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/update_memory.py +++ /dev/null @@ -1,375 +0,0 @@ -"""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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 1b7998153..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/agent.py +++ /dev/null @@ -1,55 +0,0 @@ -"""`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 deleted file mode 100644 index dd2ced3fb..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/description.md +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index cf558db62..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/system_prompt.md +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index 414cc96f4..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""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 deleted file mode 100644 index 3546d4d01..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/index.py +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index bb7c8e5a3..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/scrape_webpage.py +++ /dev/null @@ -1,300 +0,0 @@ -"""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 deleted file mode 100644 index 0d702be4c..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/search_surfsense_docs.py +++ /dev/null @@ -1,143 +0,0 @@ -"""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 deleted file mode 100644 index 2fe6bd378..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/web_search.py +++ /dev/null @@ -1,241 +0,0 @@ -"""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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 7b78f4565..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/agent.py +++ /dev/null @@ -1,55 +0,0 @@ -"""`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 deleted file mode 100644 index 71d75f67a..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/description.md +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 0f15f137f..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/system_prompt.md +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index a9b004975..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/tools/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""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 deleted file mode 100644 index 08b0e005e..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/tools/index.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 42ccba213..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/agent.py +++ /dev/null @@ -1,55 +0,0 @@ -"""`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 deleted file mode 100644 index 43865ef53..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/description.md +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index a7ef846d5..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/system_prompt.md +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index 13d4c06cb..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 37bcf083e..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/create_event.py +++ /dev/null @@ -1,324 +0,0 @@ -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}" - ) - - if ( - connector.connector_type - == SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR - ): - from app.utils.google_credentials import build_composio_credentials - - cca_id = connector.config.get("composio_connected_account_id") - if cca_id: - creds = build_composio_credentials(cca_id) - else: - return { - "status": "error", - "message": "Composio connected account ID not found for this connector.", - } - 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) - ) - - tz = context.get("timezone", "UTC") - 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: 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 deleted file mode 100644 index 4d9d69b4b..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/delete_event.py +++ /dev/null @@ -1,304 +0,0 @@ -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 - ): - from app.utils.google_credentials import build_composio_credentials - - cca_id = connector.config.get("composio_connected_account_id") - if cca_id: - creds = build_composio_credentials(cca_id) - else: - return { - "status": "error", - "message": "Composio connected account ID not found for this connector.", - } - 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 deleted file mode 100644 index 2538a494b..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/index.py +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index dc6adb822..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/search_events.py +++ /dev/null @@ -1,132 +0,0 @@ -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 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.", - } - - 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 deleted file mode 100644 index 259f52bba..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/update_event.py +++ /dev/null @@ -1,356 +0,0 @@ -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}" - ) - - if ( - connector.connector_type - == SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR - ): - from app.utils.google_credentials import build_composio_credentials - - cca_id = connector.config.get("composio_connected_account_id") - if cca_id: - creds = build_composio_credentials(cca_id) - else: - return { - "status": "error", - "message": "Composio connected account ID not found for this connector.", - } - 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() - ] - - if not update_body: - return { - "status": "error", - "message": "No changes specified. Please provide at least one field to update.", - } - - 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: 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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 057351c77..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/agent.py +++ /dev/null @@ -1,55 +0,0 @@ -"""`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 deleted file mode 100644 index 07ce599a5..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/description.md +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 84014246d..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/system_prompt.md +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index b629234f9..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/tools/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""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 deleted file mode 100644 index 08b0e005e..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/tools/index.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 3b021ee70..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/agent.py +++ /dev/null @@ -1,55 +0,0 @@ -"""`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 deleted file mode 100644 index b6f1353d0..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/description.md +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 4d3b7462c..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/system_prompt.md +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 3bf80b61b..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""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 deleted file mode 100644 index 095413bdb..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/create_page.py +++ /dev/null @@ -1,211 +0,0 @@ -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 deleted file mode 100644 index 7c03c2760..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/delete_page.py +++ /dev/null @@ -1,189 +0,0 @@ -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 deleted file mode 100644 index 28c4ee6ee..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/index.py +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 791d0d8c5..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/update_page.py +++ /dev/null @@ -1,218 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index feacecd78..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/agent.py +++ /dev/null @@ -1,55 +0,0 @@ -"""`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 deleted file mode 100644 index 44065c10b..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/description.md +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 40e9eb314..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/system_prompt.md +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index b4eaec1f0..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 7636aff71..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/_auth.py +++ /dev/null @@ -1,43 +0,0 @@ -"""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 deleted file mode 100644 index c0a3bf3c9..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/index.py +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 3cc99ac17..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/list_channels.py +++ /dev/null @@ -1,87 +0,0 @@ -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 deleted file mode 100644 index d8bf989a1..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/read_messages.py +++ /dev/null @@ -1,100 +0,0 @@ -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 deleted file mode 100644 index 236cd017a..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/send_message.py +++ /dev/null @@ -1,117 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 9ff9bc1f3..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/agent.py +++ /dev/null @@ -1,55 +0,0 @@ -"""`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 deleted file mode 100644 index 9c2575dd2..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/description.md +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 4b19be794..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/system_prompt.md +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index 836b9ee41..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 22d8a8a27..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/create_file.py +++ /dev/null @@ -1,275 +0,0 @@ -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 deleted file mode 100644 index 5864ae972..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/index.py +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 12559b57a..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/trash_file.py +++ /dev/null @@ -1,277 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 5edf37b85..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/agent.py +++ /dev/null @@ -1,55 +0,0 @@ -"""`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 deleted file mode 100644 index db5614805..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/description.md +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 961100261..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/system_prompt.md +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100644 index 294840122..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 0bd044695..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/create_draft.py +++ /dev/null @@ -1,313 +0,0 @@ -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 - ): - from app.utils.google_credentials import build_composio_credentials - - cca_id = connector.config.get("composio_connected_account_id") - if cca_id: - creds = build_composio_credentials(cca_id) - else: - return { - "status": "error", - "message": "Composio connected account ID not found for this Gmail connector.", - } - 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: 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 deleted file mode 100644 index 09082d091..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/index.py +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index deec1627c..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/read_email.py +++ /dev/null @@ -1,100 +0,0 @@ -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.", - } - - 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 deleted file mode 100644 index 2e363609e..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/search_emails.py +++ /dev/null @@ -1,182 +0,0 @@ -import logging -from datetime import datetime -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, -] - -_token_encryption_cache: object | None = None - - -def _get_token_encryption(): - global _token_encryption_cache - if _token_encryption_cache is None: - from app.config import config - from app.utils.oauth_security import TokenEncryption - - if not config.SECRET_KEY: - raise RuntimeError("SECRET_KEY not configured for token decryption.") - _token_encryption_cache = TokenEncryption(config.SECRET_KEY) - return _token_encryption_cache - - -def _build_credentials(connector: SearchSourceConnector): - """Build Google OAuth Credentials from a connector's stored config. - - Handles both native OAuth connectors (with encrypted tokens) and - Composio-backed connectors. Shared by Gmail and Calendar tools. - """ - from app.utils.google_credentials import COMPOSIO_GOOGLE_CONNECTOR_TYPES - - if connector.connector_type in COMPOSIO_GOOGLE_CONNECTOR_TYPES: - from app.utils.google_credentials import build_composio_credentials - - cca_id = connector.config.get("composio_connected_account_id") - if not cca_id: - raise ValueError("Composio connected account ID not found.") - return build_composio_credentials(cca_id) - - from google.oauth2.credentials import Credentials - - cfg = dict(connector.config) - if cfg.get("_token_encrypted"): - enc = _get_token_encryption() - for key in ("token", "refresh_token", "client_secret"): - if cfg.get(key): - cfg[key] = enc.decrypt_token(cfg[key]) - - exp = (cfg.get("expiry") or "").replace("Z", "") - return Credentials( - token=cfg.get("token"), - refresh_token=cfg.get("refresh_token"), - token_uri=cfg.get("token_uri"), - client_id=cfg.get("client_id"), - client_secret=cfg.get("client_secret"), - scopes=cfg.get("scopes", []), - expiry=datetime.fromisoformat(exp) if exp else None, - ) - - -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.", - } - - 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 deleted file mode 100644 index c3f0999f4..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/send_email.py +++ /dev/null @@ -1,315 +0,0 @@ -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 - ): - from app.utils.google_credentials import build_composio_credentials - - cca_id = connector.config.get("composio_connected_account_id") - if cca_id: - creds = build_composio_credentials(cca_id) - else: - return { - "status": "error", - "message": "Composio connected account ID not found for this Gmail connector.", - } - 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 deleted file mode 100644 index 1f1f6227a..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/trash_email.py +++ /dev/null @@ -1,309 +0,0 @@ -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 - ): - from app.utils.google_credentials import build_composio_credentials - - cca_id = connector.config.get("composio_connected_account_id") - if cca_id: - creds = build_composio_credentials(cca_id) - else: - return { - "status": "error", - "message": "Composio connected account ID not found for this Gmail connector.", - } - 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 deleted file mode 100644 index 91178cd21..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/update_draft.py +++ /dev/null @@ -1,410 +0,0 @@ -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 - ): - from app.utils.google_credentials import build_composio_credentials - - cca_id = connector.config.get("composio_connected_account_id") - if cca_id: - creds = build_composio_credentials(cca_id) - else: - return { - "status": "error", - "message": "Composio connected account ID not found for this Gmail connector.", - } - 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: 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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 4b4269e2b..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/agent.py +++ /dev/null @@ -1,55 +0,0 @@ -"""`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 deleted file mode 100644 index 3f54ef8f7..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/description.md +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 09dc0caa2..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/system_prompt.md +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index 9c63bceb1..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index f36db8f3f..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/create_file.py +++ /dev/null @@ -1,283 +0,0 @@ -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}" - ) - - pre_built_creds = None - if ( - connector.connector_type - == SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR - ): - from app.utils.google_credentials import build_composio_credentials - - cca_id = connector.config.get("composio_connected_account_id") - if cca_id: - pre_built_creds = build_composio_credentials(cca_id) - - client = GoogleDriveClient( - session=db_session, - connector_id=actual_connector_id, - credentials=pre_built_creds, - ) - 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}" - ) - 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 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 deleted file mode 100644 index 7dbee87a0..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/index.py +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 832afff0d..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/trash_file.py +++ /dev/null @@ -1,262 +0,0 @@ -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}" - ) - - pre_built_creds = None - if ( - connector.connector_type - == SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR - ): - from app.utils.google_credentials import build_composio_credentials - - cca_id = connector.config.get("composio_connected_account_id") - if cca_id: - pre_built_creds = build_composio_credentials(cca_id) - - client = GoogleDriveClient( - session=db_session, - connector_id=connector.id, - credentials=pre_built_creds, - ) - 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}" - ) - 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 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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index b381c6bcf..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/agent.py +++ /dev/null @@ -1,55 +0,0 @@ -"""`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 deleted file mode 100644 index 2cd7e082a..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/description.md +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 4f4ae8a66..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/system_prompt.md +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 768738118..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""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 deleted file mode 100644 index 8b40dde65..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/create_issue.py +++ /dev/null @@ -1,216 +0,0 @@ -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 deleted file mode 100644 index 6466c80ea..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/delete_issue.py +++ /dev/null @@ -1,183 +0,0 @@ -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 deleted file mode 100644 index 342f120be..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/index.py +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index f6e586a2e..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/update_issue.py +++ /dev/null @@ -1,226 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 4c3d1d3a5..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/agent.py +++ /dev/null @@ -1,55 +0,0 @@ -"""`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 deleted file mode 100644 index 6ad02c788..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/description.md +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index ce91cc49f..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/system_prompt.md +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index 31acf1e2a..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""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 deleted file mode 100644 index ff254e133..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/create_issue.py +++ /dev/null @@ -1,248 +0,0 @@ -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 deleted file mode 100644 index 29ef0cdf2..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/delete_issue.py +++ /dev/null @@ -1,245 +0,0 @@ -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 deleted file mode 100644 index f1ee49964..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/index.py +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index f35d0dddd..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/update_issue.py +++ /dev/null @@ -1,318 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 343874c33..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/agent.py +++ /dev/null @@ -1,55 +0,0 @@ -"""`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 deleted file mode 100644 index 9eaae4ac5..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/description.md +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index a2b4b7391..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/system_prompt.md +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 255119bee..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index c6d1cd148..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/_auth.py +++ /dev/null @@ -1,39 +0,0 @@ -"""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 deleted file mode 100644 index 0a24a988f..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/create_event.py +++ /dev/null @@ -1,129 +0,0 @@ -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 deleted file mode 100644 index 47b303295..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/index.py +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index aec5ad220..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/list_events.py +++ /dev/null @@ -1,111 +0,0 @@ -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 deleted file mode 100644 index b37a9d617..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/read_event.py +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 8c8a80ab5..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/agent.py +++ /dev/null @@ -1,55 +0,0 @@ -"""`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 deleted file mode 100644 index f1d51c18a..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/description.md +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index a40e9f4d0..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/system_prompt.md +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index 6ce825dca..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""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 deleted file mode 100644 index 6efffe960..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/create_page.py +++ /dev/null @@ -1,244 +0,0 @@ -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 deleted file mode 100644 index 07f7583d2..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/delete_page.py +++ /dev/null @@ -1,262 +0,0 @@ -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 deleted file mode 100644 index c78f630a1..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/index.py +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 85c08177c..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/update_page.py +++ /dev/null @@ -1,265 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 551388d34..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/agent.py +++ /dev/null @@ -1,55 +0,0 @@ -"""`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 deleted file mode 100644 index 31ea14624..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/description.md +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index a2f3617ba..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/system_prompt.md +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index 8edb4857e..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 21272e01d..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/create_file.py +++ /dev/null @@ -1,252 +0,0 @@ -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 deleted file mode 100644 index 9a2dadd36..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/index.py +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index a7f13b5df..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/trash_file.py +++ /dev/null @@ -1,281 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index b72f82dab..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/agent.py +++ /dev/null @@ -1,55 +0,0 @@ -"""`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 deleted file mode 100644 index 246f79dfe..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/description.md +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 009a3205c..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/system_prompt.md +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index f60078771..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/tools/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""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 deleted file mode 100644 index 08b0e005e..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/tools/index.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index aa6f34935..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/agent.py +++ /dev/null @@ -1,55 +0,0 @@ -"""`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 deleted file mode 100644 index 4fc1579b2..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/description.md +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 8c0eebdd1..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/system_prompt.md +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 60e2add49..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 7cdbeb819..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/_auth.py +++ /dev/null @@ -1,38 +0,0 @@ -"""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 deleted file mode 100644 index cbe76b040..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/index.py +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index d7b000853..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/list_channels.py +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index d24a7e4d3..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/read_messages.py +++ /dev/null @@ -1,103 +0,0 @@ -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 deleted file mode 100644 index fd8d00870..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/send_message.py +++ /dev/null @@ -1,115 +0,0 @@ -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 deleted file mode 100644 index c8714cd04..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -"""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 deleted file mode 100644 index 79ab3db10..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/index.py +++ /dev/null @@ -1,165 +0,0 @@ -"""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 deleted file mode 100644 index f24dedcf2..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -"""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 deleted file mode 100644 index d2d426ef2..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/airtable.py +++ /dev/null @@ -1,16 +0,0 @@ -"""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 deleted file mode 100644 index 9ddec5fe8..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/clickup.py +++ /dev/null @@ -1,15 +0,0 @@ -"""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 deleted file mode 100644 index 10781c9d9..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/index.py +++ /dev/null @@ -1,10 +0,0 @@ -"""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 deleted file mode 100644 index 5a67c9dc1..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/jira.py +++ /dev/null @@ -1,20 +0,0 @@ -"""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 deleted file mode 100644 index 18fd827dc..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/linear.py +++ /dev/null @@ -1,32 +0,0 @@ -"""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 deleted file mode 100644 index f9c9d3635..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/slack.py +++ /dev/null @@ -1,16 +0,0 @@ -"""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 deleted file mode 100644 index 1b7a19ad7..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/registry.py +++ /dev/null @@ -1,196 +0,0 @@ -"""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 deleted file mode 100644 index 12443da88..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -"""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 deleted file mode 100644 index 2fce413a6..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/md_file_reader.py +++ /dev/null @@ -1,14 +0,0 @@ -"""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 deleted file mode 100644 index 649478485..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/permissions.py +++ /dev/null @@ -1,60 +0,0 @@ -"""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 deleted file mode 100644 index b6614afa9..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/subagent_builder.py +++ /dev/null @@ -1,47 +0,0 @@ -"""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 1f4024d9d..36739adae 100644 --- a/surfsense_backend/app/agents/new_chat/chat_deepagent.py +++ b/surfsense_backend/app/agents/new_chat/chat_deepagent.py @@ -723,10 +723,6 @@ 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", diff --git a/surfsense_backend/app/agents/new_chat/feature_flags.py b/surfsense_backend/app/agents/new_chat/feature_flags.py index b3dc0fa82..1f5a08ec6 100644 --- a/surfsense_backend/app/agents/new_chat/feature_flags.py +++ b/surfsense_backend/app/agents/new_chat/feature_flags.py @@ -250,19 +250,24 @@ class AgentFeatureFlags: ) -def get_flags() -> AgentFeatureFlags: - """Return the resolved feature-flag state from the **current** process environment. +# 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 - 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 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 def reload_for_tests() -> AgentFeatureFlags: - """Compatibility helper for tests; equivalent to :func:`get_flags`.""" - return AgentFeatureFlags.from_env() + """Force a fresh read from env. Tests should call this after monkeypatching env.""" + global _FLAGS + _FLAGS = AgentFeatureFlags.from_env() + return _FLAGS __all__ = [ diff --git a/surfsense_backend/app/agents/new_chat/memory_extraction.py b/surfsense_backend/app/agents/new_chat/memory_extraction.py index e31774a7c..221c4c75a 100644 --- a/surfsense_backend/app/agents/new_chat/memory_extraction.py +++ b/surfsense_backend/app/agents/new_chat/memory_extraction.py @@ -16,7 +16,6 @@ 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__) @@ -145,7 +144,11 @@ async def extract_and_save_memory( [HumanMessage(content=prompt)], config={"tags": ["surfsense:internal", "memory-extraction"]}, ) - text = extract_text_content(response.content).strip() + text = ( + response.content + if isinstance(response.content, str) + else str(response.content) + ).strip() if text == "NO_UPDATE" or not text: logger.debug("Memory extraction: no update needed (user %s)", uid) @@ -204,7 +207,11 @@ async def extract_and_save_team_memory( [HumanMessage(content=prompt)], config={"tags": ["surfsense:internal", "team-memory-extraction"]}, ) - text = extract_text_content(response.content).strip() + text = ( + response.content + if isinstance(response.content, str) + else str(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 e7d9b8f75..06a27bc96 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/busy_mutex.py +++ b/surfsense_backend/app/agents/new_chat/middleware/busy_mutex.py @@ -134,23 +134,6 @@ 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 a6d2ce310..c55347284 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,7 +21,6 @@ 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 @@ -58,19 +57,6 @@ 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 5ea7f1740..37719e96a 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/permission.py +++ b/surfsense_backend/app/agents/new_chat/middleware/permission.py @@ -19,9 +19,8 @@ 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(...)``. Both the legacy - SurfSense shape and LangChain HITL ``{"decisions": [{"type": ...}]}`` - replies are accepted via :func:`_normalize_permission_decision`. +4. On ``ask``: raises a SurfSense-style ``interrupt(...)``. The reply + shape is ``{"decision_type": "once|always|reject", "feedback"?: str}``. - ``once``: proceed. - ``always``: also persist allow rules for ``request.always`` patterns. - ``reject`` w/o feedback: raise :class:`RejectedError`. @@ -82,75 +81,6 @@ 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. @@ -284,7 +214,12 @@ class PermissionMiddleware(AgentMiddleware): # type: ignore[type-arg] ot.interrupt_span(interrupt_type="permission_ask"), ): decision = interrupt(payload) - return _normalize_permission_decision(decision) + 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"} def _persist_always(self, tool_name: str, patterns: list[str]) -> None: """Promote ``always`` reply into runtime allow rules. @@ -420,5 +355,4 @@ class PermissionMiddleware(AgentMiddleware): # type: ignore[type-arg] __all__ = [ "PatternResolver", "PermissionMiddleware", - "_normalize_permission_decision", ] 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 b8bb069e2..ec667bf88 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,19 +5,12 @@ 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, 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. +- Linear (issues) → list_issues, get_issue, save_issue (create/update) - 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`, and other `slack_*` tools when connected. For **multi-step Slack-only** work, delegate with `task` to **`slack_specialist`**. +- Slack (messages, channels) → slack_search_channels, slack_read_channel, slack_read_thread - 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 b081a2123..48b7a990b 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,19 +5,12 @@ 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, 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. +- Linear (issues) → list_issues, get_issue, save_issue (create/update) - 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`, and other `slack_*` tools when connected. For **multi-step Slack-only** work, delegate with `task` to **`slack_specialist`**. +- Slack (messages, channels) → slack_search_channels, slack_read_channel, slack_read_thread - 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 2f1bfacd9..8b1378917 100644 --- a/surfsense_backend/app/agents/new_chat/prompts/routing/linear.md +++ b/surfsense_backend/app/agents/new_chat/prompts/routing/linear.md @@ -1,3 +1 @@ - -**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 4b5d07a9a..8b1378917 100644 --- a/surfsense_backend/app/agents/new_chat/prompts/routing/slack.md +++ b/surfsense_backend/app/agents/new_chat/prompts/routing/slack.md @@ -1,3 +1 @@ - -**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 bd1823b57..7d678ec79 100644 --- a/surfsense_backend/app/agents/new_chat/subagents/__init__.py +++ b/surfsense_backend/app/agents/new_chat/subagents/__init__.py @@ -20,14 +20,10 @@ 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 b993d2b06..84ca516e0 100644 --- a/surfsense_backend/app/agents/new_chat/subagents/config.py +++ b/surfsense_backend/app/agents/new_chat/subagents/config.py @@ -22,12 +22,6 @@ 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 @@ -427,12 +421,6 @@ 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 deleted file mode 100644 index cb1da499b..000000000 --- a/surfsense_backend/app/agents/new_chat/subagents/constants.py +++ /dev/null @@ -1,35 +0,0 @@ -"""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 deleted file mode 100644 index da332fe28..000000000 --- a/surfsense_backend/app/agents/new_chat/subagents/providers/linear.py +++ /dev/null @@ -1,162 +0,0 @@ -"""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 deleted file mode 100644 index 90ca80152..000000000 --- a/surfsense_backend/app/agents/new_chat/subagents/providers/slack.py +++ /dev/null @@ -1,170 +0,0 @@ -"""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 70634c65d..56f838d7e 100644 --- a/surfsense_backend/app/agents/new_chat/system_prompt.py +++ b/surfsense_backend/app/agents/new_chat/system_prompt.py @@ -26,9 +26,6 @@ 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 = ( @@ -66,7 +63,6 @@ def build_surfsense_system_prompt( mcp_connector_tools=mcp_connector_tools, citations_enabled=True, model_name=model_name, - connector_routing=_DEFAULT_CONNECTOR_ROUTING, ) @@ -97,7 +93,6 @@ 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 92a808a5e..5b96ab374 100644 --- a/surfsense_backend/app/agents/new_chat/tools/mcp_tool.py +++ b/surfsense_backend/app/agents/new_chat/tools/mcp_tool.py @@ -33,7 +33,6 @@ 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 @@ -46,10 +45,7 @@ _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 -# 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]]] = {} +_mcp_tools_cache: dict[int, tuple[float, list[StructuredTool]]] = {} def _evict_expired_mcp_cache() -> None: @@ -141,13 +137,12 @@ 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). - 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). + All MCP tools are unconditionally wrapped with HITL approval. + ``request_approval()`` is called OUTSIDE the try/except so that + ``GraphInterrupt`` propagates cleanly to LangGraph. """ tool_name = tool_def.get("name", "unnamed_tool") raw_description = tool_def.get("description", "No description provided") @@ -166,29 +161,24 @@ 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) - 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} - ) + # 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} + ) last_error: Exception | None = None for attempt in range(_TOOL_CALL_MAX_RETRIES): @@ -231,9 +221,7 @@ async def _create_mcp_tool_from_definition_stdio( "mcp_connector_name": connector_name or None, "mcp_is_generic": True, "hitl": True, - # Full-args hash: shared identifiers (cloudId, workspaceId, …) - # would otherwise collapse legitimate batches. - "dedup_key": dedup_key_full_args, + "hitl_dedup_key": next(iter(input_schema.get("required", [])), None), }, ) @@ -252,14 +240,11 @@ 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. Set - ``bypass_internal_hitl=True`` when an outer ``HumanInTheLoopMiddleware`` - already gates the tool. + ``readonly_tools``) execute immediately without user confirmation. 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``) @@ -317,7 +302,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 or bypass_internal_hitl: + if is_readonly: call_kwargs = _unpack_synthetic_input_data( {k: v for k, v in kwargs.items() if v is not None} ) @@ -400,9 +385,7 @@ 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, - # Full-args hash: shared identifiers (cloudId, workspaceId, …) - # would otherwise collapse legitimate batches. - "dedup_key": dedup_key_full_args, + "hitl_dedup_key": next(iter(input_schema.get("required", [])), None), "mcp_original_tool_name": original_tool_name, "mcp_connector_id": connector_id, }, @@ -417,8 +400,6 @@ 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] = [] @@ -470,7 +451,6 @@ 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: @@ -493,8 +473,6 @@ 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. @@ -620,7 +598,6 @@ 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: @@ -928,10 +905,14 @@ 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 (both ``bypass_internal_hitl`` variants together).""" + """Invalidate cached MCP tools. + + Args: + search_space_id: If provided, only invalidate for this search space. + If None, invalidate all cached MCP tools. + """ if search_space_id is not None: - for key in [k for k in _mcp_tools_cache if k[0] == search_space_id]: - _mcp_tools_cache.pop(key, None) + _mcp_tools_cache.pop(search_space_id, None) else: _mcp_tools_cache.clear() @@ -939,29 +920,27 @@ 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 the user's active MCP server connectors. + """Load all MCP tools from user's active MCP server connectors. - 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). + 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. """ _evict_expired_mcp_cache() now = time.monotonic() - cache_key: _MCPCacheKey = (search_space_id, bypass_internal_hitl) - cached = _mcp_tools_cache.get(cache_key) + cached = _mcp_tools_cache.get(search_space_id) 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, bypass_hitl=%s)", + "Using cached MCP tools for search space %s (%d tools, age=%.0fs)", search_space_id, len(cached_tools), now - cached_at, - bypass_internal_hitl, ) return list(cached_tools) @@ -1085,7 +1064,6 @@ 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, ) @@ -1096,7 +1074,6 @@ 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, ) @@ -1118,17 +1095,14 @@ 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[cache_key] = (now, tools) + _mcp_tools_cache[search_space_id] = (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 (bypass_hitl=%s)", - len(tools), - search_space_id, - bypass_internal_hitl, + "Loaded %d MCP tools for search space %d", len(tools), search_space_id ) 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 062668aac..fbc9edbba 100644 --- a/surfsense_backend/app/agents/new_chat/tools/update_memory.py +++ b/surfsense_backend/app/agents/new_chat/tools/update_memory.py @@ -27,7 +27,6 @@ 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__) @@ -189,11 +188,12 @@ async def _forced_rewrite(content: str, llm: Any) -> str | None: [HumanMessage(content=prompt)], config={"tags": ["surfsense:internal"]}, ) - text = extract_text_content(response.content).strip() - if not text: - logger.warning("Forced rewrite returned empty text; aborting rewrite") - return None - return text + 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 @@ -235,16 +235,6 @@ 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 f6f0c7f62..97b4cf509 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -587,9 +587,6 @@ 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/routes/memory_routes.py b/surfsense_backend/app/routes/memory_routes.py index e57ca4055..f5df45cf1 100644 --- a/surfsense_backend/app/routes/memory_routes.py +++ b/surfsense_backend/app/routes/memory_routes.py @@ -16,7 +16,6 @@ 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__) @@ -124,7 +123,11 @@ async def edit_user_memory( [HumanMessage(content=prompt)], config={"tags": ["surfsense:internal", "memory-edit"]}, ) - updated = extract_text_content(response.content).strip() + updated = ( + response.content + if isinstance(response.content, str) + else str(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 ad96654f5..2ade207d4 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -2396,7 +2396,6 @@ 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 0f0e43035..5ecfb1814 100644 --- a/surfsense_backend/app/routes/search_spaces_routes.py +++ b/surfsense_backend/app/routes/search_spaces_routes.py @@ -35,7 +35,6 @@ 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__) @@ -357,7 +356,11 @@ async def edit_team_memory( [HumanMessage(content=prompt)], config={"tags": ["surfsense:internal", "memory-edit"]}, ) - updated = extract_text_content(response.content).strip() + updated = ( + response.content + if isinstance(response.content, str) + else str(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 95d183433..1a85484fa 100644 --- a/surfsense_backend/app/schemas/new_chat.py +++ b/surfsense_backend/app/schemas/new_chat.py @@ -367,9 +367,6 @@ 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/mcp_oauth/registry.py b/surfsense_backend/app/services/mcp_oauth/registry.py index 310c3f6e8..835d70184 100644 --- a/surfsense_backend/app/services/mcp_oauth/registry.py +++ b/surfsense_backend/app/services/mcp_oauth/registry.py @@ -14,57 +14,10 @@ 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: @@ -97,8 +50,12 @@ MCP_SERVICES: dict[str, MCPServiceConfig] = { name="Linear", mcp_url="https://mcp.linear.app/mcp", connector_type="LINEAR_CONNECTOR", - allowed_tools=sorted(LINEAR_MCP_TOOL_NAMES), - readonly_tools=LINEAR_MCP_READONLY_TOOL_NAMES, + allowed_tools=[ + "list_issues", + "get_issue", + "save_issue", + ], + readonly_tools=frozenset({"list_issues", "get_issue"}), 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 3ba3912eb..487602c3b 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -28,9 +28,6 @@ from langchain_core.messages import HumanMessage from sqlalchemy.future import select from sqlalchemy.orm import selectinload -from app.agents.multi_agent_chat import ( - create_surfsense_deep_agent as create_registry_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 @@ -85,8 +82,6 @@ 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 @@ -2443,10 +2438,6 @@ 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, @@ -2761,22 +2752,13 @@ 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_registry_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( - agent_factory( + create_surfsense_deep_agent( llm=llm, search_space_id=search_space_id, db_session=session, @@ -2880,7 +2862,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 agent_factory( + agent = await create_surfsense_deep_agent( llm=llm, search_space_id=search_space_id, db_session=session, @@ -3652,12 +3634,6 @@ 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, @@ -3831,16 +3807,6 @@ 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 @@ -3867,7 +3833,6 @@ 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() @@ -3886,13 +3851,11 @@ 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, @@ -4126,16 +4089,10 @@ 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_registry_deep_agent - if _app_config.MULTI_AGENT_CHAT_ENABLED - else create_surfsense_deep_agent - ) agent_build_task = asyncio.create_task( - agent_factory( + create_surfsense_deep_agent( llm=llm, search_space_id=search_space_id, db_session=session, @@ -4147,7 +4104,6 @@ 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", ) @@ -4224,7 +4180,7 @@ async def stream_resume_chat( "fallback_config_id": llm_config_id, }, ) - agent = await agent_factory( + agent = await create_surfsense_deep_agent( llm=llm, search_space_id=search_space_id, db_session=session, @@ -4236,7 +4192,6 @@ 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: @@ -4262,9 +4217,6 @@ 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 @@ -4535,12 +4487,6 @@ 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, @@ -4675,16 +4621,6 @@ 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/tests/unit/agents/new_chat/test_dedup_tool_calls.py b/surfsense_backend/tests/unit/agents/new_chat/test_dedup_tool_calls.py index 61d9b499f..e04f50815 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,79 +130,6 @@ 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 d0ea73376..0bbdf37bf 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,7 +27,6 @@ class TestDefaultAutoApprovedToolsList: expected = { "create_gmail_draft", "update_gmail_draft", - "create_calendar_event", "create_notion_page", "create_confluence_page", "create_google_drive_file", @@ -42,12 +41,13 @@ class TestDefaultAutoApprovedToolsList: assert isinstance(DEFAULT_AUTO_APPROVED_TOOLS, frozenset) def test_send_tools_are_not_auto_approved(self) -> None: - # External-broadcast / destructive tools must always prompt. + # External-broadcast 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 deleted file mode 100644 index 535e4e940..000000000 --- a/surfsense_backend/tests/unit/agents/new_chat/test_memory_response_content.py +++ /dev/null @@ -1,87 +0,0 @@ -"""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 eda5be150..a997c8d61 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,10 +6,7 @@ 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, - _normalize_permission_decision, -) +from app.agents.new_chat.middleware.permission import PermissionMiddleware from app.agents.new_chat.permissions import Rule, Ruleset pytestmark = pytest.mark.unit @@ -115,151 +112,3 @@ 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 3035cc8e0..0adb578ce 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,16 +210,10 @@ class TestConnectorNegotiatorSubagent: class TestBuildSpecializedSubagents: - def test_returns_five_specs(self) -> None: + def test_returns_three_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", - "linear_specialist", - "slack_specialist", - "connector_negotiator", - ] + assert names == ["explore", "report_writer", "connector_negotiator"] def test_all_specs_have_unique_names(self) -> None: specs = build_specialized_subagents(tools=ALL_TOOLS) 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 9b5510df3..f2bae4167 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,7 +107,6 @@ 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, @@ -139,62 +138,6 @@ 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) */ @@ -266,7 +209,6 @@ export default function NewChatPage() { threadId: number; assistantMsgId: string; interruptData: Record; - bundleToolCallIds: string[]; } | null>(null); const toolsWithUI = TOOLS_WITH_UI_ALL; const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom); @@ -1126,39 +1068,7 @@ export default function NewChatPage() { case "data-interrupt-request": { wasInterrupted = true; const interruptData = parsed.data as Record; - 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); - } + applyInterruptRequestToContentParts(contentPartsState, toolsWithUI, interruptData); setMessages((prev) => prev.map((m) => m.id === assistantMsgId @@ -1171,7 +1081,6 @@ export default function NewChatPage() { threadId: currentThreadId, assistantMsgId, interruptData, - bundleToolCallIds, }); } break; @@ -1218,7 +1127,10 @@ 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 ) ); @@ -1260,7 +1172,10 @@ 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 ) ); @@ -1427,39 +1342,14 @@ export default function NewChatPage() { } } - // 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, - }; - } + // Merge edited args if present to fix race condition + if (decisions.length > 0 && decisions[0].type === "edit") { + mergeEditedInterruptAction(contentParts, decisions[0].edited_action); } + 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"; const selection = await getAgentFilesystemSelection(searchSpaceId, { @@ -1475,7 +1365,6 @@ 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, @@ -1524,39 +1413,7 @@ export default function NewChatPage() { switch (parsed.type) { case "data-interrupt-request": { const interruptData = parsed.data as Record; - 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); - } + applyInterruptRequestToContentParts(contentPartsState, toolsWithUI, interruptData); setMessages((prev) => prev.map((m) => m.id === assistantMsgId @@ -1568,7 +1425,6 @@ export default function NewChatPage() { threadId: resumeThreadId, assistantMsgId, interruptData, - bundleToolCallIds, }); break; } @@ -1614,7 +1470,10 @@ 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 ) ); @@ -1651,7 +1510,6 @@ export default function NewChatPage() { messages, searchSpaceId, localFilesystemEnabled, - disabledTools, queryClient, tokenUsageStore, fetchWithTurnCancellingRetry, @@ -1668,119 +1526,57 @@ export default function NewChatPage() { edited_action?: { name: string; args: Record }; }>; }; - if (!detail?.decisions || !pendingInterrupt) return; - const incoming = detail.decisions; - if (incoming.length === 0) return; - const tcIds = pendingInterrupt.bundleToolCallIds; - const N = tcIds.length; + if (detail?.decisions && pendingInterrupt) { + const decision = detail.decisions[0]; + const decisionType = decision?.type as "approve" | "reject" | "edit"; - // 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.` + 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"] }; + }) ); - return; + handleResume(detail.decisions); } - - 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, @@ -2064,7 +1860,10 @@ 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 ) ); @@ -2090,7 +1889,10 @@ 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 ) ); @@ -2279,12 +2081,6 @@ 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; @@ -2355,19 +2151,14 @@ export default function NewChatPage() { - -
-
- -
- - - +
+
+
- + + + +
{ diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 7bccc22ee..3b9d9a526 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -40,7 +40,6 @@ 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, @@ -503,51 +502,6 @@ 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)"); @@ -559,8 +513,47 @@ const AssistantMessageInner: FC = () => { Text: MarkdownText, Reasoning: ReasoningMessagePart, tools: { - by_name: TOOLS_BY_NAME, - Fallback: TOOLS_FALLBACK, + 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, }, }} /> diff --git a/surfsense_web/components/hitl-bundle-pager/index.ts b/surfsense_web/components/hitl-bundle-pager/index.ts deleted file mode 100644 index ce434d224..000000000 --- a/surfsense_web/components/hitl-bundle-pager/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 77d75fb6d..000000000 --- a/surfsense_web/components/hitl-bundle-pager/pager-chrome.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"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 deleted file mode 100644 index 64ac801fb..000000000 --- a/surfsense_web/components/hitl-bundle-pager/with-bundle-step.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"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 cdc2f74c6..136afce44 100644 --- a/surfsense_web/lib/chat/stream-side-effects.ts +++ b/surfsense_web/lib/chat/stream-side-effects.ts @@ -1,4 +1,91 @@ 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 deleted file mode 100644 index 3f52ee4d0..000000000 --- a/surfsense_web/lib/hitl/bundle-context.tsx +++ /dev/null @@ -1,153 +0,0 @@ -"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 4bb15e8b5..decf5980d 100644 --- a/surfsense_web/lib/hitl/index.ts +++ b/surfsense_web/lib/hitl/index.ts @@ -1,11 +1,3 @@ -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 e2aaf8514..439f35f21 100644 --- a/surfsense_web/lib/hitl/use-hitl-decision.ts +++ b/surfsense_web/lib/hitl/use-hitl-decision.ts @@ -1,41 +1,17 @@ /** * Shared hook for dispatching HITL decisions. * - * 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``. + * All tool-ui components that handle approve/reject/edit should use this + * instead of manually constructing `CustomEvent("hitl-decision", ...)`. */ import { useCallback } from "react"; -import { useHitlBundle, useToolCallIdContext } from "./bundle-context"; import type { HitlDecision } from "./types"; export function useHitlDecision() { - 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] - ); + const dispatch = useCallback((decisions: HitlDecision[]) => { + window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions } })); + }, []); return { dispatch }; }